fix(ai): correct conversation message handling (#4208)

Signed-off-by: yuluo-yx <yuluo08290126@gmail.com>
diff --git a/hertzbeat-ai/src/main/java/org/apache/hertzbeat/ai/service/impl/ConversationServiceImpl.java b/hertzbeat-ai/src/main/java/org/apache/hertzbeat/ai/service/impl/ConversationServiceImpl.java
index 94c7605..b642d62 100644
--- a/hertzbeat-ai/src/main/java/org/apache/hertzbeat/ai/service/impl/ConversationServiceImpl.java
+++ b/hertzbeat-ai/src/main/java/org/apache/hertzbeat/ai/service/impl/ConversationServiceImpl.java
@@ -34,7 +34,6 @@
 import org.springframework.http.codec.ServerSentEvent;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
-import org.springframework.util.CollectionUtils;
 import reactor.core.publisher.Flux;
 
 import java.util.Collections;
@@ -99,8 +98,7 @@
         ChatRequestContext context = ChatRequestContext.builder()
             .message(message)
             .conversationId(conversationId)
-            .conversationHistory(CollectionUtils.isEmpty(conversation.getMessages()) ? null
-                : conversation.getMessages().subList(0, conversation.getMessages().size() - 1))
+            .conversationHistory(messages)
             .build();
 
         // Stream response from AI service
diff --git a/hertzbeat-ai/src/test/java/org/apache/hertzbeat/ai/service/impl/ConversationServiceImplTest.java b/hertzbeat-ai/src/test/java/org/apache/hertzbeat/ai/service/impl/ConversationServiceImplTest.java
new file mode 100644
index 0000000..f46c5dc
--- /dev/null
+++ b/hertzbeat-ai/src/test/java/org/apache/hertzbeat/ai/service/impl/ConversationServiceImplTest.java
@@ -0,0 +1,108 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.hertzbeat.ai.service.impl;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.util.List;
+import java.util.Optional;
+import java.util.concurrent.atomic.AtomicLong;
+import org.apache.hertzbeat.ai.dao.ChatConversationDao;
+import org.apache.hertzbeat.ai.dao.ChatMessageDao;
+import org.apache.hertzbeat.ai.pojo.dto.ChatRequestContext;
+import org.apache.hertzbeat.ai.pojo.dto.ChatResponseChunk;
+import org.apache.hertzbeat.ai.service.ChatClientProviderService;
+import org.apache.hertzbeat.common.entity.ai.ChatConversation;
+import org.apache.hertzbeat.common.entity.ai.ChatMessage;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.http.codec.ServerSentEvent;
+import reactor.core.publisher.Flux;
+
+/**
+ * Tests multi-turn conversation context handling in {@link ConversationServiceImpl}.
+ */
+@ExtendWith(MockitoExtension.class)
+class ConversationServiceImplTest {
+
+    private static final long CONVERSATION_ID = 1L;
+
+    @Mock
+    private ChatConversationDao conversationDao;
+
+    @Mock
+    private ChatMessageDao messageDao;
+
+    @Mock
+    private ChatClientProviderService chatClientProviderService;
+
+    @InjectMocks
+    private ConversationServiceImpl conversationService;
+
+    @Test
+    void streamChatShouldKeepCompleteConversationHistory() {
+        ChatConversation conversation = ChatConversation.builder()
+            .id(CONVERSATION_ID)
+            .title("已命名会话")
+            .build();
+        List<ChatMessage> history = List.of(
+            ChatMessage.builder()
+                .id(11L)
+                .conversationId(CONVERSATION_ID)
+                .role("user")
+                .content("上一轮问题")
+                .build(),
+            ChatMessage.builder()
+                .id(12L)
+                .conversationId(CONVERSATION_ID)
+                .role("assistant")
+                .content("上一轮回答")
+                .build());
+        AtomicLong messageId = new AtomicLong(20L);
+
+        when(chatClientProviderService.isConfigured()).thenReturn(true);
+        when(conversationDao.findById(CONVERSATION_ID)).thenReturn(Optional.of(conversation));
+        when(messageDao.findByConversationIdOrderByGmtCreateAsc(CONVERSATION_ID)).thenReturn(history);
+        when(messageDao.save(any(ChatMessage.class))).thenAnswer(invocation -> {
+            ChatMessage savedMessage = invocation.getArgument(0);
+            savedMessage.setId(messageId.getAndIncrement());
+            return savedMessage;
+        });
+        when(chatClientProviderService.streamChat(any(ChatRequestContext.class)))
+            .thenReturn(Flux.just("本轮回答"));
+
+        List<ServerSentEvent<ChatResponseChunk>> events = conversationService
+            .streamChat("本轮问题", CONVERSATION_ID)
+            .collectList()
+            .block();
+
+        assertNotNull(events);
+        assertEquals(2, events.size());
+        ArgumentCaptor<ChatRequestContext> contextCaptor = ArgumentCaptor.forClass(ChatRequestContext.class);
+        verify(chatClientProviderService).streamChat(contextCaptor.capture());
+        assertEquals(history, contextCaptor.getValue().getConversationHistory());
+    }
+}
diff --git a/hertzbeat-common-spring/src/main/java/org/apache/hertzbeat/common/entity/ai/ChatMessage.java b/hertzbeat-common-spring/src/main/java/org/apache/hertzbeat/common/entity/ai/ChatMessage.java
index 3e7568b..8938ba4 100644
--- a/hertzbeat-common-spring/src/main/java/org/apache/hertzbeat/common/entity/ai/ChatMessage.java
+++ b/hertzbeat-common-spring/src/main/java/org/apache/hertzbeat/common/entity/ai/ChatMessage.java
@@ -18,6 +18,7 @@
 package org.apache.hertzbeat.common.entity.ai;
 
 import static io.swagger.v3.oas.annotations.media.Schema.AccessMode.READ_ONLY;
+import com.fasterxml.jackson.annotation.JsonIgnore;
 import io.swagger.v3.oas.annotations.media.Schema;
 import jakarta.persistence.Column;
 import jakarta.persistence.Entity;
@@ -61,12 +62,13 @@
     private Long id;
     
     @Schema(title = "conversation id")
-    @Column(name = "conversation_id", insertable = false, updatable = false)
+    @Column(name = "conversation_id")
     private Long conversationId;
 
+    @JsonIgnore
     @Schema(title = "conversation", hidden = true)
     @ManyToOne
-    @JoinColumn(name = "conversation_id")
+    @JoinColumn(name = "conversation_id", insertable = false, updatable = false)
     private ChatConversation conversation;
 
     @Schema(title = "message content")
diff --git a/hertzbeat-common-spring/src/test/java/org/apache/hertzbeat/common/entity/ai/ChatMessageTest.java b/hertzbeat-common-spring/src/test/java/org/apache/hertzbeat/common/entity/ai/ChatMessageTest.java
new file mode 100644
index 0000000..801e9ab
--- /dev/null
+++ b/hertzbeat-common-spring/src/test/java/org/apache/hertzbeat/common/entity/ai/ChatMessageTest.java
@@ -0,0 +1,52 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.hertzbeat.common.entity.ai;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+import java.util.List;
+import org.apache.hertzbeat.common.util.JsonUtil;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests AI conversation message serialization.
+ */
+class ChatMessageTest {
+
+    @Test
+    void serializationShouldNotRecurseThroughConversation() {
+        ChatConversation conversation = ChatConversation.builder()
+            .id(1L)
+            .title("序列化测试会话")
+            .build();
+        ChatMessage message = ChatMessage.builder()
+            .id(2L)
+            .conversationId(conversation.getId())
+            .conversation(conversation)
+            .role("assistant")
+            .content("序列化测试消息")
+            .build();
+        conversation.setMessages(List.of(message));
+
+        String json = JsonUtil.toJson(conversation);
+
+        assertNotNull(json);
+        assertFalse(json.contains("\"conversation\""));
+    }
+}
diff --git a/hertzbeat-startup/src/test/java/org/apache/hertzbeat/startup/dao/ChatMessageDaoTest.java b/hertzbeat-startup/src/test/java/org/apache/hertzbeat/startup/dao/ChatMessageDaoTest.java
new file mode 100644
index 0000000..bbdc3cb
--- /dev/null
+++ b/hertzbeat-startup/src/test/java/org/apache/hertzbeat/startup/dao/ChatMessageDaoTest.java
@@ -0,0 +1,66 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.hertzbeat.startup.dao;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import jakarta.annotation.Resource;
+import jakarta.persistence.EntityManager;
+import jakarta.persistence.PersistenceContext;
+import java.util.List;
+import org.apache.hertzbeat.ai.dao.ChatConversationDao;
+import org.apache.hertzbeat.ai.dao.ChatMessageDao;
+import org.apache.hertzbeat.common.entity.ai.ChatConversation;
+import org.apache.hertzbeat.common.entity.ai.ChatMessage;
+import org.apache.hertzbeat.startup.AbstractSpringIntegrationTest;
+import org.junit.jupiter.api.Test;
+import org.springframework.transaction.annotation.Transactional;
+
+/**
+ * Tests persistence mapping for AI conversation messages.
+ */
+@Transactional
+class ChatMessageDaoTest extends AbstractSpringIntegrationTest {
+
+    @Resource
+    private ChatConversationDao conversationDao;
+
+    @Resource
+    private ChatMessageDao messageDao;
+
+    @PersistenceContext
+    private EntityManager entityManager;
+
+    @Test
+    void saveMessageShouldPersistConversationId() {
+        ChatConversation conversation = conversationDao.saveAndFlush(
+            ChatConversation.builder().title("映射测试会话").build());
+        messageDao.saveAndFlush(ChatMessage.builder()
+            .conversationId(conversation.getId())
+            .role("user")
+            .content("映射测试消息")
+            .build());
+        entityManager.clear();
+
+        List<ChatMessage> messages = messageDao
+            .findByConversationIdOrderByGmtCreateAsc(conversation.getId());
+
+        assertEquals(1, messages.size());
+        assertEquals(conversation.getId(), messages.getFirst().getConversationId());
+    }
+}