> offloadContext = new HashMap<>();
@@ -62,6 +62,15 @@ public synchronized AutoContextConfig getAutoContextConfig() {
return autoContextConfig;
}
+ /**
+ * Reconciles runtime context back into the working buffer.
+ *
+ * The incoming context is expected to be either the same session prefix plus newly appended
+ * tail messages, or a rebuilt working context after a reset/reload. When the prefix still
+ * matches, only the new tail is appended into both working/original buffers; when it no longer
+ * matches, the working buffer is replaced and the original history is seeded only if it was
+ * still empty.
+ */
public synchronized void mergeWithContext(List context) {
if (context == null || context.isEmpty()) {
return;
@@ -165,9 +174,6 @@ public synchronized void deleteMessage(int index) {
return;
}
workingMemoryStorage.remove(index);
- if (index < originalMemoryStorage.size()) {
- originalMemoryStorage.remove(index);
- }
}
public synchronized List getOriginalMemoryMsgs() {
@@ -192,24 +198,8 @@ public synchronized List getCompressionEvents() {
return compressionEvents;
}
- public synchronized boolean compressIfNeeded() {
- if (workingMemoryStorage.isEmpty()) {
- return false;
- }
-
- int tokenCount = TokenCounterUtil.calculateToken(workingMemoryStorage);
- boolean msgCountReached =
- workingMemoryStorage.size() >= autoContextConfig.getMsgThreshold();
- boolean tokenReached =
- tokenCount
- >= (int)
- (autoContextConfig.getMaxToken()
- * autoContextConfig.getTokenRatio());
- if (!msgCountReached && !tokenReached) {
- return false;
- }
-
- if (tokenCount < autoContextConfig.getMinCompressionTokenThreshold()) {
+ public boolean compressIfNeeded() {
+ if (!isCompressionTriggered()) {
return false;
}
@@ -263,93 +253,72 @@ public synchronized void setWorkingMessages(List messages) {
workingMemoryStorage.addAll(copyMessages(messages));
}
+ private synchronized boolean isCompressionTriggered() {
+ if (workingMemoryStorage.isEmpty()) {
+ return false;
+ }
+ int tokenCount = TokenCounterUtil.calculateToken(workingMemoryStorage);
+ boolean msgCountReached =
+ workingMemoryStorage.size() >= autoContextConfig.getMsgThreshold();
+ boolean tokenReached =
+ tokenCount
+ >= (int)
+ (autoContextConfig.getMaxToken()
+ * autoContextConfig.getTokenRatio());
+ if (!msgCountReached && !tokenReached) {
+ return false;
+ }
+ return tokenCount >= autoContextConfig.getMinCompressionTokenThreshold();
+ }
+
private boolean compressToolGroups() {
- int cursor = 0;
- int upperLimit = Math.max(0, workingMemoryStorage.size() - autoContextConfig.getLastKeep());
boolean changed = false;
- while (cursor < upperLimit) {
- IntRange range = findToolGroup(cursor, upperLimit);
- if (range == null) {
+ int cursor = 0;
+ while (true) {
+ CompressionCandidate candidate = planToolGroupCompression(cursor);
+ if (candidate == null) {
break;
}
- List messages =
- new ArrayList<>(workingMemoryStorage.subList(range.start, range.end + 1));
- if (TokenCounterUtil.calculateToken(messages)
+ if (TokenCounterUtil.calculateToken(candidate.source())
< autoContextConfig.getMinCompressionTokenThreshold()) {
- cursor = range.end + 1;
+ cursor = candidate.end() + 1;
continue;
}
- if (compressRange(
- range.start,
- range.end,
- CompressionEvent.TOOL_INVOCATION_COMPRESS,
- PromptProvider.getPreviousRoundToolCompressPrompt(customPrompt),
- true)) {
+ String summary =
+ summarizeMessages(
+ candidate.source(), candidate.prompt(), candidate.currentRound());
+ if (applyCompression(candidate, summary)) {
changed = true;
- cursor = range.start + 1;
- upperLimit =
- Math.max(0, workingMemoryStorage.size() - autoContextConfig.getLastKeep());
+ cursor = candidate.start() + 1;
} else {
- cursor = range.end + 1;
+ cursor = candidate.end() + 1;
}
}
return changed;
}
private boolean offloadLargeMessages() {
- int limit = Math.max(0, workingMemoryStorage.size() - autoContextConfig.getLastKeep());
- for (int i = 0; i < limit; i++) {
- Msg msg = workingMemoryStorage.get(i);
- if (MsgUtils.calculateMessageCharCount(msg)
- < autoContextConfig.getLargePayloadThreshold()) {
- continue;
- }
- String uuid = UUID.randomUUID().toString();
- List original = List.of(msg);
- String summary =
- summarizeMessages(
- original,
- PromptProvider.getCurrentRoundLargeMessagePrompt(customPrompt),
- false);
- if (summary == null || summary.isBlank()) {
- summary = fallbackSummary(original);
- }
- offload(uuid, original);
- Msg compressed = compressSingleMessage(msg, summary, uuid);
- MsgUtils.replaceMsg(workingMemoryStorage, i, i, compressed);
- recordCompressionEvent(
- msg.getRole() == MsgRole.TOOL
- ? CompressionEvent.LARGE_MESSAGE_OFFLOAD
- : CompressionEvent.LARGE_MESSAGE_OFFLOAD_WITH_PROTECTION,
- i,
- i,
- original,
- compressed,
- buildCompressionMetadata(compressed));
- return true;
+ CompressionCandidate candidate = planLargeMessageOffload();
+ if (candidate == null) {
+ return false;
}
- return false;
+ String summary =
+ summarizeMessages(candidate.source(), candidate.prompt(), candidate.currentRound());
+ return applyCompression(candidate, summary);
}
private boolean compressPreviousRounds() {
boolean changed = false;
int guard = 0;
- while (workingMemoryStorage.size() > autoContextConfig.getLastKeep() + 1 && guard++ < 32) {
- int end = findPrefixCompressionEnd();
- if (end < 0) {
+ while (guard++ < 32) {
+ CompressionCandidate candidate = planPreviousRoundCompression();
+ if (candidate == null) {
break;
}
- List source = new ArrayList<>(workingMemoryStorage.subList(0, end + 1));
- if (TokenCounterUtil.calculateToken(source)
- < autoContextConfig.getMinCompressionTokenThreshold()) {
- break;
- }
- if (!compressRange(
- 0,
- end,
- CompressionEvent.PREVIOUS_ROUND_CONVERSATION_SUMMARY,
- PromptProvider.getPreviousRoundSummaryPrompt(customPrompt),
- false)) {
+ String summary =
+ summarizeMessages(
+ candidate.source(), candidate.prompt(), candidate.currentRound());
+ if (!applyCompression(candidate, summary)) {
break;
}
changed = true;
@@ -358,6 +327,85 @@ private boolean compressPreviousRounds() {
}
private boolean compressCurrentRound() {
+ CompressionCandidate candidate = planCurrentRoundCompression();
+ if (candidate == null) {
+ return false;
+ }
+ String summary =
+ summarizeMessages(candidate.source(), candidate.prompt(), candidate.currentRound());
+ return applyCompression(candidate, summary);
+ }
+
+ private synchronized CompressionCandidate planToolGroupCompression(int cursor) {
+ int upperLimit = Math.max(0, workingMemoryStorage.size() - autoContextConfig.getLastKeep());
+ if (cursor >= upperLimit) {
+ return null;
+ }
+ IntRange range = findToolGroup(cursor, upperLimit);
+ if (range == null) {
+ return null;
+ }
+ List source =
+ new ArrayList<>(workingMemoryStorage.subList(range.start, range.end + 1));
+ return new CompressionCandidate(
+ range.start,
+ range.end,
+ CompressionEvent.TOOL_INVOCATION_COMPRESS,
+ PromptProvider.getPreviousRoundToolCompressPrompt(customPrompt),
+ source,
+ false,
+ true,
+ false);
+ }
+
+ private synchronized CompressionCandidate planLargeMessageOffload() {
+ int limit = Math.max(0, workingMemoryStorage.size() - autoContextConfig.getLastKeep());
+ for (int i = 0; i < limit; i++) {
+ Msg msg = workingMemoryStorage.get(i);
+ if (MsgUtils.calculateMessageCharCount(msg)
+ < autoContextConfig.getLargePayloadThreshold()) {
+ continue;
+ }
+ return new CompressionCandidate(
+ i,
+ i,
+ msg.getRole() == MsgRole.TOOL
+ ? CompressionEvent.LARGE_MESSAGE_OFFLOAD
+ : CompressionEvent.LARGE_MESSAGE_OFFLOAD_WITH_PROTECTION,
+ PromptProvider.getCurrentRoundLargeMessagePrompt(customPrompt),
+ List.of(msg),
+ false,
+ false,
+ true);
+ }
+ return null;
+ }
+
+ private synchronized CompressionCandidate planPreviousRoundCompression() {
+ if (workingMemoryStorage.size() <= autoContextConfig.getLastKeep() + 1) {
+ return null;
+ }
+ int end = findPrefixCompressionEnd();
+ if (end < 0) {
+ return null;
+ }
+ List source = new ArrayList<>(workingMemoryStorage.subList(0, end + 1));
+ if (TokenCounterUtil.calculateToken(source)
+ < autoContextConfig.getMinCompressionTokenThreshold()) {
+ return null;
+ }
+ return new CompressionCandidate(
+ 0,
+ end,
+ CompressionEvent.PREVIOUS_ROUND_CONVERSATION_SUMMARY,
+ PromptProvider.getPreviousRoundSummaryPrompt(customPrompt),
+ source,
+ false,
+ false,
+ false);
+ }
+
+ private synchronized CompressionCandidate planCurrentRoundCompression() {
int latestUserIndex = -1;
for (int i = workingMemoryStorage.size() - 1; i >= 0; i--) {
if (workingMemoryStorage.get(i).getRole() == MsgRole.USER) {
@@ -366,52 +414,64 @@ private boolean compressCurrentRound() {
}
}
if (latestUserIndex < 0 || latestUserIndex >= workingMemoryStorage.size() - 1) {
- return false;
+ return null;
}
-
int end = workingMemoryStorage.size() - 1;
if (MsgUtils.isToolUseMessage(workingMemoryStorage.get(end))) {
end--;
}
if (end <= latestUserIndex) {
- return false;
+ return null;
}
-
List source =
new ArrayList<>(workingMemoryStorage.subList(latestUserIndex + 1, end + 1));
if (TokenCounterUtil.calculateToken(source)
< autoContextConfig.getMinCompressionTokenThreshold()) {
- return false;
+ return null;
}
- return compressRange(
+ return new CompressionCandidate(
latestUserIndex + 1,
end,
CompressionEvent.CURRENT_ROUND_MESSAGE_COMPRESS,
PromptProvider.getCurrentRoundCompressPrompt(customPrompt),
- true);
+ source,
+ true,
+ true,
+ false);
}
- private boolean compressRange(
- int start, int end, String eventType, String prompt, boolean appendOffloadTag) {
- if (start < 0 || end < start || start >= workingMemoryStorage.size()) {
+ private boolean applyCompression(CompressionCandidate candidate, String summary) {
+ if (candidate == null) {
return false;
}
- end = Math.min(end, workingMemoryStorage.size() - 1);
- List source = new ArrayList<>(workingMemoryStorage.subList(start, end + 1));
+ String resolvedSummary =
+ summary == null || summary.isBlank()
+ ? fallbackSummary(candidate.source())
+ : summary.trim();
String uuid = UUID.randomUUID().toString();
- offload(uuid, source);
- String summary =
- summarizeMessages(
- source,
- prompt,
- eventType.equals(CompressionEvent.CURRENT_ROUND_MESSAGE_COMPRESS));
- if (summary == null || summary.isBlank()) {
- summary = fallbackSummary(source);
- }
- Msg summaryMsg = buildSummaryMessage(source, summary, uuid, appendOffloadTag);
- MsgUtils.replaceMsg(workingMemoryStorage, start, end, summaryMsg);
- recordCompressionEvent(
- eventType, start, end, source, summaryMsg, buildCompressionMetadata(summaryMsg));
+ Msg compressedMessage =
+ candidate.singleMessageOffload()
+ ? compressSingleMessage(candidate.source().get(0), resolvedSummary, uuid)
+ : buildSummaryMessage(
+ candidate.source(),
+ resolvedSummary,
+ uuid,
+ candidate.appendOffloadTag());
+ synchronized (this) {
+ if (!matchesCandidate(candidate)) {
+ return false;
+ }
+ offloadContext.put(uuid, copyMessages(candidate.source()));
+ MsgUtils.replaceMsg(
+ workingMemoryStorage, candidate.start(), candidate.end(), compressedMessage);
+ recordCompressionEvent(
+ candidate.eventType(),
+ candidate.start(),
+ candidate.end(),
+ candidate.source(),
+ compressedMessage,
+ buildCompressionMetadata(compressedMessage));
+ }
return true;
}
@@ -419,7 +479,8 @@ private String summarizeMessages(List messages, String prompt, boolean curr
if (messages == null || messages.isEmpty()) {
return null;
}
- if (model == null) {
+ Model currentModel = model;
+ if (currentModel == null) {
return fallbackSummary(messages);
}
try {
@@ -465,7 +526,7 @@ private String summarizeMessages(List messages, String prompt, boolean curr
.build());
}
List responses =
- model.stream(input, null, GenerateOptions.builder().build())
+ currentModel.stream(input, null, GenerateOptions.builder().build())
.collectList()
.block();
if (responses == null || responses.isEmpty()) {
@@ -537,7 +598,10 @@ private Msg compressSingleMessage(Msg message, String summary, String uuid) {
if (!summaryInserted) {
content.add(TextBlock.builder().text(summary).build());
}
- Map metadata = new HashMap<>(message.getMetadata());
+ Map metadata =
+ message.getMetadata() == null
+ ? new HashMap<>()
+ : new HashMap<>(message.getMetadata());
Map compressMeta = new HashMap<>();
compressMeta.put("offloaduuid", uuid);
metadata.put("_compress_meta", compressMeta);
@@ -580,22 +644,29 @@ private void recordCompressionEvent(
eventType,
System.currentTimeMillis(),
Math.max(1, endIndex - startIndex + 1),
- startIndex > 0
- ? workingMemoryStorage
- .get(
- Math.min(
- startIndex - 1,
- workingMemoryStorage.size() - 1))
- .getId()
+ startIndex > 0 && startIndex - 1 < workingMemoryStorage.size()
+ ? workingMemoryStorage.get(startIndex - 1).getId()
: null,
- endIndex < workingMemoryStorage.size() - 1
- ? workingMemoryStorage.get(endIndex + 1).getId()
+ startIndex < workingMemoryStorage.size() - 1
+ ? workingMemoryStorage.get(startIndex + 1).getId()
: null,
compressedMessage != null ? compressedMessage.getId() : null,
metadata);
compressionEvents.add(event);
}
+ private boolean matchesCandidate(CompressionCandidate candidate) {
+ if (candidate.start() < 0
+ || candidate.end() < candidate.start()
+ || candidate.end() >= workingMemoryStorage.size()) {
+ return false;
+ }
+ List currentSlice =
+ new ArrayList<>(
+ workingMemoryStorage.subList(candidate.start(), candidate.end() + 1));
+ return currentSlice.equals(candidate.source());
+ }
+
private IntRange findToolGroup(int startIndex, int upperLimit) {
int start = -1;
for (int i = startIndex; i < upperLimit; i++) {
@@ -720,4 +791,14 @@ private Map> copyMsgMap(Map> input) {
}
private record IntRange(int start, int end) {}
+
+ private record CompressionCandidate(
+ int start,
+ int end,
+ String eventType,
+ String prompt,
+ List source,
+ boolean currentRound,
+ boolean appendOffloadTag,
+ boolean singleMessageOffload) {}
}
diff --git a/agentscope-extensions/agentscope-extensions-mem/agentscope-extensions-autocontext-memory/src/main/java/io/agentscope/core/memory/autocontext/TokenCounterUtil.java b/agentscope-extensions/agentscope-extensions-mem/agentscope-extensions-autocontext-memory/src/main/java/io/agentscope/core/memory/autocontext/TokenCounterUtil.java
index 4c56929ae..48d51e44a 100644
--- a/agentscope-extensions/agentscope-extensions-mem/agentscope-extensions-autocontext-memory/src/main/java/io/agentscope/core/memory/autocontext/TokenCounterUtil.java
+++ b/agentscope-extensions/agentscope-extensions-mem/agentscope-extensions-autocontext-memory/src/main/java/io/agentscope/core/memory/autocontext/TokenCounterUtil.java
@@ -18,7 +18,13 @@
import io.agentscope.core.message.Msg;
import java.util.List;
-/** Lightweight token estimator for compression triggers. */
+/**
+ * Lightweight token estimator for compression triggers.
+ *
+ * This uses a rough {@code chars / 4} heuristic, which is usually reasonable for English but
+ * can undercount CJK-heavy content where token counts are often materially higher. Replace with a
+ * model-specific tokenizer in production-sensitive paths.
+ */
public final class TokenCounterUtil {
private TokenCounterUtil() {}
diff --git a/agentscope-extensions/agentscope-extensions-mem/agentscope-extensions-autocontext-memory/src/test/java/io/agentscope/core/memory/autocontext/AutoContextMemoryTest.java b/agentscope-extensions/agentscope-extensions-mem/agentscope-extensions-autocontext-memory/src/test/java/io/agentscope/core/memory/autocontext/AutoContextMemoryTest.java
index 005c0e539..41e15ea18 100644
--- a/agentscope-extensions/agentscope-extensions-mem/agentscope-extensions-autocontext-memory/src/test/java/io/agentscope/core/memory/autocontext/AutoContextMemoryTest.java
+++ b/agentscope-extensions/agentscope-extensions-mem/agentscope-extensions-autocontext-memory/src/test/java/io/agentscope/core/memory/autocontext/AutoContextMemoryTest.java
@@ -17,19 +17,30 @@
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import io.agentscope.core.message.Msg;
+import io.agentscope.core.model.ChatResponse;
+import io.agentscope.core.model.GenerateOptions;
+import io.agentscope.core.model.Model;
+import io.agentscope.core.model.ToolSchema;
import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.jupiter.api.Test;
+import reactor.core.publisher.Flux;
import reactor.core.scheduler.Schedulers;
import reactor.test.StepVerifier;
class AutoContextMemoryTest {
@Test
- void deleteMessageRemovesTheIndexedMessageFromBothBuffers() {
+ void deleteMessageRemovesOnlyTheWorkingBufferEntry() {
AutoContextMemory memory = new AutoContextMemory(AutoContextConfig.builder().build(), null);
Msg first = AutoContextTestSupport.userMessage("first");
Msg second = AutoContextTestSupport.assistantMessage("second");
@@ -44,9 +55,35 @@ void deleteMessageRemovesTheIndexedMessageFromBothBuffers() {
assertEquals(2, memory.getMessages().size());
assertEquals("first", memory.getMessages().get(0).getTextContent());
assertEquals("third", memory.getMessages().get(1).getTextContent());
+ assertEquals(3, memory.getOriginalMemoryMsgs().size());
+ assertEquals("first", memory.getOriginalMemoryMsgs().get(0).getTextContent());
+ assertEquals("second", memory.getOriginalMemoryMsgs().get(1).getTextContent());
+ assertEquals("third", memory.getOriginalMemoryMsgs().get(2).getTextContent());
+ }
+
+ @Test
+ void deleteMessageDoesNotDeleteOriginalHistoryAfterCompression() {
+ AutoContextMemory memory =
+ new AutoContextMemory(
+ AutoContextConfig.builder()
+ .msgThreshold(2)
+ .lastKeep(0)
+ .minCompressionTokenThreshold(1)
+ .build(),
+ AutoContextTestSupport.recordingModel(
+ "compressed", new AtomicReference<>()));
+ memory.addMessage(AutoContextTestSupport.userMessage("first"));
+ memory.addMessage(AutoContextTestSupport.assistantMessage("second"));
+
+ assertTrue(memory.compressIfNeeded());
+ assertEquals(2, memory.getOriginalMemoryMsgs().size());
+
+ memory.deleteMessage(0);
+
+ assertTrue(memory.getMessages().isEmpty());
assertEquals(2, memory.getOriginalMemoryMsgs().size());
assertEquals("first", memory.getOriginalMemoryMsgs().get(0).getTextContent());
- assertEquals("third", memory.getOriginalMemoryMsgs().get(1).getTextContent());
+ assertEquals("second", memory.getOriginalMemoryMsgs().get(1).getTextContent());
}
@Test
@@ -109,4 +146,90 @@ void compressIfNeededAsyncRunsOnBoundedElasticAndSummarizesMessages() {
assertEquals("compressed", memory.getMessages().get(0).getTextContent());
assertEquals(1, memory.getCompressionEvents().size());
}
+
+ @Test
+ void compressIfNeededDoesNotHoldTheLockWhileTheModelCallIsInFlight() throws Exception {
+ CountDownLatch streamStarted = new CountDownLatch(1);
+ CountDownLatch releaseModel = new CountDownLatch(1);
+ Model blockingModel =
+ new Model() {
+ @Override
+ public Flux stream(
+ List messages, List tools, GenerateOptions options) {
+ streamStarted.countDown();
+ try {
+ if (!releaseModel.await(5, TimeUnit.SECONDS)) {
+ throw new IllegalStateException("timed out waiting for release");
+ }
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new IllegalStateException(e);
+ }
+ return Flux.just(
+ ChatResponse.builder()
+ .content(
+ List.of(
+ io.agentscope.core.message.TextBlock
+ .builder()
+ .text("compressed")
+ .build()))
+ .build());
+ }
+
+ @Override
+ public String getModelName() {
+ return "blocking";
+ }
+ };
+ AutoContextMemory memory =
+ new AutoContextMemory(
+ AutoContextConfig.builder()
+ .largePayloadThreshold(1)
+ .msgThreshold(2)
+ .lastKeep(0)
+ .minCompressionTokenThreshold(1)
+ .build(),
+ blockingModel);
+ memory.addMessage(
+ AutoContextTestSupport.userMessage(
+ "first message is large enough to trigger offload"));
+ memory.addMessage(AutoContextTestSupport.assistantMessage("second"));
+
+ ExecutorService executor = Executors.newFixedThreadPool(2);
+ try {
+ Future compressionFuture = executor.submit(memory::compressIfNeeded);
+ assertTrue(streamStarted.await(1, TimeUnit.SECONDS));
+
+ Future> addFuture =
+ executor.submit(
+ () -> memory.addMessage(AutoContextTestSupport.userMessage("third")));
+ addFuture.get(500, TimeUnit.MILLISECONDS);
+
+ releaseModel.countDown();
+ assertTrue(compressionFuture.get(5, TimeUnit.SECONDS));
+ } finally {
+ executor.shutdownNow();
+ }
+
+ assertEquals(3, memory.getMessages().size());
+ assertEquals("compressed", memory.getMessages().get(0).getTextContent());
+ assertEquals("second", memory.getMessages().get(1).getTextContent());
+ assertEquals("third", memory.getMessages().get(2).getTextContent());
+ }
+
+ @Test
+ void builderRejectsInvalidCompressionSettings() {
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> AutoContextConfig.builder().tokenRatio(0.0).build());
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> AutoContextConfig.builder().tokenRatio(1.1).build());
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> AutoContextConfig.builder().maxToken(0).build());
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> AutoContextConfig.builder().lastKeep(-1).build());
+ }
}
From f8e83943af0135d78cc69e1656812be2dddfef60 Mon Sep 17 00:00:00 2001
From: guslegend <1670547022@qq.com>
Date: Wed, 10 Jun 2026 17:42:23 +0800
Subject: [PATCH 4/4] test(autocontext): improve patch coverage for review
fixes
---
.../autocontext/AutoContextHookTest.java | 47 +++
.../autocontext/AutoContextMemoryTest.java | 215 ++++++++++++++
.../AutoContextSupportClassesTest.java | 184 ++++++++++++
.../autocontext/ContextOffloadToolTest.java | 78 +++++
.../core/memory/autocontext/MsgUtilsTest.java | 271 ++++++++++++++++++
5 files changed, 795 insertions(+)
create mode 100644 agentscope-extensions/agentscope-extensions-mem/agentscope-extensions-autocontext-memory/src/test/java/io/agentscope/core/memory/autocontext/AutoContextSupportClassesTest.java
create mode 100644 agentscope-extensions/agentscope-extensions-mem/agentscope-extensions-autocontext-memory/src/test/java/io/agentscope/core/memory/autocontext/MsgUtilsTest.java
diff --git a/agentscope-extensions/agentscope-extensions-mem/agentscope-extensions-autocontext-memory/src/test/java/io/agentscope/core/memory/autocontext/AutoContextHookTest.java b/agentscope-extensions/agentscope-extensions-mem/agentscope-extensions-autocontext-memory/src/test/java/io/agentscope/core/memory/autocontext/AutoContextHookTest.java
index 1cc560955..70411fc56 100644
--- a/agentscope-extensions/agentscope-extensions-mem/agentscope-extensions-autocontext-memory/src/test/java/io/agentscope/core/memory/autocontext/AutoContextHookTest.java
+++ b/agentscope-extensions/agentscope-extensions-mem/agentscope-extensions-autocontext-memory/src/test/java/io/agentscope/core/memory/autocontext/AutoContextHookTest.java
@@ -19,12 +19,15 @@
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
import io.agentscope.core.ReActAgent;
+import io.agentscope.core.agent.Agent;
import io.agentscope.core.agent.RuntimeContext;
import io.agentscope.core.hook.PreCallEvent;
import io.agentscope.core.hook.PreReasoningEvent;
import io.agentscope.core.state.AgentState;
+import io.agentscope.core.tool.Toolkit;
import java.lang.reflect.Field;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
@@ -105,6 +108,50 @@ void handlePreReasoningCompressesWithoutBlockingAndRewritesState() {
assertTrue(threadName.get().contains("boundedElastic"));
}
+ @Test
+ void handlePreCallIgnoresNonReactAgentAndRegistersOnlyOnce() {
+ AutoContextHook hook = new AutoContextHook();
+ Agent nonReactAgent = mock(Agent.class);
+ PreCallEvent plainEvent =
+ new PreCallEvent(
+ nonReactAgent, List.of(AutoContextTestSupport.userMessage("hello")));
+ StepVerifier.create(hook.handlePreCall(plainEvent)).expectNext(plainEvent).verifyComplete();
+
+ Toolkit toolkit = new Toolkit();
+ ReActAgent agent =
+ ReActAgent.builder()
+ .name("register-once")
+ .sysPrompt("system")
+ .toolkit(toolkit)
+ .model(AutoContextTestSupport.noopModel())
+ .build();
+ PreCallEvent event =
+ new PreCallEvent(agent, List.of(AutoContextTestSupport.userMessage("hello")));
+
+ StepVerifier.create(hook.handlePreCall(event)).expectNext(event).verifyComplete();
+ int firstCount = agent.getToolkit().getToolNames().size();
+ StepVerifier.create(hook.handlePreCall(event)).expectNext(event).verifyComplete();
+ assertEquals(firstCount, agent.getToolkit().getToolNames().size());
+ assertTrue(agent.getToolkit().getToolNames().contains("context_reload"));
+ }
+
+ @Test
+ void handlePreReasoningIgnoresNonReactAgent() {
+ AutoContextHook hook = new AutoContextHook();
+ Agent agent = mock(Agent.class);
+
+ PreReasoningEvent event =
+ new PreReasoningEvent(
+ agent, "noop", null, List.of(AutoContextTestSupport.userMessage("x")));
+
+ StepVerifier.create(hook.handlePreReasoning(event))
+ .assertNext(returned -> assertSame(event, returned))
+ .verifyComplete();
+ assertEquals(1, event.getInputMessages().size());
+ assertEquals("x", event.getInputMessages().get(0).getTextContent());
+ assertTrue(event.getSystemMessage() == null);
+ }
+
private static void setActiveRuntimeContext(ReActAgent agent, RuntimeContext runtimeContext) {
try {
Field field = ReActAgent.class.getDeclaredField("activeRc");
diff --git a/agentscope-extensions/agentscope-extensions-mem/agentscope-extensions-autocontext-memory/src/test/java/io/agentscope/core/memory/autocontext/AutoContextMemoryTest.java b/agentscope-extensions/agentscope-extensions-mem/agentscope-extensions-autocontext-memory/src/test/java/io/agentscope/core/memory/autocontext/AutoContextMemoryTest.java
index 41e15ea18..dee9c27c7 100644
--- a/agentscope-extensions/agentscope-extensions-mem/agentscope-extensions-autocontext-memory/src/test/java/io/agentscope/core/memory/autocontext/AutoContextMemoryTest.java
+++ b/agentscope-extensions/agentscope-extensions-mem/agentscope-extensions-autocontext-memory/src/test/java/io/agentscope/core/memory/autocontext/AutoContextMemoryTest.java
@@ -17,15 +17,21 @@
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import io.agentscope.core.message.Msg;
+import io.agentscope.core.message.MsgRole;
+import io.agentscope.core.message.TextBlock;
+import io.agentscope.core.message.ToolResultBlock;
+import io.agentscope.core.message.ToolUseBlock;
import io.agentscope.core.model.ChatResponse;
import io.agentscope.core.model.GenerateOptions;
import io.agentscope.core.model.Model;
import io.agentscope.core.model.ToolSchema;
import java.util.List;
+import java.util.Map;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
@@ -231,5 +237,214 @@ void builderRejectsInvalidCompressionSettings() {
assertThrows(
IllegalArgumentException.class,
() -> AutoContextConfig.builder().lastKeep(-1).build());
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> AutoContextConfig.builder().largePayloadThreshold(0).build());
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> AutoContextConfig.builder().offloadSinglePreview(-1).build());
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> AutoContextConfig.builder().msgThreshold(0).build());
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> AutoContextConfig.builder().minConsecutiveToolMessages(0).build());
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> AutoContextConfig.builder().currentRoundCompressionRatio(1.2).build());
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> AutoContextConfig.builder().minCompressionTokenThreshold(0).build());
+ }
+
+ @Test
+ void mergeWithContextAppendsTailOrReplacesWorkingBufferOnly() {
+ AutoContextMemory memory = new AutoContextMemory(AutoContextConfig.builder().build(), null);
+ Msg first = AutoContextTestSupport.userMessage("first");
+ Msg second = AutoContextTestSupport.assistantMessage("second");
+ Msg third = AutoContextTestSupport.userMessage("third");
+ memory.mergeWithContext(List.of(first, second));
+ memory.mergeWithContext(List.of(first, second, third));
+
+ assertEquals(3, memory.getMessages().size());
+ assertEquals(3, memory.getOriginalMemoryMsgs().size());
+
+ Msg reset = AutoContextTestSupport.assistantMessage("reset");
+ memory.mergeWithContext(List.of(reset));
+
+ assertEquals(1, memory.getMessages().size());
+ assertEquals("reset", memory.getMessages().get(0).getTextContent());
+ assertEquals(3, memory.getOriginalMemoryMsgs().size());
+ assertEquals("first", memory.getOriginalMemoryMsgs().get(0).getTextContent());
+ }
+
+ @Test
+ void compressIfNeededReturnsFalseWhenThresholdsOrCandidatesDoNotMatch() {
+ AutoContextMemory empty = new AutoContextMemory(AutoContextConfig.builder().build(), null);
+ assertTrue(!empty.compressIfNeeded());
+
+ AutoContextMemory belowThreshold =
+ new AutoContextMemory(
+ AutoContextConfig.builder()
+ .msgThreshold(10)
+ .minCompressionTokenThreshold(1)
+ .build(),
+ null);
+ belowThreshold.addMessage(AutoContextTestSupport.userMessage("short"));
+ belowThreshold.addMessage(AutoContextTestSupport.assistantMessage("tiny"));
+ assertTrue(!belowThreshold.compressIfNeeded());
+ }
+
+ @Test
+ void compressIfNeededHandlesCurrentRoundToolUseAndLargeToolOffload() {
+ AutoContextMemory currentRound =
+ new AutoContextMemory(
+ AutoContextConfig.builder()
+ .msgThreshold(3)
+ .lastKeep(2)
+ .minCompressionTokenThreshold(1)
+ .build(),
+ AutoContextTestSupport.recordingModel(
+ "compressed round", new AtomicReference<>()));
+ currentRound.addMessage(AutoContextTestSupport.userMessage("user"));
+ currentRound.addMessage(AutoContextTestSupport.assistantMessage("assistant body"));
+ currentRound.addMessage(
+ Msg.builder()
+ .role(MsgRole.ASSISTANT)
+ .name("assistant")
+ .content(
+ ToolUseBlock.builder()
+ .id("call-1")
+ .name("tool")
+ .input(Map.of("k", "v"))
+ .build())
+ .build());
+
+ assertTrue(currentRound.compressIfNeeded());
+ assertEquals(3, currentRound.getMessages().size());
+ assertEquals("user", currentRound.getMessages().get(0).getTextContent());
+ assertTrue(currentRound.getMessages().get(1).getTextContent().contains("compressed round"));
+ assertTrue(currentRound.getMessages().get(1).getTextContent().contains("CONTEXT_OFFLOAD"));
+ assertTrue(currentRound.getMessages().get(2).hasContentBlocks(ToolUseBlock.class));
+
+ AutoContextMemory largeTool =
+ new AutoContextMemory(
+ AutoContextConfig.builder()
+ .largePayloadThreshold(1)
+ .msgThreshold(99)
+ .maxToken(1)
+ .tokenRatio(1.0)
+ .lastKeep(0)
+ .minCompressionTokenThreshold(1)
+ .build(),
+ AutoContextTestSupport.recordingModel(
+ "tool summary", new AtomicReference<>()));
+ Msg toolMessage =
+ Msg.builder()
+ .role(MsgRole.TOOL)
+ .name("tool")
+ .content(
+ ToolResultBlock.builder()
+ .id("call-2")
+ .name("search")
+ .output(
+ List.of(
+ TextBlock.builder()
+ .text("very long payload")
+ .build()))
+ .build())
+ .build();
+ largeTool.addMessage(toolMessage);
+
+ assertTrue(largeTool.compressIfNeeded());
+ assertEquals(MsgRole.TOOL, largeTool.getMessages().get(0).getRole());
+ assertTrue(largeTool.getMessages().get(0).getMetadata().containsKey("_compress_meta"));
+ }
+
+ @Test
+ void compressIfNeededFallsBackWhenModelUnavailableOrReturnsNoText() {
+ AutoContextMemory noModel =
+ new AutoContextMemory(
+ AutoContextConfig.builder()
+ .msgThreshold(2)
+ .lastKeep(0)
+ .minCompressionTokenThreshold(1)
+ .build(),
+ null);
+ noModel.addMessage(AutoContextTestSupport.userMessage("alpha"));
+ noModel.addMessage(AutoContextTestSupport.assistantMessage("beta"));
+
+ assertTrue(noModel.compressIfNeeded());
+ assertTrue(noModel.getMessages().get(0).getTextContent().contains("Compressed 2 messages"));
+
+ Model emptyResponseModel =
+ new Model() {
+ @Override
+ public Flux stream(
+ List messages, List tools, GenerateOptions options) {
+ return Flux.just(ChatResponse.builder().content(List.of()).build());
+ }
+
+ @Override
+ public String getModelName() {
+ return "empty";
+ }
+ };
+ AutoContextMemory blankResponse =
+ new AutoContextMemory(
+ AutoContextConfig.builder()
+ .msgThreshold(2)
+ .lastKeep(0)
+ .minCompressionTokenThreshold(1)
+ .build(),
+ emptyResponseModel);
+ blankResponse.addMessage(AutoContextTestSupport.userMessage("gamma"));
+ blankResponse.addMessage(AutoContextTestSupport.assistantMessage("delta"));
+
+ assertTrue(blankResponse.compressIfNeeded());
+ assertTrue(
+ blankResponse
+ .getMessages()
+ .get(0)
+ .getTextContent()
+ .contains("Compressed 2 messages"));
+ }
+
+ @Test
+ void clearAndSetWorkingMessagesResetVisibleBuffersOnly() {
+ AutoContextMemory memory = new AutoContextMemory(AutoContextConfig.builder().build(), null);
+ memory.addMessage(AutoContextTestSupport.userMessage("a"));
+ memory.addMessage(AutoContextTestSupport.assistantMessage("b"));
+ memory.offload("uuid", List.of(AutoContextTestSupport.userMessage("offload")));
+ memory.clear("uuid");
+ assertTrue(memory.reload("uuid").isEmpty());
+
+ memory.setWorkingMessages(List.of(AutoContextTestSupport.assistantMessage("working")));
+ assertEquals(1, memory.getMessages().size());
+ assertEquals("working", memory.getMessages().get(0).getTextContent());
+ assertEquals(2, memory.getOriginalMemoryMsgs().size());
+
+ memory.clear();
+ assertTrue(memory.getMessages().isEmpty());
+ assertTrue(memory.getOriginalMemoryMsgs().isEmpty());
+ assertTrue(memory.getCompressionEvents().isEmpty());
+ assertTrue(memory.getOffloadContext().isEmpty());
+ }
+
+ @Test
+ void restoreHandlesNullSnapshotAndMissingCompressionEvents() {
+ AutoContextMemory memory = new AutoContextMemory(AutoContextConfig.builder().build(), null);
+ memory.restore(null);
+ assertTrue(memory.getMessages().isEmpty());
+
+ AutoContextState snapshot = new AutoContextState();
+ snapshot.setWorkingMessages(List.of(AutoContextTestSupport.userMessage("saved")));
+ snapshot.setOriginalMessages(List.of(AutoContextTestSupport.userMessage("saved")));
+ snapshot.setCompressionEvents(null);
+ memory.restore(snapshot);
+
+ assertEquals(1, memory.getMessages().size());
+ assertTrue(memory.getCompressionEvents().isEmpty());
+ assertNull(memory.getAutoContextConfig().getCustomPrompt());
}
}
diff --git a/agentscope-extensions/agentscope-extensions-mem/agentscope-extensions-autocontext-memory/src/test/java/io/agentscope/core/memory/autocontext/AutoContextSupportClassesTest.java b/agentscope-extensions/agentscope-extensions-mem/agentscope-extensions-autocontext-memory/src/test/java/io/agentscope/core/memory/autocontext/AutoContextSupportClassesTest.java
new file mode 100644
index 000000000..22e1c93c3
--- /dev/null
+++ b/agentscope-extensions/agentscope-extensions-mem/agentscope-extensions-autocontext-memory/src/test/java/io/agentscope/core/memory/autocontext/AutoContextSupportClassesTest.java
@@ -0,0 +1,184 @@
+/*
+ * Copyright 2024-2026 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.agentscope.core.memory.autocontext;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import io.agentscope.core.ReActAgent;
+import io.agentscope.core.hook.PostCallEvent;
+import io.agentscope.core.message.Msg;
+import io.agentscope.core.model.ChatUsage;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.junit.jupiter.api.Test;
+import reactor.test.StepVerifier;
+
+class AutoContextSupportClassesTest {
+
+ @Test
+ void promptProviderUsesCustomPromptsAndFallsBackForBlankValues() {
+ PromptConfig customPrompt =
+ PromptConfig.builder()
+ .previousRoundToolCompressPrompt("tool")
+ .previousRoundSummaryPrompt("summary")
+ .currentRoundLargeMessagePrompt("large")
+ .currentRoundCompressPrompt("current")
+ .build();
+
+ assertEquals("tool", PromptProvider.getPreviousRoundToolCompressPrompt(customPrompt));
+ assertEquals("summary", PromptProvider.getPreviousRoundSummaryPrompt(customPrompt));
+ assertEquals("large", PromptProvider.getCurrentRoundLargeMessagePrompt(customPrompt));
+ assertEquals("current", PromptProvider.getCurrentRoundCompressPrompt(customPrompt));
+
+ PromptConfig blankPrompt =
+ PromptConfig.builder()
+ .previousRoundToolCompressPrompt(" ")
+ .previousRoundSummaryPrompt("")
+ .currentRoundLargeMessagePrompt(" ")
+ .currentRoundCompressPrompt("")
+ .build();
+
+ assertEquals(
+ Prompts.PREVIOUS_ROUND_TOOL_INVOCATION_COMPRESS_PROMPT,
+ PromptProvider.getPreviousRoundToolCompressPrompt(blankPrompt));
+ assertEquals(
+ Prompts.PREVIOUS_ROUND_CONVERSATION_SUMMARY_PROMPT,
+ PromptProvider.getPreviousRoundSummaryPrompt(blankPrompt));
+ assertEquals(
+ Prompts.CURRENT_ROUND_LARGE_MESSAGE_SUMMARY_PROMPT,
+ PromptProvider.getCurrentRoundLargeMessagePrompt(blankPrompt));
+ assertEquals(
+ Prompts.CURRENT_ROUND_MESSAGE_COMPRESS_PROMPT,
+ PromptProvider.getCurrentRoundCompressPrompt(blankPrompt));
+ }
+
+ @Test
+ void compressionEventAccessorsReflectMetadataAndDefensiveCopies() {
+ Map metadata = new HashMap<>();
+ metadata.put("tokenBefore", 12);
+ metadata.put("tokenAfter", 5);
+ metadata.put("inputToken", 9);
+ metadata.put("outputToken", 3);
+ metadata.put("time", 1.5d);
+ CompressionEvent event =
+ new CompressionEvent("type", 10L, 2, "prev", "next", "compressed", metadata);
+
+ metadata.put("tokenBefore", 100);
+
+ assertEquals("type", event.getEventType());
+ assertEquals(10L, event.getTimestamp());
+ assertEquals(2, event.getCompressedMessageCount());
+ assertEquals("prev", event.getPreviousMessageId());
+ assertEquals("next", event.getNextMessageId());
+ assertEquals("compressed", event.getCompressedMessageId());
+ assertEquals(12, event.getTokenBefore());
+ assertEquals(5, event.getTokenAfter());
+ assertEquals(7, event.getTokenReduction());
+ assertEquals(9, event.getCompressInputToken());
+ assertEquals(3, event.getCompressOutputToken());
+
+ CompressionEvent mutable = new CompressionEvent();
+ mutable.setEventType("updated");
+ mutable.setTimestamp(20L);
+ mutable.setCompressedMessageCount(4);
+ mutable.setPreviousMessageId("p");
+ mutable.setNextMessageId("n");
+ mutable.setCompressedMessageId("c");
+ mutable.setMetadata(null);
+
+ assertEquals("updated", mutable.getEventType());
+ assertEquals(20L, mutable.getTimestamp());
+ assertEquals(4, mutable.getCompressedMessageCount());
+ assertEquals("p", mutable.getPreviousMessageId());
+ assertEquals("n", mutable.getNextMessageId());
+ assertEquals("c", mutable.getCompressedMessageId());
+ assertTrue(mutable.getMetadata().isEmpty());
+ }
+
+ @Test
+ void autoContextStateCopiesCollectionsAndNormalizesNulls() {
+ List working = new ArrayList<>(List.of(AutoContextTestSupport.userMessage("work")));
+ List original =
+ new ArrayList<>(List.of(AutoContextTestSupport.assistantMessage("original")));
+ List events =
+ new ArrayList<>(
+ List.of(new CompressionEvent("type", 1L, 1, null, null, null, null)));
+ Map> offload = new HashMap<>();
+ offload.put(
+ "uuid", new ArrayList<>(List.of(AutoContextTestSupport.userMessage("offload"))));
+
+ AutoContextState state = new AutoContextState();
+ state.setWorkingMessages(working);
+ state.setOriginalMessages(original);
+ state.setCompressionEvents(events);
+ state.setOffloadContext(offload);
+
+ working.clear();
+ original.clear();
+ events.clear();
+ offload.clear();
+
+ assertEquals(1, state.getWorkingMessages().size());
+ assertEquals(1, state.getOriginalMessages().size());
+ assertEquals(1, state.getCompressionEvents().size());
+ assertEquals(1, state.getOffloadContext().size());
+
+ state.setWorkingMessages(null);
+ state.setOriginalMessages(null);
+ state.setCompressionEvents(null);
+ state.setOffloadContext(null);
+
+ assertTrue(state.getWorkingMessages().isEmpty());
+ assertTrue(state.getOriginalMessages().isEmpty());
+ assertTrue(state.getCompressionEvents().isEmpty());
+ assertTrue(state.getOffloadContext().isEmpty());
+ }
+
+ @Test
+ void tokenCounterUsesMinimumOfOneAndCharHeuristic() {
+ assertEquals(1, TokenCounterUtil.calculateToken(List.of()));
+ assertEquals(
+ 2,
+ TokenCounterUtil.calculateToken(
+ List.of(AutoContextTestSupport.userMessage("12345678"))));
+ }
+
+ @Test
+ void onEventReturnsUnhandledEventUnchanged() {
+ AutoContextHook hook = new AutoContextHook();
+ ReActAgent agent =
+ ReActAgent.builder()
+ .name("post-call")
+ .sysPrompt("system")
+ .model(AutoContextTestSupport.noopModel())
+ .build();
+ Msg finalMessage =
+ Msg.builder()
+ .role(io.agentscope.core.message.MsgRole.ASSISTANT)
+ .name("assistant")
+ .textContent("done")
+ .usage(ChatUsage.builder().inputTokens(1).outputTokens(2).time(0.1).build())
+ .build();
+ PostCallEvent event = new PostCallEvent(agent, finalMessage);
+
+ StepVerifier.create(hook.onEvent(event)).expectNext(event).verifyComplete();
+ assertSame(finalMessage, event.getFinalMessage());
+ }
+}
diff --git a/agentscope-extensions/agentscope-extensions-mem/agentscope-extensions-autocontext-memory/src/test/java/io/agentscope/core/memory/autocontext/ContextOffloadToolTest.java b/agentscope-extensions/agentscope-extensions-mem/agentscope-extensions-autocontext-memory/src/test/java/io/agentscope/core/memory/autocontext/ContextOffloadToolTest.java
index 97fd97158..abacdeffa 100644
--- a/agentscope-extensions/agentscope-extensions-mem/agentscope-extensions-autocontext-memory/src/test/java/io/agentscope/core/memory/autocontext/ContextOffloadToolTest.java
+++ b/agentscope-extensions/agentscope-extensions-mem/agentscope-extensions-autocontext-memory/src/test/java/io/agentscope/core/memory/autocontext/ContextOffloadToolTest.java
@@ -16,6 +16,7 @@
package io.agentscope.core.memory.autocontext;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
import io.agentscope.core.ReActAgent;
@@ -85,4 +86,81 @@ void reloadUsesHookRuntimeContextWhenAgentAndStateAreAvailable() {
assertTrue(messages.get(0).getTextContent().contains("offloaded-one"));
assertTrue(messages.get(1).getTextContent().contains("offloaded-two"));
}
+
+ @Test
+ void reloadReturnsErrorMessagesForInvalidOrUnavailableContext() {
+ ContextOffloadTool noLoaderTool = new ContextOffloadTool((ContextOffLoader) null);
+ List blankUuidMessages = noLoaderTool.reload(" ");
+ assertEquals(1, blankUuidMessages.size());
+ assertTrue(blankUuidMessages.get(0).getTextContent().contains("UUID cannot be null"));
+
+ List unavailableMessages = noLoaderTool.reload("uuid-1");
+ assertEquals(1, unavailableMessages.size());
+ assertTrue(
+ unavailableMessages
+ .get(0)
+ .getTextContent()
+ .contains("Context offloader is not available"));
+ }
+
+ @Test
+ void reloadReturnsErrorMessageWhenLoaderThrowsOrNothingFound() {
+ ContextOffloadTool throwingTool =
+ new ContextOffloadTool(
+ new ContextOffLoader() {
+ @Override
+ public void offload(String uuid, List messages) {}
+
+ @Override
+ public List reload(String uuid) {
+ throw new IllegalStateException("boom");
+ }
+
+ @Override
+ public void clear(String uuid) {}
+ });
+
+ List thrown = throwingTool.reload("uuid-2");
+ assertEquals(1, thrown.size());
+ assertTrue(thrown.get(0).getTextContent().contains("boom"));
+
+ ContextOffloadTool emptyTool =
+ new ContextOffloadTool(
+ new ContextOffLoader() {
+ @Override
+ public void offload(String uuid, List messages) {}
+
+ @Override
+ public List reload(String uuid) {
+ return List.of();
+ }
+
+ @Override
+ public void clear(String uuid) {}
+ });
+ List missing = emptyTool.reload("uuid-3");
+ assertEquals(1, missing.size());
+ assertTrue(missing.get(0).getTextContent().contains("No messages found for UUID"));
+ }
+
+ @Test
+ void reloadWithoutAgentUsesHookBackedMemoryLookup() {
+ AutoContextHook hook = new AutoContextHook();
+ ContextOffloadTool tool = new ContextOffloadTool(hook);
+ ReActAgent agent =
+ ReActAgent.builder()
+ .name("hook-default")
+ .sysPrompt("system")
+ .model(AutoContextTestSupport.noopModel())
+ .build();
+ RuntimeContext runtimeContext =
+ RuntimeContext.builder().sessionId("session-2").userId("bob").build();
+ List expected = List.of(AutoContextTestSupport.userMessage("hook-only"));
+
+ hook.memoryFor(agent, runtimeContext).offload("hook-uuid", expected);
+
+ List reloaded = tool.reload("hook-uuid", agent, runtimeContext);
+ assertEquals(1, reloaded.size());
+ assertSame(expected.get(0), reloaded.get(0));
+ }
}
diff --git a/agentscope-extensions/agentscope-extensions-mem/agentscope-extensions-autocontext-memory/src/test/java/io/agentscope/core/memory/autocontext/MsgUtilsTest.java b/agentscope-extensions/agentscope-extensions-mem/agentscope-extensions-autocontext-memory/src/test/java/io/agentscope/core/memory/autocontext/MsgUtilsTest.java
new file mode 100644
index 000000000..b306fad97
--- /dev/null
+++ b/agentscope-extensions/agentscope-extensions-mem/agentscope-extensions-autocontext-memory/src/test/java/io/agentscope/core/memory/autocontext/MsgUtilsTest.java
@@ -0,0 +1,271 @@
+/*
+ * Copyright 2024-2026 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.agentscope.core.memory.autocontext;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import io.agentscope.core.message.ContentBlock;
+import io.agentscope.core.message.MessageMetadataKeys;
+import io.agentscope.core.message.Msg;
+import io.agentscope.core.message.MsgRole;
+import io.agentscope.core.message.TextBlock;
+import io.agentscope.core.message.ToolResultBlock;
+import io.agentscope.core.message.ToolUseBlock;
+import io.agentscope.core.util.JsonUtils;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.junit.jupiter.api.Test;
+
+class MsgUtilsTest {
+
+ @Test
+ void serializeAndDeserializeMessageListsAndMapsRoundTrip() {
+ List messages =
+ List.of(
+ AutoContextTestSupport.userMessage("hello"),
+ AutoContextTestSupport.assistantMessage("world"));
+ Map> byUuid = Map.of("uuid", messages);
+
+ Object serializedList = MsgUtils.serializeMsgList(messages);
+ Object serializedMap = MsgUtils.serializeMsgListMap(byUuid);
+
+ assertInstanceOf(List.class, serializedList);
+ assertInstanceOf(Map.class, serializedMap);
+
+ Object deserializedList = MsgUtils.deserializeToMsgList(serializedList);
+ Object deserializedMap = MsgUtils.deserializeToMsgListMap(serializedMap);
+
+ assertInstanceOf(List.class, deserializedList);
+ assertInstanceOf(Map.class, deserializedMap);
+ @SuppressWarnings("unchecked")
+ List restoredMessages = (List) deserializedList;
+ assertEquals(2, restoredMessages.size());
+ assertEquals("hello", restoredMessages.get(0).getTextContent());
+ assertEquals("world", restoredMessages.get(1).getTextContent());
+ assertEquals(MsgRole.USER, restoredMessages.get(0).getRole());
+ assertEquals(MsgRole.ASSISTANT, restoredMessages.get(1).getRole());
+ @SuppressWarnings("unchecked")
+ Map> restoredMap = (Map>) deserializedMap;
+ assertEquals(1, restoredMap.size());
+ assertEquals("hello", restoredMap.get("uuid").get(0).getTextContent());
+ assertEquals("world", restoredMap.get("uuid").get(1).getTextContent());
+
+ assertSame("plain", MsgUtils.serializeMsgList("plain"));
+ assertSame("plain", MsgUtils.deserializeToMsgList("plain"));
+ assertSame("plain", MsgUtils.serializeMsgListMap("plain"));
+ assertSame("plain", MsgUtils.deserializeToMsgListMap("plain"));
+ }
+
+ @Test
+ void serializeAndDeserializeCompressionEventsSupportMetadataAndLegacyFields() {
+ CompressionEvent event =
+ new CompressionEvent(
+ "type",
+ 1L,
+ 2,
+ "prev",
+ "next",
+ "compressed",
+ Map.of("tokenBefore", 6, "tokenAfter", 2, "inputToken", 3, "time", 0.5d));
+ Object serialized = MsgUtils.serializeCompressionEventList(List.of(event));
+ Object deserialized = MsgUtils.deserializeToCompressionEventList(serialized);
+
+ assertInstanceOf(List.class, deserialized);
+ @SuppressWarnings("unchecked")
+ List events = (List) deserialized;
+ assertEquals(1, events.size());
+ assertEquals("type", events.get(0).getEventType());
+ assertEquals(6, events.get(0).getTokenBefore());
+ assertEquals(2, events.get(0).getTokenAfter());
+
+ Map legacyEvent = new HashMap<>();
+ legacyEvent.put("eventType", "legacy");
+ legacyEvent.put("timestamp", 2L);
+ legacyEvent.put("compressedMessageCount", 1);
+ legacyEvent.put("previousMessageId", "p");
+ legacyEvent.put("nextMessageId", "n");
+ legacyEvent.put("compressedMessageId", "c");
+ legacyEvent.put("tokenBefore", 10);
+ legacyEvent.put("tokenAfter", 4);
+ legacyEvent.put("inputToken", 7);
+ legacyEvent.put("outputToken", 3);
+ legacyEvent.put("time", 1.25d);
+ List