This is an automated email from the ASF dual-hosted git repository.

davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git


The following commit(s) were added to refs/heads/main by this push:
     new 941c2fe2313c CAMEL-23955: Fix conversation memory user turns and 
history reset
941c2fe2313c is described below

commit 941c2fe2313c9d777d96c8826de9c5ecd328ad32
Author: Omar Atie <[email protected]>
AuthorDate: Sat Jul 18 00:11:30 2026 -0700

    CAMEL-23955: Fix conversation memory user turns and history reset
    
    Fix two bugs in OpenAI conversation memory: user turns were missing
    from history (only assistant responses were saved), and systemMessage
    reset had no effect because removeHeader() was used instead of
    removeProperty(). Added tests for both scenarios.
    
    Closes #24880
---
 .../camel/component/openai/OpenAIProducer.java     | 23 +++++-
 .../openai/OpenAIConversationMemoryResetTest.java  | 94 +++++++++++++++++++++
 .../OpenAIConversationMemoryUserMessageTest.java   | 95 ++++++++++++++++++++++
 .../ROOT/pages/camel-4x-upgrade-guide-4_22.adoc    | 12 +++
 4 files changed, 223 insertions(+), 1 deletion(-)

diff --git 
a/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIProducer.java
 
b/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIProducer.java
index 93c543f9d4d1..81995a7fae2b 100644
--- 
a/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIProducer.java
+++ 
b/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIProducer.java
@@ -72,6 +72,7 @@ public class OpenAIProducer extends DefaultAsyncProducer {
     private static final Logger LOG = 
LoggerFactory.getLogger(OpenAIProducer.class);
     private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
     private static final Pattern THINK_PATTERN = 
Pattern.compile("^\\s*<think>(.*?)</think>\\s*", Pattern.DOTALL);
+    private static final String PENDING_USER_MESSAGE = 
"CamelOpenAIPendingUserMessage";
 
     public OpenAIProducer(OpenAIEndpoint endpoint) {
         super(endpoint);
@@ -206,10 +207,12 @@ public class OpenAIProducer extends DefaultAsyncProducer {
         Message in = exchange.getIn();
         List<ChatCompletionMessageParam> messages = new ArrayList<>();
 
+        exchange.removeProperty(PENDING_USER_MESSAGE);
+
         // If a system message is configured and conversation memory is 
enabled, reset
         // history
         if (ObjectHelper.isNotEmpty(config.getSystemMessage()) && 
config.isConversationMemory()) {
-            in.removeHeader(config.getConversationHistoryProperty());
+            exchange.removeProperty(config.getConversationHistoryProperty());
         }
 
         String systemPrompt = in.getHeader(OpenAIConstants.SYSTEM_MESSAGE, 
String.class);
@@ -235,6 +238,9 @@ public class OpenAIProducer extends DefaultAsyncProducer {
         ChatCompletionMessageParam userMessage = buildUserMessage(in, config);
         if (userMessage != null) {
             messages.add(userMessage);
+            if (config.isConversationMemory()) {
+                exchange.setProperty(PENDING_USER_MESSAGE, userMessage);
+            }
         }
 
         if (messages.isEmpty()) {
@@ -658,6 +664,15 @@ public class OpenAIProducer extends DefaultAsyncProducer {
         }
     }
 
+    private void appendPendingUserMessageToHistory(Exchange exchange, 
List<ChatCompletionMessageParam> history) {
+        ChatCompletionMessageParam pendingUserMessage
+                = exchange.getProperty(PENDING_USER_MESSAGE, 
ChatCompletionMessageParam.class);
+        if (pendingUserMessage != null) {
+            history.add(pendingUserMessage);
+            exchange.removeProperty(PENDING_USER_MESSAGE);
+        }
+    }
+
     private void updateConversationHistory(
             Exchange exchange, ChatCompletionCreateParams params,
             ChatCompletion response) {
@@ -674,6 +689,8 @@ public class OpenAIProducer extends DefaultAsyncProducer {
             history = new ArrayList<>();
         }
 
+        appendPendingUserMessageToHistory(exchange, history);
+
         // Add assistant response to history
         String assistantContent = 
response.choices().get(0).message().content().orElse("");
         ChatCompletionMessageParam assistantMessage = 
ChatCompletionMessageParam.ofAssistant(
@@ -701,6 +718,8 @@ public class OpenAIProducer extends DefaultAsyncProducer {
             history = new ArrayList<>();
         }
 
+        appendPendingUserMessageToHistory(exchange, history);
+
         // Add all intermediate agentic messages (assistant+toolCalls, tool 
responses)
         history.addAll(agenticMessages);
 
@@ -733,6 +752,8 @@ public class OpenAIProducer extends DefaultAsyncProducer {
             history = new ArrayList<>();
         }
 
+        appendPendingUserMessageToHistory(exchange, history);
+
         // Add all intermediate agentic messages
         history.addAll(agenticMessages);
 
diff --git 
a/components/camel-ai/camel-openai/src/test/java/org/apache/camel/component/openai/OpenAIConversationMemoryResetTest.java
 
b/components/camel-ai/camel-openai/src/test/java/org/apache/camel/component/openai/OpenAIConversationMemoryResetTest.java
new file mode 100644
index 000000000000..ef4efc69a56d
--- /dev/null
+++ 
b/components/camel-ai/camel-openai/src/test/java/org/apache/camel/component/openai/OpenAIConversationMemoryResetTest.java
@@ -0,0 +1,94 @@
+/*
+ * 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.camel.component.openai;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicReference;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.openai.models.chat.completions.ChatCompletionMessageParam;
+import com.openai.models.chat.completions.ChatCompletionUserMessageParam;
+import org.apache.camel.Exchange;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.test.infra.openai.mock.OpenAIMock;
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Verifies the documented conversation history reset: "When systemMessage is 
set and conversationMemory is enabled, the
+ * conversation history is reset" (openai-mcp.adoc, and the systemMessage 
option description in OpenAIConfiguration).
+ */
+public class OpenAIConversationMemoryResetTest extends CamelTestSupport {
+
+    private static final ObjectMapper MAPPER = new ObjectMapper();
+
+    private final AtomicReference<String> requestBody = new 
AtomicReference<>();
+
+    @RegisterExtension
+    public OpenAIMock openAIMock = new OpenAIMock().builder()
+            .when("hello")
+            .assertRequest(requestBody::set)
+            .replyWith("hi")
+            .end()
+            .build();
+
+    @Override
+    protected RouteBuilder createRouteBuilder() {
+        return new RouteBuilder() {
+            @Override
+            public void configure() {
+                from("direct:chat")
+                        
.to("openai:chat-completion?model=gpt-5&apiKey=dummy&conversationMemory=true"
+                            + "&systemMessage=You are a helpful 
assistant&baseUrl=" + openAIMock.getBaseUrl() + "/v1");
+            }
+        };
+    }
+
+    @Test
+    void systemMessageWithConversationMemoryMustResetHistory() throws 
Exception {
+        List<ChatCompletionMessageParam> staleHistory = new ArrayList<>();
+        staleHistory.add(ChatCompletionMessageParam.ofUser(
+                ChatCompletionUserMessageParam.builder()
+                        
.content(ChatCompletionUserMessageParam.Content.ofText("stale-history-entry"))
+                        .build()));
+
+        Exchange result = template.request("direct:chat", e -> {
+            e.setProperty("CamelOpenAIConversationHistory", staleHistory);
+            e.getIn().setBody("hello");
+        });
+
+        assertThat(result.getException()).isNull();
+
+        String request = requestBody.get();
+        assertThat(request)
+                .as("The mock should have captured the chat completion 
request")
+                .isNotNull();
+
+        JsonNode messages = MAPPER.readTree(request).get("messages");
+        assertThat(messages).isNotNull();
+
+        assertThat(messages)
+                .as("systemMessage + conversationMemory=true is documented to 
reset the conversation history, "
+                    + "so the stale history entry must not be sent to the 
model. Request was: %s", request)
+                .noneMatch(message -> 
"stale-history-entry".equals(message.path("content").asText()));
+    }
+}
diff --git 
a/components/camel-ai/camel-openai/src/test/java/org/apache/camel/component/openai/OpenAIConversationMemoryUserMessageTest.java
 
b/components/camel-ai/camel-openai/src/test/java/org/apache/camel/component/openai/OpenAIConversationMemoryUserMessageTest.java
new file mode 100644
index 000000000000..2a280a84812c
--- /dev/null
+++ 
b/components/camel-ai/camel-openai/src/test/java/org/apache/camel/component/openai/OpenAIConversationMemoryUserMessageTest.java
@@ -0,0 +1,95 @@
+/*
+ * 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.camel.component.openai;
+
+import java.util.concurrent.atomic.AtomicReference;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.apache.camel.Exchange;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.test.infra.openai.mock.OpenAIMock;
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Verifies the conversation memory contract documented in 
openai-component.adoc ("Conversation Memory (Per Exchange)"):
+ * with {@code conversationMemory=true}, a second call on the same Exchange 
must send the previous <b>user</b> turn to
+ * the model, not only the previous assistant turn.
+ */
+public class OpenAIConversationMemoryUserMessageTest extends CamelTestSupport {
+
+    private static final ObjectMapper MAPPER = new ObjectMapper();
+
+    private final AtomicReference<String> secondRequestBody = new 
AtomicReference<>();
+
+    @RegisterExtension
+    public OpenAIMock openAIMock = new OpenAIMock().builder()
+            .when("My name is Alice")
+            .replyWith("Nice to meet you!")
+            .end()
+            .when("What is my name?")
+            .assertRequest(secondRequestBody::set)
+            .replyWith("Your name is Alice.")
+            .end()
+            .build();
+
+    @Override
+    protected RouteBuilder createRouteBuilder() {
+        return new RouteBuilder() {
+            @Override
+            public void configure() {
+                from("direct:conversation")
+                        
.to("openai:chat-completion?model=gpt-5&apiKey=dummy&conversationMemory=true&baseUrl="
+                            + openAIMock.getBaseUrl() + "/v1")
+                        .setBody(constant("What is my name?"))
+                        
.to("openai:chat-completion?model=gpt-5&apiKey=dummy&conversationMemory=true&baseUrl="
+                            + openAIMock.getBaseUrl() + "/v1");
+            }
+        };
+    }
+
+    @Test
+    void conversationHistoryMustIncludePreviousUserMessage() throws Exception {
+        Exchange result = template.request("direct:conversation", e -> 
e.getIn().setBody("My name is Alice"));
+
+        assertThat(result.getException()).isNull();
+        assertThat(result.getMessage().getBody(String.class)).isEqualTo("Your 
name is Alice.");
+
+        String request = secondRequestBody.get();
+        assertThat(request)
+                .as("The mock should have captured the second chat completion 
request")
+                .isNotNull();
+
+        JsonNode messages = MAPPER.readTree(request).get("messages");
+        assertThat(messages).isNotNull();
+
+        assertThat(messages)
+                .as("The previous assistant turn must be part of the 
conversation history sent to the model")
+                .anyMatch(message -> 
"assistant".equals(message.path("role").asText())
+                        && "Nice to meet 
you!".equals(message.path("content").asText()));
+        assertThat(messages)
+                .as("The previous user turn must be part of the conversation 
history sent to the model, "
+                    + "otherwise the model sees assistant answers without the 
questions that produced them. Request was: %s",
+                        request)
+                .anyMatch(message -> 
"user".equals(message.path("role").asText())
+                        && "My name is 
Alice".equals(message.path("content").asText()));
+    }
+}
diff --git 
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc 
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc
index 2d79112cac9f..3b2c9d782943 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc
@@ -163,6 +163,18 @@ String chat(AiAgentBody<?> aiAgentBody, ToolProvider 
toolProvider);
 Result<String> chat(AiAgentBody<?> aiAgentBody, ToolProvider toolProvider);
 ----
 
+=== camel-openai
+
+The `conversationMemory` feature on the `chat-completion` operation has two 
behavior fixes:
+
+* User messages are now stored in the `CamelOpenAIConversationHistory` 
exchange property (configurable via
+  `conversationHistoryProperty`) alongside assistant responses. Previously 
only assistant turns were appended,
+  so code that reads the history property directly will see roughly twice as 
many entries (alternating user
+  and assistant messages) compared to Camel 4.21 and earlier.
+* When `systemMessage` is set together with `conversationMemory=true`, the 
conversation history is now
+  correctly cleared via `exchange.removeProperty()`. Previously 
`removeHeader()` was used on a property that
+  is stored as an exchange property, so the documented reset had no effect and 
stale history was kept.
+
 === camel-management - Throughput MBean attribute uses EWMA smoothing
 
 The `Throughput` attribute on the `ManagedPerformanceCounter` JMX MBean now 
reports an EWMA

Reply via email to