fix(memory): isolate recall ledger by owner

This commit is contained in:
mateaix 2026-09-08 22:23:45 +08:00
parent c59b6b6448
commit dfae7edbe0
6 changed files with 252 additions and 34 deletions

View File

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

View File

@ -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, ""));
}
}
}

View File

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

View File

@ -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());
}
}
}

View File

@ -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());

View File

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