mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(executor): retention sweep and per-conversation purge for tool-result spill files
This commit is contained in:
parent
7015d1513c
commit
86a6829102
@ -97,6 +97,23 @@ public class ToolResultProperties {
|
||||
*/
|
||||
private List<String> excludedTools = List.of("read_file", "read_workspace_memory_file");
|
||||
|
||||
/**
|
||||
* Days to retain spill files before the scheduled cleanup deletes them.
|
||||
* Spill files exist to let the model recover full tool output via
|
||||
* {@code read_file} during the active conversation; once the conversation
|
||||
* is dormant for this many days the agent is extremely unlikely to ever
|
||||
* read the file again, and disk pressure starts to matter.
|
||||
*/
|
||||
private int retentionDays = 7;
|
||||
|
||||
/**
|
||||
* Cron expression for the spill-cleanup task. Defaults to once a day at
|
||||
* 03:00 server-local time so cleanup runs during quiet hours. Set this
|
||||
* to a Spring-recognised value (six-field cron) or change the bean
|
||||
* wiring to disable it entirely.
|
||||
*/
|
||||
private String cleanupCron = "0 0 3 * * ?";
|
||||
|
||||
public boolean isEnabled() { return enabled; }
|
||||
public void setEnabled(boolean enabled) { this.enabled = enabled; }
|
||||
|
||||
@ -130,6 +147,14 @@ public class ToolResultProperties {
|
||||
this.excludedTools = excludedTools == null ? List.of() : excludedTools;
|
||||
}
|
||||
|
||||
public int getRetentionDays() { return retentionDays; }
|
||||
public void setRetentionDays(int retentionDays) { this.retentionDays = retentionDays; }
|
||||
|
||||
public String getCleanupCron() { return cleanupCron; }
|
||||
public void setCleanupCron(String cleanupCron) {
|
||||
this.cleanupCron = cleanupCron == null ? "" : cleanupCron;
|
||||
}
|
||||
|
||||
/** O(1) membership test for the exclusion list, used on every tool result. */
|
||||
public Set<String> excludedToolsSet() {
|
||||
return Set.copyOf(excludedTools);
|
||||
|
||||
@ -0,0 +1,54 @@
|
||||
package vip.mate.agent.graph.executor;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Drives {@link ToolResultStorage#cleanupExpired()} on a cron schedule so
|
||||
* spill files don't accumulate forever. Kept in its own class instead of
|
||||
* inlined into {@link ToolResultStorage} for two reasons:
|
||||
*
|
||||
* <ul>
|
||||
* <li>Tests can exercise {@code cleanupExpired()} directly without
|
||||
* fighting the Spring scheduler.</li>
|
||||
* <li>Deployments that want to disable the schedule entirely can simply
|
||||
* leave this component out of the autoconfigure path.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>The cron expression comes from
|
||||
* {@link ToolResultProperties#getCleanupCron()} (default {@code 0 0 3 * * ?},
|
||||
* i.e. once a day at 03:00 server-local time). The retention horizon comes
|
||||
* from {@link ToolResultProperties#getRetentionDays()}.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class ToolResultRetentionScheduler {
|
||||
|
||||
private final ToolResultStorage storage;
|
||||
private final ToolResultProperties props;
|
||||
|
||||
/**
|
||||
* Cron-fired hook. Failures are logged at WARN so they show up in
|
||||
* standard log scrapes without aborting the scheduler thread — losing
|
||||
* a single sweep is fine, the next one will catch the same files.
|
||||
*/
|
||||
@Scheduled(cron = "${mate.agent.tool-result.cleanup-cron:0 0 3 * * ?}")
|
||||
public void cleanup() {
|
||||
if (props.getRetentionDays() <= 0) {
|
||||
log.debug("[ToolResultRetentionScheduler] retentionDays<=0, skipping sweep");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
int deleted = storage.cleanupExpired();
|
||||
if (deleted > 0) {
|
||||
log.info("[ToolResultRetentionScheduler] sweep deleted {} spill file(s) older than {} days",
|
||||
deleted, props.getRetentionDays());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("[ToolResultRetentionScheduler] sweep failed: {}", e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -58,6 +58,15 @@ public class ToolResultStorage {
|
||||
/** D-6: monotonically increasing spill counter for observability. */
|
||||
private final java.util.concurrent.atomic.AtomicLong spillCount = new java.util.concurrent.atomic.AtomicLong();
|
||||
|
||||
/**
|
||||
* Workspace roots observed during this JVM's lifetime. Populated every
|
||||
* time a successful spill resolves a base directory; consulted by the
|
||||
* scheduled retention sweep and by {@link #purgeConversation} so we
|
||||
* don't have to query the database for every workspace path. Cross-JVM
|
||||
* orphans are not covered — that is documented in the cleanup javadoc.
|
||||
*/
|
||||
private final java.util.Set<Path> observedRoots = java.util.concurrent.ConcurrentHashMap.newKeySet();
|
||||
|
||||
public ToolResultStorage(ToolResultProperties props) {
|
||||
this.props = props;
|
||||
this.excludedToolsSnapshot = props.excludedToolsSet();
|
||||
@ -270,15 +279,162 @@ public class ToolResultStorage {
|
||||
}
|
||||
|
||||
private Path resolveBaseDir(String workspaceBasePath) {
|
||||
Path base;
|
||||
if (!props.getStorageBaseDir().isEmpty()) {
|
||||
return Paths.get(props.getStorageBaseDir());
|
||||
base = Paths.get(props.getStorageBaseDir());
|
||||
} else if (workspaceBasePath != null && !workspaceBasePath.isBlank()) {
|
||||
base = Paths.get(workspaceBasePath, ".mateclaw", "tool-results");
|
||||
} else {
|
||||
String tmp = System.getProperty("java.io.tmpdir");
|
||||
if (tmp == null || tmp.isEmpty()) return null;
|
||||
base = Paths.get(tmp, "mateclaw", "tool-results");
|
||||
}
|
||||
if (workspaceBasePath != null && !workspaceBasePath.isBlank()) {
|
||||
return Paths.get(workspaceBasePath, ".mateclaw", "tool-results");
|
||||
// Register so the retention sweep and conversation-delete hook can
|
||||
// reach this root even when the workspace path is no longer in scope.
|
||||
observedRoots.add(base);
|
||||
return base;
|
||||
}
|
||||
|
||||
/**
|
||||
* Roots currently known to this instance. Exposed package-private so the
|
||||
* scheduled retention sweep and unit tests can enumerate them without
|
||||
* touching the underlying set directly.
|
||||
*/
|
||||
java.util.Set<Path> getObservedRoots() {
|
||||
return java.util.Collections.unmodifiableSet(observedRoots);
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort: delete every spill file and per-conversation directory
|
||||
* older than {@link ToolResultProperties#getRetentionDays()} across all
|
||||
* roots this storage has seen, plus the configured base dir and the
|
||||
* tmpdir fallback. Returns the number of files deleted.
|
||||
*
|
||||
* <p>Workspaces that never received a spill in this JVM's lifetime are
|
||||
* not covered. Persisting an observed-roots registry across restarts
|
||||
* could fix that, but is intentionally out of scope — the operator-side
|
||||
* remedy is to run a one-off cleanup with {@code storage-base-dir}
|
||||
* pointed at the historical workspace.
|
||||
*/
|
||||
public int cleanupExpired() {
|
||||
if (props.getRetentionDays() <= 0) {
|
||||
return 0;
|
||||
}
|
||||
long cutoffEpochMillis = System.currentTimeMillis()
|
||||
- (long) props.getRetentionDays() * 24L * 60L * 60L * 1000L;
|
||||
|
||||
java.util.Set<Path> roots = new java.util.LinkedHashSet<>(observedRoots);
|
||||
if (!props.getStorageBaseDir().isEmpty()) {
|
||||
roots.add(Paths.get(props.getStorageBaseDir()));
|
||||
}
|
||||
String tmp = System.getProperty("java.io.tmpdir");
|
||||
if (tmp == null || tmp.isEmpty()) return null;
|
||||
return Paths.get(tmp, "mateclaw", "tool-results");
|
||||
if (tmp != null && !tmp.isEmpty()) {
|
||||
roots.add(Paths.get(tmp, "mateclaw", "tool-results"));
|
||||
}
|
||||
|
||||
int deleted = 0;
|
||||
for (Path root : roots) {
|
||||
deleted += deleteExpiredUnder(root, cutoffEpochMillis);
|
||||
}
|
||||
if (deleted > 0) {
|
||||
log.info("[ToolResultStorage] cleanup: {} spill files removed across {} root(s)",
|
||||
deleted, roots.size());
|
||||
}
|
||||
return deleted;
|
||||
}
|
||||
|
||||
private int deleteExpiredUnder(Path root, long cutoffEpochMillis) {
|
||||
if (root == null || !java.nio.file.Files.isDirectory(root)) {
|
||||
return 0;
|
||||
}
|
||||
int deleted = 0;
|
||||
try (java.util.stream.Stream<Path> stream = java.nio.file.Files.walk(root, 2)) {
|
||||
for (Path p : (Iterable<Path>) stream::iterator) {
|
||||
if (p.equals(root)) continue;
|
||||
if (!java.nio.file.Files.isRegularFile(p)) continue;
|
||||
try {
|
||||
long mtime = java.nio.file.Files.getLastModifiedTime(p).toMillis();
|
||||
if (mtime < cutoffEpochMillis) {
|
||||
java.nio.file.Files.deleteIfExists(p);
|
||||
deleted++;
|
||||
}
|
||||
} catch (java.io.IOException ioe) {
|
||||
log.warn("[ToolResultStorage] failed to inspect spill file {}: {}", p, ioe.getMessage());
|
||||
}
|
||||
}
|
||||
} catch (java.io.IOException ioe) {
|
||||
log.warn("[ToolResultStorage] cleanup walk failed under {}: {}", root, ioe.getMessage());
|
||||
return deleted;
|
||||
}
|
||||
// Best-effort: remove emptied per-conversation directories.
|
||||
try (java.util.stream.Stream<Path> stream = java.nio.file.Files.list(root)) {
|
||||
for (Path child : (Iterable<Path>) stream::iterator) {
|
||||
if (!java.nio.file.Files.isDirectory(child)) continue;
|
||||
try (java.util.stream.Stream<Path> kids = java.nio.file.Files.list(child)) {
|
||||
if (kids.findAny().isEmpty()) {
|
||||
java.nio.file.Files.deleteIfExists(child);
|
||||
}
|
||||
} catch (java.io.IOException ignored) {
|
||||
// empty-check failure is not fatal — leave the directory alone
|
||||
}
|
||||
}
|
||||
} catch (java.io.IOException ioe) {
|
||||
log.warn("[ToolResultStorage] empty-dir sweep failed under {}: {}", root, ioe.getMessage());
|
||||
}
|
||||
return deleted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete every spill file produced for {@code conversationId} across
|
||||
* all observed roots, plus the configured base and tmpdir fallback.
|
||||
* Called by {@code ConversationService.deleteConversation} so spill
|
||||
* directories don't outlive the conversation that owns them.
|
||||
*
|
||||
* <p>Silently no-ops when nothing matches — a conversation that never
|
||||
* spilled, or one whose workspace root was never observed in this JVM,
|
||||
* is simply left alone. Returns the number of files deleted.
|
||||
*/
|
||||
public int purgeConversation(String conversationId) {
|
||||
if (conversationId == null || conversationId.isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
String safeConv = sanitize(conversationId);
|
||||
java.util.Set<Path> roots = new java.util.LinkedHashSet<>(observedRoots);
|
||||
if (!props.getStorageBaseDir().isEmpty()) {
|
||||
roots.add(Paths.get(props.getStorageBaseDir()));
|
||||
}
|
||||
String tmp = System.getProperty("java.io.tmpdir");
|
||||
if (tmp != null && !tmp.isEmpty()) {
|
||||
roots.add(Paths.get(tmp, "mateclaw", "tool-results"));
|
||||
}
|
||||
int deleted = 0;
|
||||
for (Path root : roots) {
|
||||
Path convDir = root.resolve(safeConv);
|
||||
if (!java.nio.file.Files.isDirectory(convDir)) continue;
|
||||
try (java.util.stream.Stream<Path> stream = java.nio.file.Files.list(convDir)) {
|
||||
for (Path p : (Iterable<Path>) stream::iterator) {
|
||||
try {
|
||||
if (java.nio.file.Files.isRegularFile(p)) {
|
||||
java.nio.file.Files.deleteIfExists(p);
|
||||
deleted++;
|
||||
}
|
||||
} catch (java.io.IOException ioe) {
|
||||
log.warn("[ToolResultStorage] failed to delete spill file {}: {}", p, ioe.getMessage());
|
||||
}
|
||||
}
|
||||
} catch (java.io.IOException ioe) {
|
||||
log.warn("[ToolResultStorage] purge walk failed under {}: {}", convDir, ioe.getMessage());
|
||||
}
|
||||
try {
|
||||
java.nio.file.Files.deleteIfExists(convDir);
|
||||
} catch (java.io.IOException ignored) {
|
||||
// non-empty after deletes (another writer raced us) — fine, leave it
|
||||
}
|
||||
}
|
||||
if (deleted > 0) {
|
||||
log.info("[ToolResultStorage] purged {} spill file(s) for conversation {}", deleted, conversationId);
|
||||
}
|
||||
return deleted;
|
||||
}
|
||||
|
||||
/** Strip path separators and reserved characters so user-supplied IDs cannot escape the directory. */
|
||||
|
||||
@ -65,6 +65,19 @@ public class ConversationService {
|
||||
private final ChannelSessionMapper channelSessionMapper;
|
||||
private final ApplicationEventPublisher eventPublisher;
|
||||
|
||||
/**
|
||||
* Optional spill store. Injected via a setter so the existing @RequiredArgsConstructor
|
||||
* stays stable and tests that build the service directly don't need to wire
|
||||
* tool-result storage. When present, deleteConversation also purges any spill
|
||||
* files this conversation produced so they don't outlive the row that owned them.
|
||||
*/
|
||||
private vip.mate.agent.graph.executor.ToolResultStorage toolResultStorage;
|
||||
|
||||
@org.springframework.beans.factory.annotation.Autowired(required = false)
|
||||
public void setToolResultStorage(vip.mate.agent.graph.executor.ToolResultStorage toolResultStorage) {
|
||||
this.toolResultStorage = toolResultStorage;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户的会话列表(返回 VO,包含 agentName/agentIcon/status)
|
||||
*/
|
||||
@ -501,15 +514,34 @@ public class ConversationService {
|
||||
@Override
|
||||
public void afterCommit() {
|
||||
cleanAttachmentFiles(conversationId);
|
||||
purgeToolResultSpill(conversationId);
|
||||
eventPublisher.publishEvent(new ConversationDeletedEvent(conversationId));
|
||||
}
|
||||
});
|
||||
} else {
|
||||
cleanAttachmentFiles(conversationId);
|
||||
purgeToolResultSpill(conversationId);
|
||||
eventPublisher.publishEvent(new ConversationDeletedEvent(conversationId));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort: ask the spill store to delete every tool-result file this
|
||||
* conversation produced. No-op when no spill store is wired in (legacy
|
||||
* deployments or tests that don't need spill). Failures are logged but
|
||||
* never propagated — leaving an extra file on disk is a small price
|
||||
* compared to surfacing IO errors as a 500 on the delete endpoint.
|
||||
*/
|
||||
private void purgeToolResultSpill(String conversationId) {
|
||||
if (toolResultStorage == null) return;
|
||||
try {
|
||||
toolResultStorage.purgeConversation(conversationId);
|
||||
} catch (Exception e) {
|
||||
log.warn("[Conversation] tool-result spill purge failed for {}: {}",
|
||||
conversationId, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空会话消息(同时清理附件文件)
|
||||
*/
|
||||
|
||||
@ -229,6 +229,11 @@ mate:
|
||||
excluded-tools:
|
||||
- read_file
|
||||
- read_workspace_memory_file
|
||||
# Spill files are deleted after this many days. Set to 0 to disable the
|
||||
# scheduled sweep entirely (files still get purged when the conversation
|
||||
# is deleted explicitly via ConversationService.deleteConversation).
|
||||
retention-days: 7
|
||||
cleanup-cron: "0 0 3 * * ?"
|
||||
conversation:
|
||||
window:
|
||||
# 测试时临时调低:2000 token ≈ 2000 中文字,3 轮对话即可触发压缩
|
||||
|
||||
@ -0,0 +1,161 @@
|
||||
package vip.mate.agent.graph.executor;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.attribute.FileTime;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
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.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* Retention sweep + per-conversation purge for {@link ToolResultStorage}.
|
||||
*
|
||||
* <p>The store keeps a per-JVM "observed roots" registry — every time a
|
||||
* spill resolves a directory, that directory is remembered so the
|
||||
* retention sweep can reach it even after the workspace path has gone
|
||||
* out of scope. These tests verify the registry behaviour, the
|
||||
* mtime-based deletion contract, and the targeted per-conversation purge
|
||||
* called from {@code ConversationService.deleteConversation}.
|
||||
*/
|
||||
class ToolResultStorageRetentionTest {
|
||||
|
||||
@Test
|
||||
void successfulSpillRegistersTheRoot(@TempDir Path tempDir) {
|
||||
ToolResultStorage storage = newStorage(tempDir, /*threshold*/ 100, /*retention*/ 7);
|
||||
|
||||
// Trigger a spill so resolveBaseDir() is invoked.
|
||||
String out = storage.persistIfOversized(
|
||||
"x".repeat(500), "web_search", "call-1", "conv-A", tempDir.toString());
|
||||
assertTrue(out.startsWith(ToolResultStorage.SPILL_MARKER_PREFIX));
|
||||
|
||||
assertTrue(storage.getObservedRoots().contains(tempDir),
|
||||
"successful spill must register its resolved root for later cleanup");
|
||||
}
|
||||
|
||||
@Test
|
||||
void cleanupDeletesFilesOlderThanRetention(@TempDir Path tempDir) throws Exception {
|
||||
// retention = 1 day
|
||||
ToolResultStorage storage = newStorage(tempDir, /*threshold*/ 100, /*retention*/ 1);
|
||||
|
||||
// Drop a "fresh" spill via the public API.
|
||||
String fresh = storage.persistIfOversized(
|
||||
"fresh".repeat(200), "web_search", "call-fresh", "conv-A", tempDir.toString());
|
||||
assertTrue(fresh.startsWith(ToolResultStorage.SPILL_MARKER_PREFIX));
|
||||
Path freshFile = pathFromPreview(fresh);
|
||||
|
||||
// Drop a "stale" spill and back-date its mtime by 8 days.
|
||||
String stale = storage.persistIfOversized(
|
||||
"stale".repeat(200), "web_search", "call-stale", "conv-B", tempDir.toString());
|
||||
Path staleFile = pathFromPreview(stale);
|
||||
Files.setLastModifiedTime(staleFile,
|
||||
FileTime.from(Instant.now().minus(8, ChronoUnit.DAYS)));
|
||||
|
||||
int deleted = storage.cleanupExpired();
|
||||
|
||||
assertEquals(1, deleted, "only the stale file should be removed");
|
||||
assertTrue(Files.exists(freshFile), "fresh file must survive");
|
||||
assertFalse(Files.exists(staleFile), "stale file must be deleted");
|
||||
}
|
||||
|
||||
@Test
|
||||
void cleanupIsNoOpWhenRetentionDisabled(@TempDir Path tempDir) throws Exception {
|
||||
ToolResultStorage storage = newStorage(tempDir, /*threshold*/ 100, /*retention*/ 0);
|
||||
|
||||
String stale = storage.persistIfOversized(
|
||||
"stale".repeat(200), "web_search", "call-1", "conv-A", tempDir.toString());
|
||||
Path staleFile = pathFromPreview(stale);
|
||||
Files.setLastModifiedTime(staleFile,
|
||||
FileTime.from(Instant.now().minus(100, ChronoUnit.DAYS)));
|
||||
|
||||
int deleted = storage.cleanupExpired();
|
||||
assertEquals(0, deleted, "retentionDays<=0 must disable the sweep entirely");
|
||||
assertTrue(Files.exists(staleFile), "stale file must remain when sweep is disabled");
|
||||
}
|
||||
|
||||
@Test
|
||||
void cleanupRemovesEmptiedConversationDirectories(@TempDir Path tempDir) throws Exception {
|
||||
ToolResultStorage storage = newStorage(tempDir, /*threshold*/ 100, /*retention*/ 1);
|
||||
|
||||
String stale = storage.persistIfOversized(
|
||||
"stale".repeat(200), "web_search", "call-stale", "conv-old", tempDir.toString());
|
||||
Path staleFile = pathFromPreview(stale);
|
||||
Path staleDir = staleFile.getParent();
|
||||
Files.setLastModifiedTime(staleFile,
|
||||
FileTime.from(Instant.now().minus(8, ChronoUnit.DAYS)));
|
||||
|
||||
storage.cleanupExpired();
|
||||
|
||||
assertFalse(Files.exists(staleFile));
|
||||
assertFalse(Files.exists(staleDir),
|
||||
"the empty conv-old/ directory should be cleaned up too");
|
||||
}
|
||||
|
||||
@Test
|
||||
void purgeConversationDeletesAllFilesForOneConversation(@TempDir Path tempDir) throws Exception {
|
||||
ToolResultStorage storage = newStorage(tempDir, /*threshold*/ 100, /*retention*/ 30);
|
||||
|
||||
// Two spills for conv-A, one spill for conv-B.
|
||||
String a1 = storage.persistIfOversized(
|
||||
"a1".repeat(200), "web_search", "call-a1", "conv-A", tempDir.toString());
|
||||
String a2 = storage.persistIfOversized(
|
||||
"a2".repeat(200), "web_search", "call-a2", "conv-A", tempDir.toString());
|
||||
String b1 = storage.persistIfOversized(
|
||||
"b1".repeat(200), "web_search", "call-b1", "conv-B", tempDir.toString());
|
||||
Path af1 = pathFromPreview(a1);
|
||||
Path af2 = pathFromPreview(a2);
|
||||
Path bf1 = pathFromPreview(b1);
|
||||
|
||||
int deleted = storage.purgeConversation("conv-A");
|
||||
|
||||
assertEquals(2, deleted, "both A files should be deleted");
|
||||
assertFalse(Files.exists(af1));
|
||||
assertFalse(Files.exists(af2));
|
||||
assertTrue(Files.exists(bf1), "conv-B files must not be touched by a conv-A purge");
|
||||
}
|
||||
|
||||
@Test
|
||||
void purgeConversationIsSilentForUnknownConversation(@TempDir Path tempDir) {
|
||||
ToolResultStorage storage = newStorage(tempDir, /*threshold*/ 100, /*retention*/ 7);
|
||||
|
||||
// No spill at all → nothing to purge → 0, no exception.
|
||||
int deleted = storage.purgeConversation("never-existed");
|
||||
assertEquals(0, deleted);
|
||||
}
|
||||
|
||||
@Test
|
||||
void purgeConversationHandlesBlankIdSafely(@TempDir Path tempDir) {
|
||||
ToolResultStorage storage = newStorage(tempDir, /*threshold*/ 100, /*retention*/ 7);
|
||||
|
||||
assertEquals(0, storage.purgeConversation(null));
|
||||
assertEquals(0, storage.purgeConversation(""));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ helpers
|
||||
|
||||
private static ToolResultStorage newStorage(Path tempDir, int threshold, int retentionDays) {
|
||||
ToolResultProperties props = new ToolResultProperties();
|
||||
props.setStorageBaseDir(tempDir.toString());
|
||||
props.setPerResultThresholdChars(threshold);
|
||||
props.setPreviewHeadChars(80);
|
||||
props.setRetentionDays(retentionDays);
|
||||
props.setExcludedTools(List.of());
|
||||
return new ToolResultStorage(props);
|
||||
}
|
||||
|
||||
private static Path pathFromPreview(String preview) {
|
||||
java.util.regex.Matcher m = java.util.regex.Pattern.compile("path=(\\S+)").matcher(preview);
|
||||
assertTrue(m.find(), "preview must include path=...");
|
||||
Path p = Path.of(m.group(1));
|
||||
assertNotNull(p);
|
||||
return p;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user