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