From a86a9cdc5dffa996a46040a8ef50ff7f7882a9e2 Mon Sep 17 00:00:00 2001 From: Aref Behboudi Date: Wed, 19 Aug 2026 11:39:24 +0300 Subject: [PATCH] Replace ChatMemory with Spring AI Session abstraction Migrate conversation persistence from ChatMemoryRepository to the spring-ai-session SessionService: - Add spring-ai-session 0.7.0 dependency - Replace FileSystemChatMemoryRepository/ChatYamlSerializer with FileSystemSessionRepository - Wire SessionMemoryAdvisor with token-count-triggered compaction (turn-window strategy, 50k token threshold) - Update ChatChannel and DefaultAgent to use sessions keyed by a single-tenant agent user id --- .../java/ai/javaclaw/chat/ChatChannel.java | 15 +- .../ai/javaclaw/chat/ChatChannelTest.java | 32 +- .../chat/ws/ChatStreamingIntegrationTest.java | 11 +- base/build.gradle | 2 + .../ai/javaclaw/JavaClawConfiguration.java | 39 ++- .../java/ai/javaclaw/agent/DefaultAgent.java | 8 +- .../agent/memory/ChatYamlSerializer.java | 77 ----- .../FileSystemChatMemoryRepository.java | 128 ------- .../memory/FileSystemSessionRepository.java | 313 ++++++++++++++++++ .../FileSystemChatMemoryRepositoryTest.java | 194 ----------- .../FileSystemSessionRepositoryTest.java | 275 +++++++++++++++ gradle/libs.versions.toml | 1 + 12 files changed, 661 insertions(+), 434 deletions(-) delete mode 100644 base/src/main/java/ai/javaclaw/agent/memory/ChatYamlSerializer.java delete mode 100644 base/src/main/java/ai/javaclaw/agent/memory/FileSystemChatMemoryRepository.java create mode 100644 base/src/main/java/ai/javaclaw/agent/memory/FileSystemSessionRepository.java delete mode 100644 base/src/test/java/ai/javaclaw/agent/memory/FileSystemChatMemoryRepositoryTest.java create mode 100644 base/src/test/java/ai/javaclaw/agent/memory/FileSystemSessionRepositoryTest.java diff --git a/app/src/main/java/ai/javaclaw/chat/ChatChannel.java b/app/src/main/java/ai/javaclaw/chat/ChatChannel.java index c4578b53..a8b39e98 100644 --- a/app/src/main/java/ai/javaclaw/chat/ChatChannel.java +++ b/app/src/main/java/ai/javaclaw/chat/ChatChannel.java @@ -1,5 +1,6 @@ package ai.javaclaw.chat; +import ai.javaclaw.JavaClawConfiguration; import ai.javaclaw.agent.Agent; import ai.javaclaw.agent.ResponseListener; import ai.javaclaw.channels.Channel; @@ -7,10 +8,11 @@ import ai.javaclaw.channels.ChannelRegistry; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.ai.chat.memory.ChatMemoryRepository; import org.springframework.ai.chat.messages.AssistantMessage; import org.springframework.ai.chat.messages.Message; import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.session.Session; +import org.springframework.ai.session.SessionService; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; @@ -41,15 +43,15 @@ public class ChatChannel implements Channel { private final Agent agent; private final ChannelRegistry channelRegistry; - private final ChatMemoryRepository chatMemoryRepository; + private final SessionService sessionService; private final ObjectMapper objectMapper; private final ConcurrentLinkedQueue pendingMessages = new ConcurrentLinkedQueue<>(); private final AtomicReference wsSession = new AtomicReference<>(); - public ChatChannel(Agent agent, ChannelRegistry channelRegistry, ChatMemoryRepository chatMemoryRepository, ObjectMapper objectMapper) { + public ChatChannel(Agent agent, ChannelRegistry channelRegistry, SessionService sessionService, ObjectMapper objectMapper) { this.agent = agent; this.channelRegistry = channelRegistry; - this.chatMemoryRepository = chatMemoryRepository; + this.sessionService = sessionService; this.objectMapper = objectMapper; channelRegistry.registerChannel(this); log.info("Started Web Chat channel"); @@ -117,7 +119,8 @@ public void flushPendingMessages() { public List conversationIds() { List result = new ArrayList<>(); result.add("web"); - chatMemoryRepository.findConversationIds().stream() + sessionService.findByUserId(JavaClawConfiguration.AGENT_USER_ID).stream() + .map(Session::id) .filter(id -> !id.equals("web")) .forEach(result::add); return result; @@ -128,7 +131,7 @@ public List conversationIds() { * Returns a single welcome bubble if no history exists yet. */ public List loadHistoryAsHtml(String conversationId) { - List history = chatMemoryRepository.findByConversationId(conversationId); + List history = sessionService.getMessages(conversationId); if (history.isEmpty()) { return List.of(ChatHtml.agentBubble("Hi! I'm your JavaClaw assistant. How can I help you today?")); } diff --git a/app/src/test/java/ai/javaclaw/chat/ChatChannelTest.java b/app/src/test/java/ai/javaclaw/chat/ChatChannelTest.java index 7db87c16..c185504a 100644 --- a/app/src/test/java/ai/javaclaw/chat/ChatChannelTest.java +++ b/app/src/test/java/ai/javaclaw/chat/ChatChannelTest.java @@ -1,5 +1,6 @@ package ai.javaclaw.chat; +import ai.javaclaw.JavaClawConfiguration; import ai.javaclaw.agent.Agent; import ai.javaclaw.agent.ResponseListener; import ai.javaclaw.channels.ChannelRegistry; @@ -8,9 +9,10 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.ai.chat.memory.ChatMemoryRepository; import org.springframework.ai.chat.messages.AssistantMessage; import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.session.Session; +import org.springframework.ai.session.SessionService; import org.springframework.web.socket.TextMessage; import org.springframework.web.socket.WebSocketSession; import tools.jackson.databind.ObjectMapper; @@ -31,13 +33,19 @@ class ChatChannelTest { @Mock Agent agent; - @Mock ChatMemoryRepository chatMemoryRepository; + @Mock SessionService sessionService; ChatChannel chatChannel; @BeforeEach void setUp() { - chatChannel = new ChatChannel(agent, new ChannelRegistry(), chatMemoryRepository, new ObjectMapper()); + chatChannel = new ChatChannel(agent, new ChannelRegistry(), sessionService, new ObjectMapper()); + } + + private static List sessions(String... ids) { + return java.util.Arrays.stream(ids) + .map(id -> Session.builder().id(id).userId(JavaClawConfiguration.AGENT_USER_ID).build()) + .toList(); } // ----------------------------------------------------------------------- @@ -46,7 +54,7 @@ void setUp() { @Test void conversationIdsAlwaysContainsWebFirst() { - when(chatMemoryRepository.findConversationIds()).thenReturn(List.of("telegram-42", "web")); + when(sessionService.findByUserId(JavaClawConfiguration.AGENT_USER_ID)).thenReturn(sessions("telegram-42", "web")); List ids = chatChannel.conversationIds(); @@ -55,7 +63,7 @@ void conversationIdsAlwaysContainsWebFirst() { @Test void conversationIdsIncludesWebEvenWhenRepositoryReturnsEmpty() { - when(chatMemoryRepository.findConversationIds()).thenReturn(List.of()); + when(sessionService.findByUserId(JavaClawConfiguration.AGENT_USER_ID)).thenReturn(List.of()); List ids = chatChannel.conversationIds(); @@ -64,7 +72,7 @@ void conversationIdsIncludesWebEvenWhenRepositoryReturnsEmpty() { @Test void conversationIdsIncludesOtherChannelsAfterWeb() { - when(chatMemoryRepository.findConversationIds()).thenReturn(List.of("telegram-42", "telegram-99")); + when(sessionService.findByUserId(JavaClawConfiguration.AGENT_USER_ID)).thenReturn(sessions("telegram-42", "telegram-99")); List ids = chatChannel.conversationIds(); @@ -73,7 +81,7 @@ void conversationIdsIncludesOtherChannelsAfterWeb() { @Test void conversationIdsDeduplicatesWeb() { - when(chatMemoryRepository.findConversationIds()).thenReturn(List.of("web", "telegram-42")); + when(sessionService.findByUserId(JavaClawConfiguration.AGENT_USER_ID)).thenReturn(sessions("web", "telegram-42")); List ids = chatChannel.conversationIds(); @@ -86,7 +94,7 @@ void conversationIdsDeduplicatesWeb() { @Test void loadHistoryReturnsWelcomeBubbleWhenNoHistory() { - when(chatMemoryRepository.findByConversationId("web")).thenReturn(List.of()); + when(sessionService.getMessages("web")).thenReturn(List.of()); List bubbles = chatChannel.loadHistoryAsHtml("web"); @@ -96,7 +104,7 @@ void loadHistoryReturnsWelcomeBubbleWhenNoHistory() { @Test void loadHistoryRendersUserAndAgentBubbles() { - when(chatMemoryRepository.findByConversationId("web")).thenReturn(List.of( + when(sessionService.getMessages("web")).thenReturn(List.of( new UserMessage("Hello"), new AssistantMessage("Hi there") )); @@ -110,7 +118,7 @@ void loadHistoryRendersUserAndAgentBubbles() { @Test void loadHistoryEscapesHtmlInMessages() { - when(chatMemoryRepository.findByConversationId("web")).thenReturn(List.of( + when(sessionService.getMessages("web")).thenReturn(List.of( new UserMessage("") )); @@ -121,11 +129,11 @@ void loadHistoryEscapesHtmlInMessages() { @Test void loadHistoryUsesSuppliedConversationId() { - when(chatMemoryRepository.findByConversationId("telegram-42")).thenReturn(List.of()); + when(sessionService.getMessages("telegram-42")).thenReturn(List.of()); chatChannel.loadHistoryAsHtml("telegram-42"); - verify(chatMemoryRepository).findByConversationId("telegram-42"); + verify(sessionService).getMessages("telegram-42"); } // ----------------------------------------------------------------------- diff --git a/app/src/test/java/ai/javaclaw/chat/ws/ChatStreamingIntegrationTest.java b/app/src/test/java/ai/javaclaw/chat/ws/ChatStreamingIntegrationTest.java index cf177b09..e34bc07d 100644 --- a/app/src/test/java/ai/javaclaw/chat/ws/ChatStreamingIntegrationTest.java +++ b/app/src/test/java/ai/javaclaw/chat/ws/ChatStreamingIntegrationTest.java @@ -5,8 +5,9 @@ import ai.javaclaw.channels.ChannelRegistry; import ai.javaclaw.chat.ChatChannel; import org.junit.jupiter.api.Test; -import org.springframework.ai.chat.memory.ChatMemoryRepository; -import org.springframework.ai.chat.memory.InMemoryChatMemoryRepository; +import org.springframework.ai.session.DefaultSessionService; +import org.springframework.ai.session.InMemorySessionRepository; +import org.springframework.ai.session.SessionService; import org.springframework.boot.autoconfigure.ImportAutoConfiguration; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.web.server.LocalServerPort; @@ -109,8 +110,10 @@ ChannelRegistry channelRegistry() { } @Bean - ChatMemoryRepository chatMemoryRepository() { - return new InMemoryChatMemoryRepository(); + SessionService sessionService() { + return DefaultSessionService.builder() + .sessionRepository(InMemorySessionRepository.builder().build()) + .build(); } @Bean diff --git a/base/build.gradle b/base/build.gradle index bdf4d655..1e0f2f77 100644 --- a/base/build.gradle +++ b/base/build.gradle @@ -10,6 +10,7 @@ dependencies { implementation(libs.jobrunr.spring.starter) implementation 'org.apache.commons:commons-lang3' implementation 'com.fasterxml.jackson.core:jackson-core' + implementation 'tools.jackson.core:jackson-databind' // No idea? runtimeOnly(libs.netty.resolver.dns.native.macos) @@ -21,6 +22,7 @@ dependencies { implementation(libs.spring.ai.lucene) implementation(libs.spring.ai.agent.utils) + api(libs.spring.ai.session) runtimeOnly 'com.h2database:h2' testImplementation 'org.springframework.boot:spring-boot-starter-test' diff --git a/base/src/main/java/ai/javaclaw/JavaClawConfiguration.java b/base/src/main/java/ai/javaclaw/JavaClawConfiguration.java index ac1fb5cb..eaee44f9 100644 --- a/base/src/main/java/ai/javaclaw/JavaClawConfiguration.java +++ b/base/src/main/java/ai/javaclaw/JavaClawConfiguration.java @@ -11,19 +11,22 @@ import org.springaicommunity.agent.tools.SkillsTool; import org.springaicommunity.agent.tools.SmartWebFetchTool; import org.springframework.ai.chat.client.ChatClient; -import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor; import org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor; import org.springframework.ai.chat.client.advisor.ToolCallingAdvisor; import org.springframework.ai.chat.client.advisor.toolsearch.ToolSearchToolCallingAdvisor; -import org.springframework.ai.chat.memory.ChatMemory; -import org.springframework.ai.chat.memory.ChatMemoryRepository; -import org.springframework.ai.chat.memory.MessageWindowChatMemory; import org.springframework.ai.chat.messages.AssistantMessage; import org.springframework.ai.chat.model.ChatModel; import org.springframework.ai.chat.model.ChatResponse; import org.springframework.ai.chat.model.Generation; import org.springframework.ai.mcp.SyncMcpToolCallbackProvider; import org.springframework.ai.model.SpringAIModelProperties; +import org.springframework.ai.session.DefaultSessionService; +import org.springframework.ai.session.SessionRepository; +import org.springframework.ai.session.SessionService; +import org.springframework.ai.session.advisor.SessionMemoryAdvisor; +import org.springframework.ai.session.compaction.TokenCountTrigger; +import org.springframework.ai.session.compaction.TurnWindowCompactionStrategy; +import org.springframework.ai.tokenizer.JTokkitTokenCountEstimator; import org.springframework.beans.factory.ObjectProvider; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -44,6 +47,15 @@ public class JavaClawConfiguration { public static final String AGENT_MD = "AGENT.private.md"; + /** Single-tenant install: every session belongs to this user. */ + public static final String AGENT_USER_ID = "javaclaw"; + + /** + * Estimated token count of a session's history at which older turns are + * compacted out of the context window sent to the model. + */ + private static final int COMPACTION_TOKEN_THRESHOLD = 50_000; + @Value("${agent.skills.paths}") List skillPaths; @@ -51,12 +63,14 @@ public class JavaClawConfiguration { @Bean @ConditionalOnProperty(name = SpringAIModelProperties.CHAT_MODEL, havingValue = "unknown", matchIfMissing = true) public ChatModel chatModel() { - return prompt -> new ChatResponse(List.of(new Generation(new AssistantMessage("No AI model has been configured. If you did configure a model recently, restart JavaClaw manually for the changes to take effect.")))); + return _ -> new ChatResponse(List.of(new Generation(new AssistantMessage("No AI model has been configured. If you did configure a model recently, restart JavaClaw manually for the changes to take effect.")))); } @Bean - public ChatMemory chatMemory(ChatMemoryRepository chatMemoryRepository) { - return MessageWindowChatMemory.builder().chatMemoryRepository(chatMemoryRepository).build(); + public SessionService sessionService(SessionRepository sessionRepository) { + return DefaultSessionService.builder() + .sessionRepository(sessionRepository) + .build(); } @Bean @@ -73,7 +87,7 @@ public ChatClient.Builder chatClientBuilder(ObjectProvider chatModelP @Bean @DependsOn({"mcpHeaderCustomizer"}) public ChatClient chatClient(ChatClient.Builder chatClientBuilder, - ChatMemory chatMemory, + SessionService sessionService, ObjectProvider toolSearchToolCallAdvisorProvider, SyncMcpToolCallbackProvider mcpToolProvider, TaskManager taskManager, @@ -115,7 +129,14 @@ public ChatClient chatClient(ChatClient.Builder chatClientBuilder, SmartWebFetchTool.builder(chatClientBuilder.clone().build()).build()) .defaultAdvisors( toolCallAdvisor, - MessageChatMemoryAdvisor.builder(chatMemory).build() + SessionMemoryAdvisor.builder(sessionService) + .defaultUserId(AGENT_USER_ID) + .compactionTrigger(TokenCountTrigger.builder() + .threshold(COMPACTION_TOKEN_THRESHOLD) + .tokenCountEstimator(new JTokkitTokenCountEstimator()) + .build()) + .compactionStrategy(TurnWindowCompactionStrategy.builder().build()) + .build() ); autoDiscoveredTools.forEach(autoDiscoveredTool -> chatClientBuilder.defaultTools(autoDiscoveredTool.tool())); diff --git a/base/src/main/java/ai/javaclaw/agent/DefaultAgent.java b/base/src/main/java/ai/javaclaw/agent/DefaultAgent.java index e63b4761..59082cb8 100644 --- a/base/src/main/java/ai/javaclaw/agent/DefaultAgent.java +++ b/base/src/main/java/ai/javaclaw/agent/DefaultAgent.java @@ -3,7 +3,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.ai.chat.client.ChatClient; -import org.springframework.ai.chat.memory.ChatMemory; +import org.springframework.ai.session.advisor.SessionMemoryAdvisor; import org.springframework.stereotype.Component; @Component @@ -21,7 +21,7 @@ public DefaultAgent(ChatClient chatClient) { public String respondTo(String conversationId, String question) { return chatClient .prompt(question) - .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, conversationId)) + .advisors(a -> a.param(SessionMemoryAdvisor.SESSION_ID_CONTEXT_KEY, conversationId)) .call() .content(); } @@ -32,7 +32,7 @@ public String respondTo(String conversationId, String question, ResponseListener try { chatClient .prompt(question) - .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, conversationId)) + .advisors(a -> a.param(SessionMemoryAdvisor.SESSION_ID_CONTEXT_KEY, conversationId)) .stream() .content() .doOnNext(token -> { @@ -59,7 +59,7 @@ public String respondTo(String conversationId, String question, ResponseListener public T prompt(String conversationId, String input, Class result) { return chatClient .prompt(input) - .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, conversationId)) + .advisors(a -> a.param(SessionMemoryAdvisor.SESSION_ID_CONTEXT_KEY, conversationId)) .call() .entity(result); } diff --git a/base/src/main/java/ai/javaclaw/agent/memory/ChatYamlSerializer.java b/base/src/main/java/ai/javaclaw/agent/memory/ChatYamlSerializer.java deleted file mode 100644 index 004d18be..00000000 --- a/base/src/main/java/ai/javaclaw/agent/memory/ChatYamlSerializer.java +++ /dev/null @@ -1,77 +0,0 @@ -package ai.javaclaw.agent.memory; - -import org.springframework.ai.chat.messages.AssistantMessage; -import org.springframework.ai.chat.messages.Message; -import org.springframework.ai.chat.messages.MessageType; -import org.springframework.ai.chat.messages.SystemMessage; -import org.springframework.ai.chat.messages.UserMessage; -import org.springframework.util.ObjectUtils; -import org.yaml.snakeyaml.DumperOptions; -import org.yaml.snakeyaml.Yaml; - -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.stream.Collectors; - -/** - * Serialises and deserialises a list of Spring AI {@link Message} objects to/from - * a YAML block-list string, for use as the body of a {@link ai.javaclaw.files.YamlDocument}. - * - *

Format (one entry per message, role is the key): - *

- * - user: |
- *     Question text
- * - assistant: |
- *     Answer text
- * 
- */ -class ChatYamlSerializer { - - private static final Set PERSISTABLE_MESSAGES = Set.of(MessageType.USER, MessageType.ASSISTANT, MessageType.SYSTEM); - - private ChatYamlSerializer() {} - - static List deserialize(String body) { - if (body == null || body.isBlank()) { - return List.of(); - } - Yaml yaml = new Yaml(); - List> entries = yaml.load(body); - if (entries == null) { - return List.of(); - } - return entries.stream() - .map(entry -> { - Map.Entry first = entry.entrySet().iterator().next(); - return toMessage(first.getKey(), first.getValue()); - }) - .collect(Collectors.toList()); - } - - static String serialize(List messages) { - List> entries = messages.stream() - .filter(msg -> PERSISTABLE_MESSAGES.contains(msg.getMessageType()) && !ObjectUtils.isEmpty(msg.getText())) - .map(msg -> { - Map entry = new LinkedHashMap<>(); - entry.put(msg.getMessageType().getValue(), msg.getText()); - return entry; - }) - .collect(Collectors.toList()); - - DumperOptions options = new DumperOptions(); - options.setDefaultFlowStyle(DumperOptions.FlowStyle.BLOCK); - options.setPrettyFlow(true); - return new Yaml(options).dump(entries); - } - - private static Message toMessage(String role, String content) { - return switch (role) { - case "user" -> new UserMessage(content); - case "assistant" -> new AssistantMessage(content); - case "system" -> new SystemMessage(content); - default -> throw new IllegalArgumentException("Unknown role in chat history: " + role); - }; - } -} diff --git a/base/src/main/java/ai/javaclaw/agent/memory/FileSystemChatMemoryRepository.java b/base/src/main/java/ai/javaclaw/agent/memory/FileSystemChatMemoryRepository.java deleted file mode 100644 index e3b01fc8..00000000 --- a/base/src/main/java/ai/javaclaw/agent/memory/FileSystemChatMemoryRepository.java +++ /dev/null @@ -1,128 +0,0 @@ -package ai.javaclaw.agent.memory; - -import ai.javaclaw.files.YamlDocument; -import ai.javaclaw.files.YamlParser; -import org.springframework.ai.chat.memory.ChatMemoryRepository; -import org.springframework.ai.chat.messages.Message; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.core.io.Resource; -import org.springframework.stereotype.Component; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.StandardOpenOption; -import java.time.Instant; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.stream.Stream; - -/** - * Persists chat conversation history as YAML files inside the agent workspace. - * - *

The conversation ID is the channel name, e.g. {@code web} or - * {@code telegram-123456789}. The repository maps this to a flat file: - * {@code {workspace}/conversations/chat-{channel}.yaml} - * - *

Each file has a frontmatter block with timestamps and a body containing the - * message list: - *

- * ---
- * createdAt: 2026-03-21T10:00:00Z
- * updatedAt: 2026-03-21T10:05:30Z
- * ---
- * - user: |
- *     Question text
- * - assistant: |
- *     Answer text
- * 
- */ -@Component -public class FileSystemChatMemoryRepository implements ChatMemoryRepository { - - private final Path conversationsDir; - - public FileSystemChatMemoryRepository(@Value("${agent.workspace:Unknown}") Resource workspaceDir) throws IOException { - this.conversationsDir = workspaceDir.getFilePath().resolve("conversations"); - } - - @Override - public List findConversationIds() { - if (!Files.exists(conversationsDir)) return List.of(); - try (Stream files = Files.list(conversationsDir)) { - return files - .map(p -> p.getFileName().toString()) - .filter(name -> name.startsWith("chat-") && name.endsWith(".yaml")) - .map(name -> name.substring("chat-".length(), name.length() - ".yaml".length())) - .toList(); - } catch (IOException e) { - throw new RuntimeException("Failed to list conversations", e); - } - } - - @Override - public List findByConversationId(String conversationId) { - Path file = resolveFile(conversationId); - if (!Files.exists(file)) return List.of(); - try { - YamlDocument doc = YamlParser.parse(Files.readString(file)); - return ChatYamlSerializer.deserialize(doc.body()); - } catch (IOException e) { - throw new RuntimeException("Failed to read conversation: " + conversationId, e); - } - } - - @Override - public void saveAll(String conversationId, List messages) { - Path file = resolveFile(conversationId); - ensureDirectory(file.getParent()); - - // Preserve createdAt from any existing file; set it fresh on first write - String createdAt = Instant.now().toString(); - if (Files.exists(file)) { - try { - Map existing = YamlParser.parse(Files.readString(file)).frontmatter(); - if (existing.containsKey("createdAt")) createdAt = existing.get("createdAt"); - } catch (IOException ignored) {} - } - - Map frontmatter = new LinkedHashMap<>(); - frontmatter.put("createdAt", createdAt); - frontmatter.put("updatedAt", Instant.now().toString()); - - String body = ChatYamlSerializer.serialize(messages); - String content = YamlParser.serialize(new YamlDocument(frontmatter, body)); - try { - Files.writeString(file, content, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); - } catch (IOException e) { - throw new RuntimeException("Failed to save conversation: " + conversationId, e); - } - } - - @Override - public void deleteByConversationId(String conversationId) { - try { - Files.deleteIfExists(resolveFile(conversationId)); - } catch (IOException e) { - throw new RuntimeException("Failed to delete conversation: " + conversationId, e); - } - } - - /** - * Maps a conversation ID (channel name) to - * {@code conversations/chat-{channel}.yaml}. - */ - private Path resolveFile(String conversationId) { - return conversationsDir.resolve("chat-" + conversationId + ".yaml"); - } - - private static Path ensureDirectory(Path dir) { - try { - Files.createDirectories(dir); - return dir; - } catch (IOException e) { - throw new RuntimeException("Failed to create directory: " + dir, e); - } - } -} diff --git a/base/src/main/java/ai/javaclaw/agent/memory/FileSystemSessionRepository.java b/base/src/main/java/ai/javaclaw/agent/memory/FileSystemSessionRepository.java new file mode 100644 index 00000000..1469442c --- /dev/null +++ b/base/src/main/java/ai/javaclaw/agent/memory/FileSystemSessionRepository.java @@ -0,0 +1,313 @@ +package ai.javaclaw.agent.memory; + +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.messages.Message; +import org.springframework.ai.chat.messages.MessageType; +import org.springframework.ai.chat.messages.SystemMessage; +import org.springframework.ai.chat.messages.ToolResponseMessage; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.session.EventFilter; +import org.springframework.ai.session.Session; +import org.springframework.ai.session.SessionEvent; +import org.springframework.ai.session.SessionRepository; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.Resource; +import org.springframework.stereotype.Component; +import tools.jackson.core.type.TypeReference; +import tools.jackson.databind.SerializationFeature; +import tools.jackson.databind.json.JsonMapper; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; + +/** + * File-backed {@link SessionRepository} that persists each session — metadata and + * event log — as a JSON file inside the agent workspace, so chat history survives + * restarts. + * + *

The session ID is the channel name, e.g. {@code web} or + * {@code telegram-123456789}. The repository maps this to a flat file: + * {@code {workspace}/conversations/chat-{sessionId}.json} + * + *

Semantics mirror {@link org.springframework.ai.session.InMemorySessionRepository}: + * appends are idempotent by event id, compaction uses a compare-and-swap on the + * event-log version, and reads of unknown sessions return empty rather than throwing. + * Messages are stored the same way as the library's JDBC repository: message type, + * plain text, plus a JSON blob for tool calls / tool responses. All operations are + * serialized on a single lock — sufficient for a single-node agent. + */ +@Component +public class FileSystemSessionRepository implements SessionRepository { + + private final Path conversationsDir; + private final JsonMapper jsonMapper = JsonMapper.builder().enable(SerializationFeature.INDENT_OUTPUT).build(); + private final Object lock = new Object(); + + public FileSystemSessionRepository(@Value("${agent.workspace:Unknown}") Resource workspaceDir) throws IOException { + this.conversationsDir = workspaceDir.getFilePath().resolve("conversations"); + } + + @Override + public Session save(Session session) { + synchronized (lock) { + SessionFile existing = load(session.id()); + List events = existing != null ? existing.events() : List.of(); + long version = existing != null ? existing.eventVersion() : 0L; + // createdAt is immutable: keep the original timestamp on updates + String createdAt = existing != null ? existing.createdAt() : session.createdAt().toString(); + write(new SessionFile(session.id(), session.userId(), + createdAt, session.expiresAt() != null ? session.expiresAt().toString() : null, + session.metadata(), version, events)); + return session; + } + } + + @Override + public Session findById(String sessionId) { + synchronized (lock) { + SessionFile data = load(sessionId); + return data != null ? toSession(data) : null; + } + } + + @Override + public List findByUserId(String userId) { + synchronized (lock) { + return loadAll().stream() + .filter(data -> userId.equals(data.userId())) + .map(FileSystemSessionRepository::toSession) + .toList(); + } + } + + @Override + public List findExpiredSessionIds(Instant before) { + synchronized (lock) { + return loadAll().stream() + .filter(data -> data.expiresAt() != null && Instant.parse(data.expiresAt()).isBefore(before)) + .map(SessionFile::id) + .toList(); + } + } + + @Override + public void delete(String sessionId) { + synchronized (lock) { + try { + Files.deleteIfExists(resolveFile(sessionId)); + } catch (IOException e) { + throw new RuntimeException("Failed to delete session: " + sessionId, e); + } + } + } + + @Override + public void appendEvent(SessionEvent event) { + synchronized (lock) { + SessionFile data = load(event.getSessionId()); + if (data == null) { + throw new IllegalArgumentException("Session not found: " + event.getSessionId()); + } + boolean alreadyAppended = data.events().stream().anyMatch(e -> e.id().equals(event.getId())); + if (alreadyAppended) { + // Idempotent replay of an already-committed event: no duplicate, no version bump + return; + } + List events = new ArrayList<>(data.events()); + events.add(toEntry(event)); + write(data.withEvents(events)); + } + } + + @Override + public boolean compactEvents(String sessionId, List archivedEvents, + List retainedEvents, long expectedVersion) { + synchronized (lock) { + SessionFile data = load(sessionId); + if (data == null) { + throw new IllegalArgumentException("Session not found: " + sessionId); + } + if (data.eventVersion() != expectedVersion) { + return false; + } + // Previously-archived events first, then the newly-archived ones, then the new + // active window. Any other previously-active event (e.g. a superseded synthetic + // summary) is dropped. + List events = new ArrayList<>(); + data.events().stream().filter(EventEntry::archived).forEach(events::add); + archivedEvents.forEach(e -> events.add(toEntry(e.asArchived()))); + retainedEvents.forEach(e -> events.add(toEntry(e))); + write(data.withEvents(events)); + return true; + } + } + + @Override + public long getEventVersion(String sessionId) { + synchronized (lock) { + SessionFile data = load(sessionId); + return data != null ? data.eventVersion() : 0L; + } + } + + @Override + public List findEvents(String sessionId, EventFilter filter) { + synchronized (lock) { + SessionFile data = load(sessionId); + if (data == null) { + return List.of(); + } + List matched = data.events().stream() + .map(entry -> toEvent(sessionId, entry)) + .filter(filter::matches) + .collect(java.util.stream.Collectors.toCollection(ArrayList::new)); + + if (filter.lastN() != null && matched.size() > filter.lastN()) { + matched = matched.subList(matched.size() - filter.lastN(), matched.size()); + } + if (filter.pageSize() != null) { + int page = filter.page() != null ? filter.page() : 0; + int fromIdx = page * filter.pageSize(); + matched = fromIdx >= matched.size() ? new ArrayList<>() + : matched.subList(fromIdx, Math.min(fromIdx + filter.pageSize(), matched.size())); + } + return List.copyOf(matched); + } + } + + // ------------------------------------------------------------------------- + // File access + // ------------------------------------------------------------------------- + + private SessionFile load(String sessionId) { + Path file = resolveFile(sessionId); + if (!Files.exists(file)) return null; + try { + return jsonMapper.readValue(Files.readString(file), SessionFile.class); + } catch (IOException e) { + throw new RuntimeException("Failed to read session: " + sessionId, e); + } + } + + private List loadAll() { + if (!Files.exists(conversationsDir)) return List.of(); + try (Stream files = Files.list(conversationsDir)) { + return files + .map(p -> p.getFileName().toString()) + .filter(name -> name.startsWith("chat-") && name.endsWith(".json")) + .map(name -> load(name.substring("chat-".length(), name.length() - ".json".length()))) + .filter(java.util.Objects::nonNull) + .toList(); + } catch (IOException e) { + throw new RuntimeException("Failed to list sessions", e); + } + } + + private void write(SessionFile data) { + Path file = resolveFile(data.id()); + try { + Files.createDirectories(file.getParent()); + Files.writeString(file, jsonMapper.writeValueAsString(data), + StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); + } catch (IOException e) { + throw new RuntimeException("Failed to save session: " + data.id(), e); + } + } + + private Path resolveFile(String sessionId) { + return conversationsDir.resolve("chat-" + sessionId + ".json"); + } + + // ------------------------------------------------------------------------- + // Mapping + // ------------------------------------------------------------------------- + + private static Session toSession(SessionFile data) { + Session.Builder builder = Session.builder() + .id(data.id()) + .userId(data.userId()) + .createdAt(Instant.parse(data.createdAt())) + .metadata(data.metadata() != null ? data.metadata() : Map.of()); + if (data.expiresAt() != null) { + builder.expiresAt(Instant.parse(data.expiresAt())); + } + return builder.build(); + } + + private EventEntry toEntry(SessionEvent event) { + Message msg = event.getMessage(); + return new EventEntry(event.getId(), event.getTimestamp().toString(), msg.getMessageType().name(), + msg.getText(), messageDataToJson(msg), event.isArchived(), event.getBranch(), event.getMetadata()); + } + + private SessionEvent toEvent(String sessionId, EventEntry entry) { + return SessionEvent.builder() + .id(entry.id()) + .sessionId(sessionId) + .timestamp(Instant.parse(entry.timestamp())) + .message(toMessage(MessageType.valueOf(entry.messageType()), entry.text(), entry.messageData())) + .branch(entry.branch()) + .archived(entry.archived()) + .metadata(entry.metadata() != null ? entry.metadata() : Map.of()) + .build(); + } + + /** + * Type-specific message payload as JSON — tool calls for assistant messages, + * tool responses for tool messages, {@code null} otherwise (matches the + * serialization used by the library's JDBC repository). + */ + private String messageDataToJson(Message message) { + if (message instanceof AssistantMessage am && am.hasToolCalls()) { + return jsonMapper.writeValueAsString(am.getToolCalls()); + } + if (message instanceof ToolResponseMessage trm) { + return jsonMapper.writeValueAsString(trm.getResponses()); + } + return null; + } + + private Message toMessage(MessageType type, String text, String messageData) { + return switch (type) { + case USER -> new UserMessage(text != null ? text : ""); + case SYSTEM -> new SystemMessage(text != null ? text : ""); + case ASSISTANT -> { + if (messageData != null && !messageData.isBlank()) { + List toolCalls = jsonMapper.readValue(messageData, + new TypeReference>() { }); + yield AssistantMessage.builder().content(text).toolCalls(toolCalls).build(); + } + yield new AssistantMessage(text != null ? text : ""); + } + case TOOL -> { + List responses = messageData != null && !messageData.isBlank() + ? jsonMapper.readValue(messageData, new TypeReference>() { }) + : List.of(); + yield ToolResponseMessage.builder().responses(responses).build(); + } + }; + } + + // ------------------------------------------------------------------------- + // On-disk shape + // ------------------------------------------------------------------------- + + record SessionFile(String id, String userId, String createdAt, String expiresAt, + Map metadata, long eventVersion, List events) { + + SessionFile withEvents(List newEvents) { + return new SessionFile(id, userId, createdAt, expiresAt, metadata, eventVersion + 1, newEvents); + } + } + + record EventEntry(String id, String timestamp, String messageType, String text, + String messageData, boolean archived, String branch, Map metadata) { + } +} diff --git a/base/src/test/java/ai/javaclaw/agent/memory/FileSystemChatMemoryRepositoryTest.java b/base/src/test/java/ai/javaclaw/agent/memory/FileSystemChatMemoryRepositoryTest.java deleted file mode 100644 index 50887d22..00000000 --- a/base/src/test/java/ai/javaclaw/agent/memory/FileSystemChatMemoryRepositoryTest.java +++ /dev/null @@ -1,194 +0,0 @@ -package ai.javaclaw.agent.memory; - -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; -import org.springframework.ai.chat.messages.AssistantMessage; -import org.springframework.ai.chat.messages.Message; -import org.springframework.ai.chat.messages.UserMessage; -import org.springframework.core.io.FileSystemResource; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.List; - -import static org.assertj.core.api.Assertions.assertThat; - -class FileSystemChatMemoryRepositoryTest { - - @TempDir - Path workspaceDir; - FileSystemChatMemoryRepository repository; - - @BeforeEach - void setUp() throws IOException { - repository = new FileSystemChatMemoryRepository(new FileSystemResource(workspaceDir)); - } - - // ----------------------------------------------------------------------- - // saveAll / findByConversationId roundtrip - // ----------------------------------------------------------------------- - - @Test - void saveAndReloadConversation() throws IOException { - List messages = List.of( - new UserMessage("Hello!"), - new AssistantMessage("Hi there, how can I help?") - ); - - repository.saveAll("2026-03-21/web", messages); - - List loaded = repository.findByConversationId("2026-03-21/web"); - assertThat(loaded).hasSize(2); - assertThat(loaded.get(0).getText()).isEqualTo("Hello!"); - assertThat(loaded.get(1).getText()).isEqualTo("Hi there, how can I help?"); - } - - @Test - void saveCreatesFileAtCorrectPath() throws IOException { - repository.saveAll("web", List.of(new UserMessage("Hi"))); - - Path expectedFile = workspaceDir.resolve("conversations/chat-web.yaml"); - assertThat(expectedFile).exists(); - String content = Files.readString(expectedFile); - assertThat(content) - .contains("createdAt:") - .contains("updatedAt:") - .contains("user: Hi"); - } - - @Test - void savePreservesCreatedAtOnSubsequentSaves() throws IOException { - repository.saveAll("web", List.of(new UserMessage("First"))); - - Path file = workspaceDir.resolve("conversations/chat-web.yaml"); - String firstCreatedAt = extractFrontmatterValue(Files.readString(file), "createdAt"); - - repository.saveAll("web", List.of(new UserMessage("First"), new AssistantMessage("Second"))); - - String updatedCreatedAt = extractFrontmatterValue(Files.readString(file), "createdAt"); - assertThat(updatedCreatedAt).isEqualTo(firstCreatedAt); - } - - @Test - void saveUpdatesUpdatedAtOnSubsequentSaves() throws IOException { - repository.saveAll("web", List.of(new UserMessage("First"))); - Path file = workspaceDir.resolve("conversations/chat-web.yaml"); - String firstUpdatedAt = extractFrontmatterValue(Files.readString(file), "updatedAt"); - - repository.saveAll("web", List.of(new UserMessage("First"), new AssistantMessage("Second"))); - - String secondUpdatedAt = extractFrontmatterValue(Files.readString(file), "updatedAt"); - // updatedAt must be present on both writes; they may be equal if writes happen within the same instant - assertThat(secondUpdatedAt).isNotNull(); - assertThat(firstUpdatedAt).isNotNull(); - } - - @Test - void saveCreatesFileForTelegramChannel() { - repository.saveAll("telegram-123456789", List.of(new UserMessage("Hello from Telegram"))); - - Path expectedFile = workspaceDir.resolve("conversations/chat-telegram-123456789.yaml"); - assertThat(expectedFile).exists(); - } - - @Test - void saveOverwritesPreviousMessages() { - repository.saveAll("web", List.of(new UserMessage("First"))); - repository.saveAll("web", List.of(new UserMessage("First"), new AssistantMessage("Second"))); - - List loaded = repository.findByConversationId("web"); - assertThat(loaded).hasSize(2); - } - - @Test - void savePreservesMessageOrder() { - List messages = List.of( - new UserMessage("Question 1"), - new AssistantMessage("Answer 1"), - new UserMessage("Question 2"), - new AssistantMessage("Answer 2") - ); - - repository.saveAll("web", messages); - - List loaded = repository.findByConversationId("web"); - assertThat(loaded).extracting(Message::getText) - .containsExactly("Question 1", "Answer 1", "Question 2", "Answer 2"); - } - - // ----------------------------------------------------------------------- - // findByConversationId — missing file - // ----------------------------------------------------------------------- - - @Test - void findReturnsEmptyListWhenConversationDoesNotExist() { - List messages = repository.findByConversationId("web"); - - assertThat(messages).isEmpty(); - } - - @Test - void findReturnsEmptyListWhenConversationsDirDoesNotExist() { - List messages = repository.findByConversationId("web"); - - assertThat(messages).isEmpty(); - assertThat(workspaceDir.resolve("conversations")).doesNotExist(); - } - - // ----------------------------------------------------------------------- - // findConversationIds - // ----------------------------------------------------------------------- - - @Test - void findConversationIdsReturnsAllSavedIds() { - repository.saveAll("web", List.of(new UserMessage("Hi"))); - repository.saveAll("telegram-111", List.of(new UserMessage("Hello"))); - - List ids = repository.findConversationIds(); - - assertThat(ids).containsExactlyInAnyOrder( - "web", - "telegram-111" - ); - } - - @Test - void findConversationIdsReturnsEmptyListWhenNothingSaved() { - assertThat(repository.findConversationIds()).isEmpty(); - } - - // ----------------------------------------------------------------------- - // deleteByConversationId - // ----------------------------------------------------------------------- - - @Test - void deleteRemovesFile() throws IOException { - repository.saveAll("web", List.of(new UserMessage("Hi"))); - - repository.deleteByConversationId("web"); - - Path file = workspaceDir.resolve("conversations/chat-web.yaml"); - assertThat(file).doesNotExist(); - assertThat(repository.findByConversationId("web")).isEmpty(); - } - - @Test - void deleteIsIdempotentWhenFileDoesNotExist() { - // must not throw - repository.deleteByConversationId("web"); - } - - // ----------------------------------------------------------------------- - // helpers - // ----------------------------------------------------------------------- - - private static String extractFrontmatterValue(String fileContent, String key) { - return fileContent.lines() - .filter(line -> line.startsWith(key + ": ")) - .map(line -> line.substring((key + ": ").length()).strip()) - .findFirst() - .orElse(null); - } -} diff --git a/base/src/test/java/ai/javaclaw/agent/memory/FileSystemSessionRepositoryTest.java b/base/src/test/java/ai/javaclaw/agent/memory/FileSystemSessionRepositoryTest.java new file mode 100644 index 00000000..c4062092 --- /dev/null +++ b/base/src/test/java/ai/javaclaw/agent/memory/FileSystemSessionRepositoryTest.java @@ -0,0 +1,275 @@ +package ai.javaclaw.agent.memory; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.messages.ToolResponseMessage; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.session.EventFilter; +import org.springframework.ai.session.Session; +import org.springframework.ai.session.SessionEvent; +import org.springframework.core.io.FileSystemResource; + +import java.io.IOException; +import java.nio.file.Path; +import java.time.Instant; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class FileSystemSessionRepositoryTest { + + @TempDir + Path workspace; + + FileSystemSessionRepository repository; + + @BeforeEach + void setUp() throws IOException { + repository = new FileSystemSessionRepository(new FileSystemResource(workspace)); + } + + private Session saveSession(String id) { + return repository.save(Session.builder().id(id).userId("javaclaw").build()); + } + + private SessionEvent userEvent(String sessionId, String eventId, String text) { + return SessionEvent.builder().id(eventId).sessionId(sessionId).message(new UserMessage(text)).build(); + } + + // ----------------------------------------------------------------------- + // Session lifecycle + // ----------------------------------------------------------------------- + + @Test + void saveAndFindByIdRoundTripsSessionMetadata() { + repository.save(Session.builder().id("web").userId("javaclaw") + .metadata(Map.of("channel", "web")).build()); + + Session found = repository.findById("web"); + + assertThat(found).isNotNull(); + assertThat(found.id()).isEqualTo("web"); + assertThat(found.userId()).isEqualTo("javaclaw"); + assertThat(found.metadata()).containsEntry("channel", "web"); + } + + @Test + void saveCreatesFileAtCorrectPath() { + saveSession("telegram-42"); + + assertThat(workspace.resolve("conversations").resolve("chat-telegram-42.json")).exists(); + } + + @Test + void findByIdReturnsNullWhenSessionDoesNotExist() { + assertThat(repository.findById("missing")).isNull(); + } + + @Test + void savePreservesEventsAndVersionOnMetadataUpdate() { + saveSession("web"); + repository.appendEvent(userEvent("web", "e1", "Hello")); + + repository.save(Session.builder().id("web").userId("javaclaw").metadata(Map.of("updated", true)).build()); + + assertThat(repository.findEvents("web", EventFilter.all())).hasSize(1); + assertThat(repository.getEventVersion("web")).isEqualTo(1); + } + + @Test + void savePreservesCreatedAtOnMetadataUpdate() { + Instant originalCreatedAt = Instant.parse("2026-01-01T10:00:00Z"); + repository.save(Session.builder().id("web").userId("javaclaw").createdAt(originalCreatedAt).build()); + + repository.save(Session.builder().id("web").userId("javaclaw").metadata(Map.of("updated", true)).build()); + + assertThat(repository.findById("web").createdAt()).isEqualTo(originalCreatedAt); + } + + @Test + void findByUserIdReturnsOnlyMatchingSessions() { + saveSession("web"); + saveSession("telegram-42"); + repository.save(Session.builder().id("other").userId("someone-else").build()); + + List sessions = repository.findByUserId("javaclaw"); + + assertThat(sessions).extracting(Session::id).containsExactlyInAnyOrder("web", "telegram-42"); + } + + @Test + void findExpiredSessionIdsReturnsOnlyExpiredOnes() { + repository.save(Session.builder().id("old").userId("javaclaw") + .expiresAt(Instant.now().minusSeconds(60)).build()); + saveSession("fresh"); + + assertThat(repository.findExpiredSessionIds(Instant.now())).containsExactly("old"); + } + + @Test + void deleteRemovesSession() { + saveSession("web"); + + repository.delete("web"); + + assertThat(repository.findById("web")).isNull(); + } + + @Test + void deleteIsIdempotentWhenSessionDoesNotExist() { + repository.delete("missing"); + } + + // ----------------------------------------------------------------------- + // Events + // ----------------------------------------------------------------------- + + @Test + void eventsSurviveARepositoryRestart() throws IOException { + saveSession("web"); + repository.appendEvent(userEvent("web", "e1", "Hello")); + repository.appendEvent(SessionEvent.builder().id("e2").sessionId("web") + .message(new AssistantMessage("Hi there")).build()); + + FileSystemSessionRepository reloaded = new FileSystemSessionRepository(new FileSystemResource(workspace)); + + List events = reloaded.findEvents("web", EventFilter.all()); + assertThat(events).hasSize(2); + assertThat(events.get(0).getMessage().getText()).isEqualTo("Hello"); + assertThat(events.get(1).getMessage().getText()).isEqualTo("Hi there"); + assertThat(reloaded.findById("web")).isNotNull(); + assertThat(reloaded.getEventVersion("web")).isEqualTo(2); + } + + @Test + void appendEventThrowsWhenSessionDoesNotExist() { + assertThatThrownBy(() -> repository.appendEvent(userEvent("missing", "e1", "Hello"))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void appendEventIsIdempotentByEventId() { + saveSession("web"); + repository.appendEvent(userEvent("web", "e1", "Hello")); + repository.appendEvent(userEvent("web", "e1", "Hello")); + + assertThat(repository.findEvents("web", EventFilter.all())).hasSize(1); + assertThat(repository.getEventVersion("web")).isEqualTo(1); + } + + @Test + void appendEventPreservesOrderAndIncrementsVersion() { + saveSession("web"); + repository.appendEvent(userEvent("web", "e1", "first")); + repository.appendEvent(userEvent("web", "e2", "second")); + repository.appendEvent(userEvent("web", "e3", "third")); + + assertThat(repository.findEvents("web", EventFilter.all())) + .extracting(e -> e.getMessage().getText()) + .containsExactly("first", "second", "third"); + assertThat(repository.getEventVersion("web")).isEqualTo(3); + } + + @Test + void assistantMessageWithToolCallsRoundTrips() throws IOException { + saveSession("web"); + repository.appendEvent(SessionEvent.builder().id("e1").sessionId("web") + .message(AssistantMessage.builder() + .content("calling a tool") + .toolCalls(List.of(new AssistantMessage.ToolCall("call-1", "function", "search", "{\"q\":\"x\"}"))) + .build()) + .build()); + + FileSystemSessionRepository reloaded = new FileSystemSessionRepository(new FileSystemResource(workspace)); + + SessionEvent event = reloaded.findEvents("web", EventFilter.all()).get(0); + assertThat(event.hasToolCalls()).isTrue(); + AssistantMessage message = (AssistantMessage) event.getMessage(); + assertThat(message.getToolCalls()).hasSize(1); + assertThat(message.getToolCalls().get(0).name()).isEqualTo("search"); + assertThat(message.getToolCalls().get(0).arguments()).isEqualTo("{\"q\":\"x\"}"); + } + + @Test + void toolResponseMessageRoundTrips() throws IOException { + saveSession("web"); + repository.appendEvent(SessionEvent.builder().id("e1").sessionId("web") + .message(ToolResponseMessage.builder() + .responses(List.of(new ToolResponseMessage.ToolResponse("call-1", "search", "result text"))) + .build()) + .build()); + + FileSystemSessionRepository reloaded = new FileSystemSessionRepository(new FileSystemResource(workspace)); + + ToolResponseMessage message = (ToolResponseMessage) reloaded.findEvents("web", EventFilter.all()) + .get(0).getMessage(); + assertThat(message.getResponses()).hasSize(1); + assertThat(message.getResponses().get(0).responseData()).isEqualTo("result text"); + } + + @Test + void findEventsReturnsEmptyListWhenSessionDoesNotExist() { + assertThat(repository.findEvents("missing", EventFilter.all())).isEmpty(); + } + + @Test + void findEventsAppliesLastNFilter() { + saveSession("web"); + repository.appendEvent(userEvent("web", "e1", "first")); + repository.appendEvent(userEvent("web", "e2", "second")); + repository.appendEvent(userEvent("web", "e3", "third")); + + assertThat(repository.findEvents("web", EventFilter.lastN(2))) + .extracting(e -> e.getMessage().getText()) + .containsExactly("second", "third"); + } + + @Test + void getEventVersionReturnsZeroWhenSessionDoesNotExist() { + assertThat(repository.getEventVersion("missing")).isZero(); + } + + // ----------------------------------------------------------------------- + // Compaction + // ----------------------------------------------------------------------- + + @Test + void compactEventsArchivesOldEventsAndKeepsRetainedOnes() { + saveSession("web"); + SessionEvent oldest = userEvent("web", "e1", "oldest"); + SessionEvent recent = userEvent("web", "e2", "recent"); + repository.appendEvent(oldest); + repository.appendEvent(recent); + + boolean swapped = repository.compactEvents("web", List.of(oldest), List.of(recent), + repository.getEventVersion("web")); + + assertThat(swapped).isTrue(); + assertThat(repository.findEvents("web", EventFilter.active())) + .extracting(e -> e.getMessage().getText()) + .containsExactly("recent"); + assertThat(repository.findEvents("web", EventFilter.all())).hasSize(2); + } + + @Test + void compactEventsReturnsFalseOnVersionMismatch() { + saveSession("web"); + SessionEvent event = userEvent("web", "e1", "Hello"); + repository.appendEvent(event); + + boolean swapped = repository.compactEvents("web", List.of(event), List.of(), 999); + + assertThat(swapped).isFalse(); + assertThat(repository.findEvents("web", EventFilter.active())).hasSize(1); + } + + @Test + void compactEventsThrowsWhenSessionDoesNotExist() { + assertThatThrownBy(() -> repository.compactEvents("missing", List.of(), List.of(), 0)) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index cdc19d46..23f08d22 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -10,6 +10,7 @@ bom-spring-boot = { module = "org.springframework.boot:spring-boot-dependencies" bom-spring-ai = { module = "org.springframework.ai:spring-ai-bom", version.ref = "spring-ai" } bom-spring-modulith = { module = "org.springframework.modulith:spring-modulith-bom", version = "2.0.6" } spring-ai-agent-utils = { module = "org.springaicommunity:spring-ai-agent-utils", version = "0.9.0" } +spring-ai-session = { module = "org.springaicommunity:spring-ai-session", version = "0.7.0" } spring-ai-lucene = { module = "org.apache.lucene:lucene-core", version = "9.12.3" } spring-modulith-starter-test = { module = "org.springframework.modulith:spring-modulith-starter-test" } archunit-junit5 = { module = "com.tngtech.archunit:archunit-junit5", version.ref = "archunit" }