mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
fix(memory): isolate recall ledger by owner
This commit is contained in:
parent
c59b6b6448
commit
dfae7edbe0
@ -324,11 +324,11 @@ public class AgentService {
|
||||
*/
|
||||
public String chat(Long agentId, String message, String conversationId, ChatOrigin origin) {
|
||||
clearAutoRecordedForNewTurn(conversationId);
|
||||
memoryRecallTracker.trackRecalls(agentId, message);
|
||||
if (isDshAgent(agentId)) {
|
||||
return collectChatResult(chatStructuredStream(agentId, message, conversationId,
|
||||
"", null, origin != null ? origin : ChatOrigin.EMPTY)).content();
|
||||
}
|
||||
trackMemoryRecalls(agentId, message, origin);
|
||||
BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId);
|
||||
ChatOriginHolder.set(origin != null ? origin : ChatOrigin.EMPTY);
|
||||
try {
|
||||
@ -365,13 +365,13 @@ public class AgentService {
|
||||
|
||||
public Flux<String> chatStream(Long agentId, String message, String conversationId, ChatOrigin origin) {
|
||||
clearAutoRecordedForNewTurn(conversationId);
|
||||
memoryRecallTracker.trackRecalls(agentId, message);
|
||||
if (isDshAgent(agentId)) {
|
||||
return chatStructuredStream(agentId, message, conversationId, "", null,
|
||||
origin != null ? origin : ChatOrigin.EMPTY)
|
||||
.filter(delta -> delta.content() != null)
|
||||
.map(StreamDelta::content);
|
||||
}
|
||||
trackMemoryRecalls(agentId, message, origin);
|
||||
BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId);
|
||||
// Capture the origin into a request-scoped holder; cleared on Flux
|
||||
// termination so the next reactive subscriber doesn't inherit stale state.
|
||||
@ -408,7 +408,7 @@ public class AgentService {
|
||||
String requesterId, String thinkingLevel,
|
||||
ChatOrigin origin) {
|
||||
clearAutoRecordedForNewTurn(conversationId);
|
||||
memoryRecallTracker.trackRecalls(agentId, message);
|
||||
trackMemoryRecalls(agentId, message, origin);
|
||||
if (isDshAgent(agentId)) {
|
||||
AgentEntity dshAgent = getAgent(agentId);
|
||||
return withLifecycleFlux(agentId, message, conversationId,
|
||||
@ -468,7 +468,7 @@ public class AgentService {
|
||||
|
||||
public String execute(Long agentId, String goal, String conversationId, ChatOrigin origin) {
|
||||
clearAutoRecordedForNewTurn(conversationId);
|
||||
memoryRecallTracker.trackRecalls(agentId, goal);
|
||||
trackMemoryRecalls(agentId, goal, origin);
|
||||
BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId);
|
||||
ChatOriginHolder.set(origin != null ? origin : ChatOrigin.EMPTY);
|
||||
try {
|
||||
@ -495,7 +495,7 @@ public class AgentService {
|
||||
|
||||
public String chatWithReplay(Long agentId, String userMessage, String conversationId,
|
||||
String toolCallPayload, ChatOrigin origin) {
|
||||
memoryRecallTracker.trackRecalls(agentId, userMessage);
|
||||
trackMemoryRecalls(agentId, userMessage, origin);
|
||||
BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId);
|
||||
ChatOriginHolder.set(origin != null ? origin : ChatOrigin.EMPTY);
|
||||
try {
|
||||
@ -543,7 +543,7 @@ public class AgentService {
|
||||
public Flux<StreamDelta> chatWithReplayStream(Long agentId, String userMessage, String conversationId,
|
||||
String toolCallPayload, String requesterId,
|
||||
ChatOrigin origin) {
|
||||
memoryRecallTracker.trackRecalls(agentId, userMessage);
|
||||
trackMemoryRecalls(agentId, userMessage, origin);
|
||||
BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId);
|
||||
ChatOrigin captured = origin != null ? origin : ChatOrigin.EMPTY;
|
||||
return Flux.defer(() -> {
|
||||
@ -755,6 +755,13 @@ public class AgentService {
|
||||
return "dsh".equalsIgnoreCase(entity.getRuntimeType());
|
||||
}
|
||||
|
||||
private void trackMemoryRecalls(Long agentId, String message, ChatOrigin origin) {
|
||||
String ownerKey = memoryProperties.isLifecycleMediatorEnabled()
|
||||
? memoryOwnerResolver.resolve(origin != null ? origin : ChatOrigin.EMPTY)
|
||||
: null;
|
||||
memoryRecallTracker.trackRecalls(agentId, message, ownerKey);
|
||||
}
|
||||
|
||||
private void validateDshConfiguration(AgentEntity agent) {
|
||||
if (!"dsh".equalsIgnoreCase(agent.getRuntimeType())) return;
|
||||
if (dshRuntimeService == null) {
|
||||
|
||||
@ -8,6 +8,7 @@ import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import vip.mate.memory.MemoryProperties;
|
||||
import vip.mate.memory.identity.MemoryScope;
|
||||
import vip.mate.memory.model.MemoryRecallEntity;
|
||||
import vip.mate.memory.repository.MemoryRecallMapper;
|
||||
|
||||
@ -55,9 +56,21 @@ public class MemoryRecallService {
|
||||
* 记录一次文件召回
|
||||
*/
|
||||
public void recordRecall(Long agentId, String filename, String snippetText, String userQueryHash) {
|
||||
recordRecall(agentId, filename, snippetText, userQueryHash, null, MemoryScope.TEAM);
|
||||
}
|
||||
|
||||
/** Owner-aware recall ledger write. Shared legacy rows keep a null owner key. */
|
||||
public void recordRecall(Long agentId, String filename, String snippetText, String userQueryHash,
|
||||
String ownerKey, String scope) {
|
||||
if (agentId == null || filename == null || filename.isBlank()) {
|
||||
return;
|
||||
}
|
||||
String effectiveScope = normalizeScope(scope);
|
||||
String effectiveOwner = MemoryScope.PERSONAL.equals(effectiveScope) ? ownerKey : null;
|
||||
if (MemoryScope.PERSONAL.equals(effectiveScope)
|
||||
&& (effectiveOwner == null || effectiveOwner.isBlank())) {
|
||||
return;
|
||||
}
|
||||
// 写库前硬截断:覆盖所有调用路径(含 trackActiveRetrieval 透传的外部 filename),
|
||||
// 防 filename 突破 VARCHAR(256) 导致写入失败(#461)
|
||||
filename = truncateFilename(filename);
|
||||
@ -67,12 +80,13 @@ public class MemoryRecallService {
|
||||
? snippetText.substring(0, 200)
|
||||
: snippetText;
|
||||
|
||||
MemoryRecallEntity existing = recallMapper.selectOne(
|
||||
new LambdaQueryWrapper<MemoryRecallEntity>()
|
||||
LambdaQueryWrapper<MemoryRecallEntity> existingQuery = new LambdaQueryWrapper<MemoryRecallEntity>()
|
||||
.eq(MemoryRecallEntity::getAgentId, agentId)
|
||||
.eq(MemoryRecallEntity::getFilename, filename)
|
||||
.eq(MemoryRecallEntity::getDeleted, 0)
|
||||
.last("LIMIT 1"));
|
||||
.eq(MemoryRecallEntity::getScope, effectiveScope)
|
||||
.eq(MemoryRecallEntity::getDeleted, 0);
|
||||
applyOwnerIdentity(existingQuery, effectiveOwner, effectiveScope);
|
||||
MemoryRecallEntity existing = recallMapper.selectOne(existingQuery.last("LIMIT 1"));
|
||||
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
|
||||
@ -103,6 +117,8 @@ public class MemoryRecallService {
|
||||
entity.setLastRecalledAt(now);
|
||||
entity.setPromoted(false);
|
||||
entity.setScore(0.0);
|
||||
entity.setOwnerKey(effectiveOwner);
|
||||
entity.setScope(effectiveScope);
|
||||
entity.setCreateTime(now);
|
||||
entity.setUpdateTime(now);
|
||||
entity.setDeleted(0);
|
||||
@ -115,12 +131,13 @@ public class MemoryRecallService {
|
||||
} catch (org.springframework.dao.DuplicateKeyException e) {
|
||||
// 并发插入冲突,重新查询后更新(不递归,避免 StackOverflow)
|
||||
log.debug("[MemoryRecall] Concurrent insert for {}, falling back to update", filename);
|
||||
MemoryRecallEntity retry = recallMapper.selectOne(
|
||||
new LambdaQueryWrapper<MemoryRecallEntity>()
|
||||
LambdaQueryWrapper<MemoryRecallEntity> retryQuery = new LambdaQueryWrapper<MemoryRecallEntity>()
|
||||
.eq(MemoryRecallEntity::getAgentId, agentId)
|
||||
.eq(MemoryRecallEntity::getFilename, filename)
|
||||
.eq(MemoryRecallEntity::getDeleted, 0)
|
||||
.last("LIMIT 1"));
|
||||
.eq(MemoryRecallEntity::getScope, effectiveScope)
|
||||
.eq(MemoryRecallEntity::getDeleted, 0);
|
||||
applyOwnerIdentity(retryQuery, effectiveOwner, effectiveScope);
|
||||
MemoryRecallEntity retry = recallMapper.selectOne(retryQuery.last("LIMIT 1"));
|
||||
if (retry != null) {
|
||||
retry.setRecallCount(retry.getRecallCount() + 1);
|
||||
retry.setDailyCount(retry.getDailyCount() + 1);
|
||||
@ -157,6 +174,9 @@ public class MemoryRecallService {
|
||||
return recallMapper.selectList(
|
||||
new LambdaQueryWrapper<MemoryRecallEntity>()
|
||||
.eq(MemoryRecallEntity::getAgentId, agentId)
|
||||
// Current Dream writes shared MEMORY.md. Keep PERSONAL
|
||||
// candidates out until consolidation itself is owner-aware.
|
||||
.in(MemoryRecallEntity::getScope, MemoryScope.TEAM, MemoryScope.GLOBAL)
|
||||
.eq(MemoryRecallEntity::getPromoted, false)
|
||||
.eq(MemoryRecallEntity::getDeleted, 0)
|
||||
.orderByDesc(MemoryRecallEntity::getScore));
|
||||
@ -280,10 +300,12 @@ public class MemoryRecallService {
|
||||
long total = recallMapper.selectCount(
|
||||
new LambdaQueryWrapper<MemoryRecallEntity>()
|
||||
.eq(MemoryRecallEntity::getAgentId, agentId)
|
||||
.in(MemoryRecallEntity::getScope, MemoryScope.TEAM, MemoryScope.GLOBAL)
|
||||
.eq(MemoryRecallEntity::getDeleted, 0));
|
||||
long promoted = recallMapper.selectCount(
|
||||
new LambdaQueryWrapper<MemoryRecallEntity>()
|
||||
.eq(MemoryRecallEntity::getAgentId, agentId)
|
||||
.in(MemoryRecallEntity::getScope, MemoryScope.TEAM, MemoryScope.GLOBAL)
|
||||
.eq(MemoryRecallEntity::getPromoted, true)
|
||||
.eq(MemoryRecallEntity::getDeleted, 0));
|
||||
long pending = total - promoted;
|
||||
@ -307,6 +329,7 @@ public class MemoryRecallService {
|
||||
List<MemoryRecallEntity> candidates = recallMapper.selectList(
|
||||
new LambdaQueryWrapper<MemoryRecallEntity>()
|
||||
.eq(MemoryRecallEntity::getAgentId, agentId)
|
||||
.in(MemoryRecallEntity::getScope, MemoryScope.TEAM, MemoryScope.GLOBAL)
|
||||
.eq(MemoryRecallEntity::getDeleted, 0)
|
||||
.orderByDesc(MemoryRecallEntity::getScore));
|
||||
|
||||
@ -374,4 +397,23 @@ public class MemoryRecallService {
|
||||
}
|
||||
}
|
||||
|
||||
private static String normalizeScope(String scope) {
|
||||
if (MemoryScope.PERSONAL.equals(scope) || MemoryScope.GLOBAL.equals(scope)) {
|
||||
return scope;
|
||||
}
|
||||
return MemoryScope.TEAM;
|
||||
}
|
||||
|
||||
private static void applyOwnerIdentity(LambdaQueryWrapper<MemoryRecallEntity> query,
|
||||
String ownerKey, String scope) {
|
||||
if (MemoryScope.PERSONAL.equals(scope)) {
|
||||
query.eq(MemoryRecallEntity::getOwnerKey, ownerKey);
|
||||
} else {
|
||||
// V137 left legacy shared recall rows with NULL while newer rows may
|
||||
// use the workspace-file empty-string sentinel. Treat both as shared.
|
||||
query.and(w -> w.isNull(MemoryRecallEntity::getOwnerKey)
|
||||
.or().eq(MemoryRecallEntity::getOwnerKey, ""));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@ import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Component;
|
||||
import vip.mate.memory.identity.MemoryScope;
|
||||
import vip.mate.workspace.document.model.WorkspaceFileEntity;
|
||||
import vip.mate.workspace.document.repository.WorkspaceFileMapper;
|
||||
|
||||
@ -44,15 +45,32 @@ public class MemoryRecallTracker {
|
||||
*/
|
||||
@Async
|
||||
public void trackRecalls(Long agentId, String userQuery) {
|
||||
trackRecalls(agentId, userQuery, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Track only the shared files plus PERSONAL files visible to {@code ownerKey}.
|
||||
* The owner and scope are copied into the recall ledger so downstream Dream
|
||||
* processing cannot collapse two owners' same-named files into one candidate.
|
||||
*/
|
||||
@Async
|
||||
public void trackRecalls(Long agentId, String userQuery, String ownerKey) {
|
||||
try {
|
||||
// 精确复现 buildSystemPrompt 的注入条件
|
||||
LambdaQueryWrapper<WorkspaceFileEntity> query = new LambdaQueryWrapper<WorkspaceFileEntity>()
|
||||
.eq(WorkspaceFileEntity::getAgentId, agentId)
|
||||
.eq(WorkspaceFileEntity::getEnabled, true)
|
||||
.isNotNull(WorkspaceFileEntity::getContent)
|
||||
.ne(WorkspaceFileEntity::getContent, "");
|
||||
if (ownerKey == null || ownerKey.isBlank()) {
|
||||
query.in(WorkspaceFileEntity::getScope, MemoryScope.TEAM, MemoryScope.GLOBAL);
|
||||
} else {
|
||||
query.and(w -> w
|
||||
.in(WorkspaceFileEntity::getScope, MemoryScope.TEAM, MemoryScope.GLOBAL)
|
||||
.or(p -> p.eq(WorkspaceFileEntity::getScope, MemoryScope.PERSONAL)
|
||||
.eq(WorkspaceFileEntity::getOwnerKey, ownerKey)));
|
||||
}
|
||||
List<WorkspaceFileEntity> injectedFiles = workspaceFileMapper.selectList(
|
||||
new LambdaQueryWrapper<WorkspaceFileEntity>()
|
||||
.eq(WorkspaceFileEntity::getAgentId, agentId)
|
||||
.eq(WorkspaceFileEntity::getEnabled, true)
|
||||
.isNotNull(WorkspaceFileEntity::getContent)
|
||||
.ne(WorkspaceFileEntity::getContent, "")
|
||||
.orderByAsc(WorkspaceFileEntity::getSortOrder));
|
||||
query.orderByAsc(WorkspaceFileEntity::getSortOrder));
|
||||
|
||||
if (injectedFiles.isEmpty()) {
|
||||
return;
|
||||
@ -62,6 +80,10 @@ public class MemoryRecallTracker {
|
||||
int trackedCount = 0;
|
||||
|
||||
for (WorkspaceFileEntity file : injectedFiles) {
|
||||
// Defence in depth for custom mappers/tests and future query refactors.
|
||||
if (!isVisibleToOwner(file, ownerKey)) {
|
||||
continue;
|
||||
}
|
||||
String content = file.getContent();
|
||||
if (content == null || content.isBlank()) {
|
||||
continue;
|
||||
@ -71,10 +93,12 @@ public class MemoryRecallTracker {
|
||||
|
||||
if (filename.startsWith("memory/") && filename.endsWith(".md")) {
|
||||
// daily note: 按 ## 标题拆分为独立片段
|
||||
trackedCount += trackDailyNoteSnippets(agentId, filename, content, queryHash);
|
||||
trackedCount += trackDailyNoteSnippets(agentId, filename, content, queryHash,
|
||||
file.getOwnerKey(), file.getScope());
|
||||
} else {
|
||||
// 非 daily note (PROFILE.md, MEMORY.md 等): 文件级追踪
|
||||
recallService.recordRecall(agentId, filename, content, queryHash);
|
||||
recallService.recordRecall(agentId, filename, content, queryHash,
|
||||
file.getOwnerKey(), file.getScope());
|
||||
trackedCount++;
|
||||
}
|
||||
}
|
||||
@ -88,7 +112,8 @@ public class MemoryRecallTracker {
|
||||
/**
|
||||
* 将 daily note 按 ## 标题拆分为独立片段,分别追踪
|
||||
*/
|
||||
private int trackDailyNoteSnippets(Long agentId, String filename, String content, String queryHash) {
|
||||
private int trackDailyNoteSnippets(Long agentId, String filename, String content, String queryHash,
|
||||
String ownerKey, String scope) {
|
||||
Matcher matcher = SECTION_PATTERN.matcher(content);
|
||||
List<Integer> sectionStarts = new java.util.ArrayList<>();
|
||||
while (matcher.find()) {
|
||||
@ -97,7 +122,7 @@ public class MemoryRecallTracker {
|
||||
|
||||
if (sectionStarts.isEmpty()) {
|
||||
// 没有 ## 标题,整个文件作为一个片段
|
||||
recallService.recordRecall(agentId, filename, content.trim(), queryHash);
|
||||
recallService.recordRecall(agentId, filename, content.trim(), queryHash, ownerKey, scope);
|
||||
return 1;
|
||||
}
|
||||
|
||||
@ -106,7 +131,7 @@ public class MemoryRecallTracker {
|
||||
if (sectionStarts.get(0) > 0) {
|
||||
String preamble = content.substring(0, sectionStarts.get(0)).trim();
|
||||
if (!preamble.isEmpty()) {
|
||||
recallService.recordRecall(agentId, filename + "#preamble", preamble, queryHash);
|
||||
recallService.recordRecall(agentId, filename + "#preamble", preamble, queryHash, ownerKey, scope);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
@ -119,7 +144,7 @@ public class MemoryRecallTracker {
|
||||
// 从 ## 标题行提取 section 标识
|
||||
String firstLine = snippet.contains("\n") ? snippet.substring(0, snippet.indexOf('\n')).trim() : snippet;
|
||||
String sectionKey = filename + "#" + sanitizeSectionKey(firstLine);
|
||||
recallService.recordRecall(agentId, sectionKey, snippet, queryHash);
|
||||
recallService.recordRecall(agentId, sectionKey, snippet, queryHash, ownerKey, scope);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
@ -149,17 +174,33 @@ public class MemoryRecallTracker {
|
||||
*/
|
||||
@Async
|
||||
public void trackActiveRetrieval(Long agentId, String filename, String content) {
|
||||
trackActiveRetrieval(agentId, filename, content, null, MemoryScope.TEAM);
|
||||
}
|
||||
|
||||
@Async
|
||||
public void trackActiveRetrieval(Long agentId, String filename, String content,
|
||||
String ownerKey, String scope) {
|
||||
try {
|
||||
if (agentId == null || filename == null || content == null || content.isBlank()) {
|
||||
return;
|
||||
}
|
||||
recallService.recordRecall(agentId, filename, content, "__active_read__");
|
||||
recallService.recordRecall(agentId, filename, content, "__active_read__", ownerKey, scope);
|
||||
log.debug("[MemoryRecall] Tracked active retrieval: agent={}, file={}", agentId, filename);
|
||||
} catch (Exception e) {
|
||||
log.warn("[MemoryRecall] Failed to track active retrieval for agent={}: {}", agentId, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
static boolean isVisibleToOwner(WorkspaceFileEntity file, String ownerKey) {
|
||||
String scope = file.getScope();
|
||||
if (scope == null || scope.isBlank() || MemoryScope.TEAM.equals(scope) || MemoryScope.GLOBAL.equals(scope)) {
|
||||
return true;
|
||||
}
|
||||
return MemoryScope.PERSONAL.equals(scope)
|
||||
&& ownerKey != null && !ownerKey.isBlank()
|
||||
&& ownerKey.equals(file.getOwnerKey());
|
||||
}
|
||||
|
||||
private String sha256Short(String text) {
|
||||
if (text == null || text.isBlank()) return null;
|
||||
try {
|
||||
|
||||
@ -115,7 +115,8 @@ public class WorkspaceMemoryTool {
|
||||
|
||||
// 追踪主动检索信号(比被动注入更强的"真实需要"指标)
|
||||
String content = file.getContent() != null ? file.getContent() : "";
|
||||
memoryRecallTracker.trackActiveRetrieval(parsedAgentId, filename, content);
|
||||
memoryRecallTracker.trackActiveRetrieval(parsedAgentId, filename, content,
|
||||
file.getOwnerKey(), file.getScope());
|
||||
|
||||
JSONObject result = new JSONObject();
|
||||
result.set("agentId", String.valueOf(agentId));
|
||||
@ -282,7 +283,8 @@ public class WorkspaceMemoryTool {
|
||||
// PERSONAL row when present) so PERSONAL hits track correctly.
|
||||
WorkspaceFileEntity file = workspaceFileService.getVisibleFile(parsedAgentId, hit.filename(), ownerKey);
|
||||
if (file != null && file.getContent() != null) {
|
||||
memoryRecallTracker.trackActiveRetrieval(parsedAgentId, hit.filename(), file.getContent());
|
||||
memoryRecallTracker.trackActiveRetrieval(parsedAgentId, hit.filename(), file.getContent(),
|
||||
file.getOwnerKey(), file.getScope());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -19,6 +19,7 @@ import vip.mate.workspace.conversation.repository.ConversationMapper;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.ArgumentMatchers.isNull;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
@ -74,7 +75,7 @@ class LifecycleRecallCountIT {
|
||||
}
|
||||
|
||||
// trackRecalls: exactly 10 times (once per chat call)
|
||||
verify(memoryRecallTracker, times(10)).trackRecalls(eq(1L), any());
|
||||
verify(memoryRecallTracker, times(10)).trackRecalls(eq(1L), any(), isNull());
|
||||
|
||||
// Mediator is not invoked when flag is off
|
||||
verify(memoryManager, never()).prefetchAll(any(), any(), any());
|
||||
@ -92,11 +93,12 @@ class LifecycleRecallCountIT {
|
||||
}
|
||||
|
||||
// trackRecalls: still exactly 10 times — NOT 20 (D4: mediator does not call trackRecalls)
|
||||
verify(memoryRecallTracker, times(10)).trackRecalls(eq(1L), any());
|
||||
verify(memoryRecallTracker, times(10)).trackRecalls(eq(1L), any(), eq("system"));
|
||||
|
||||
// Mediator IS invoked
|
||||
verify(memoryManager, times(10)).prefetchAll(eq(1L), any(), any());
|
||||
verify(memoryManager, times(10)).syncAll(eq(1L), eq("conv-1"), any(), any());
|
||||
verify(memoryManager, times(10)).syncAll(
|
||||
eq(1L), eq("conv-1"), any(), any(), eq("system"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@ -116,7 +118,8 @@ class LifecycleRecallCountIT {
|
||||
}
|
||||
|
||||
// Total: 10 trackRecalls calls regardless of flag state
|
||||
verify(memoryRecallTracker, times(10)).trackRecalls(eq(1L), any());
|
||||
verify(memoryRecallTracker, times(5)).trackRecalls(eq(1L), any(), isNull());
|
||||
verify(memoryRecallTracker, times(5)).trackRecalls(eq(1L), any(), eq("system"));
|
||||
|
||||
// Mediator only called for the ON rounds
|
||||
verify(memoryManager, times(5)).prefetchAll(eq(1L), any(), any());
|
||||
|
||||
@ -0,0 +1,123 @@
|
||||
package vip.mate.memory.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import vip.mate.memory.MemoryProperties;
|
||||
import vip.mate.memory.identity.MemoryScope;
|
||||
import vip.mate.memory.model.MemoryRecallEntity;
|
||||
import vip.mate.memory.repository.MemoryRecallMapper;
|
||||
import vip.mate.workspace.document.model.WorkspaceFileEntity;
|
||||
import vip.mate.workspace.document.repository.WorkspaceFileMapper;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class MemoryRecallOwnerIsolationTest {
|
||||
|
||||
@BeforeAll
|
||||
static void initLambdaCache() {
|
||||
TableInfoHelper.initTableInfo(
|
||||
new MapperBuilderAssistant(new MybatisConfiguration(), ""),
|
||||
MemoryRecallEntity.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("tracker records shared and current-owner files but rejects another owner's row")
|
||||
void trackerPropagatesOnlyVisibleOwnerIdentity() {
|
||||
MemoryRecallService recallService = mock(MemoryRecallService.class);
|
||||
WorkspaceFileMapper fileMapper = mock(WorkspaceFileMapper.class);
|
||||
WorkspaceFileEntity shared = file("MEMORY.md", "shared", "", MemoryScope.TEAM);
|
||||
WorkspaceFileEntity ownerA = file("PROFILE.md", "owner-a", "user:a", MemoryScope.PERSONAL);
|
||||
WorkspaceFileEntity ownerB = file("PROFILE.md", "owner-b", "user:b", MemoryScope.PERSONAL);
|
||||
when(fileMapper.selectList(any())).thenReturn(List.of(shared, ownerA, ownerB));
|
||||
|
||||
new MemoryRecallTracker(recallService, fileMapper)
|
||||
.trackRecalls(7L, "what do you remember?", "user:a");
|
||||
|
||||
verify(recallService).recordRecall(eq(7L), eq("MEMORY.md"), eq("shared"),
|
||||
anyString(), eq(""), eq(MemoryScope.TEAM));
|
||||
verify(recallService).recordRecall(eq(7L), eq("PROFILE.md"), eq("owner-a"),
|
||||
anyString(), eq("user:a"), eq(MemoryScope.PERSONAL));
|
||||
verify(recallService, never()).recordRecall(eq(7L), eq("PROFILE.md"), eq("owner-b"),
|
||||
anyString(), eq("user:b"), eq(MemoryScope.PERSONAL));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("owner-aware ledger insert persists PERSONAL scope and owner")
|
||||
void recordRecallPersistsPersonalIdentity() {
|
||||
MemoryRecallMapper mapper = mock(MemoryRecallMapper.class);
|
||||
when(mapper.selectOne(any())).thenReturn(null);
|
||||
MemoryRecallService service = new MemoryRecallService(mapper, new MemoryProperties(), new ObjectMapper());
|
||||
|
||||
service.recordRecall(7L, "PROFILE.md", "private preference", "query-hash",
|
||||
"user:a", MemoryScope.PERSONAL);
|
||||
|
||||
ArgumentCaptor<MemoryRecallEntity> inserted = ArgumentCaptor.forClass(MemoryRecallEntity.class);
|
||||
verify(mapper).insert(inserted.capture());
|
||||
assertEquals("user:a", inserted.getValue().getOwnerKey());
|
||||
assertEquals(MemoryScope.PERSONAL, inserted.getValue().getScope());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("legacy recordRecall overload remains shared and ownerless")
|
||||
void legacyRecordRecallRemainsShared() {
|
||||
MemoryRecallMapper mapper = mock(MemoryRecallMapper.class);
|
||||
when(mapper.selectOne(any())).thenReturn(null);
|
||||
MemoryRecallService service = new MemoryRecallService(mapper, new MemoryProperties(), new ObjectMapper());
|
||||
|
||||
service.recordRecall(7L, "MEMORY.md", "shared memory", "query-hash");
|
||||
|
||||
ArgumentCaptor<MemoryRecallEntity> inserted = ArgumentCaptor.forClass(MemoryRecallEntity.class);
|
||||
verify(mapper).insert(inserted.capture());
|
||||
assertNull(inserted.getValue().getOwnerKey());
|
||||
assertEquals(MemoryScope.TEAM, inserted.getValue().getScope());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("shared Dream candidate query excludes PERSONAL scope")
|
||||
@SuppressWarnings({"rawtypes", "unchecked"})
|
||||
void dreamCandidatesStaySharedOnly() {
|
||||
MemoryRecallMapper mapper = mock(MemoryRecallMapper.class);
|
||||
when(mapper.selectList(any())).thenReturn(List.of());
|
||||
MemoryRecallService service = new MemoryRecallService(mapper, new MemoryProperties(), new ObjectMapper());
|
||||
|
||||
service.listCandidates(7L);
|
||||
|
||||
ArgumentCaptor<LambdaQueryWrapper<MemoryRecallEntity>> query =
|
||||
ArgumentCaptor.forClass(LambdaQueryWrapper.class);
|
||||
verify(mapper).selectList(query.capture());
|
||||
query.getValue().getSqlSegment();
|
||||
String parameters = query.getValue().getParamNameValuePairs().values().toString();
|
||||
assertTrue(parameters.contains(MemoryScope.TEAM));
|
||||
assertTrue(parameters.contains(MemoryScope.GLOBAL));
|
||||
assertFalse(parameters.contains(MemoryScope.PERSONAL));
|
||||
}
|
||||
|
||||
private static WorkspaceFileEntity file(String filename, String content, String ownerKey, String scope) {
|
||||
WorkspaceFileEntity file = new WorkspaceFileEntity();
|
||||
file.setFilename(filename);
|
||||
file.setContent(content);
|
||||
file.setOwnerKey(ownerKey);
|
||||
file.setScope(scope);
|
||||
file.setEnabled(true);
|
||||
return file;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user