Skip to content

Commit 81a0fbe

Browse files
committed
fix(showcase): fix spring-ai D5 failures — all 28 features green
Root cause: TOOL_CALL_RESULT events reused the parent assistant message ID, causing React deduplicateMessages() to overwrite assistant messages with tool messages (Map keyed by ID). Fix uses unique UUIDs for each tool result message across all three controllers. Also adds data-testid to the BYOC hashbrown renderer so the D5 harness can detect assistant messages, registers AG-UI Jackson mixins via JacksonConfig postConfigurer, normalizes array-format content via ContentNormalizingModule, removes ChatMemory beans that interfered with aimock fixture matching, and injects Spring-managed ObjectMapper into AgentConfigController.
1 parent 4d2ae71 commit 81a0fbe

9 files changed

Lines changed: 222 additions & 50 deletions

File tree

showcase/integrations/spring-ai/src/app/demos/byoc-hashbrown/hashbrown-renderer.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,10 @@ const AssistantMessageRenderer = memo(function AssistantMessageRenderer({
213213
const { value } = useJsonParser(message.content ?? "", kit.schema);
214214
if (!value) return null;
215215
return (
216-
<div className="mt-2 flex w-full justify-start">
216+
<div
217+
data-testid="copilot-assistant-message"
218+
className="mt-2 flex w-full justify-start"
219+
>
217220
<div className="w-full px-1 py-1">{kit.render(value)}</div>
218221
</div>
219222
);

showcase/integrations/spring-ai/src/main/java/com/copilotkit/showcase/springai/A2uiFixedSchemaController.java

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,6 @@
33
import com.agui.server.spring.AgUiParameters;
44
import com.agui.server.spring.AgUiService;
55
import com.copilotkit.showcase.springai.tools.DisplayFlightTool;
6-
import org.springframework.ai.chat.memory.ChatMemory;
7-
import org.springframework.ai.chat.memory.InMemoryChatMemoryRepository;
8-
import org.springframework.ai.chat.memory.MessageWindowChatMemory;
96
import org.springframework.ai.chat.model.ChatModel;
107
import org.springframework.ai.tool.function.FunctionToolCallback;
118
import org.springframework.beans.factory.annotation.Autowired;
@@ -47,14 +44,9 @@ public ResponseEntity<SseEmitter> run(@RequestBody AgUiParameters params) {
4744
}
4845

4946
private StreamingToolAgent buildAgent() {
50-
ChatMemory memory = MessageWindowChatMemory.builder()
51-
.chatMemoryRepository(new InMemoryChatMemoryRepository())
52-
.maxMessages(10)
53-
.build();
5447
return StreamingToolAgent.builder()
5548
.agentId("a2ui-fixed-schema")
5649
.chatModel(chatModel)
57-
.chatMemory(memory)
5850
.systemMessage("""
5951
You are a flight-search assistant. When the user asks about a flight,
6052
call the display_flight tool with the origin airport code, destination

showcase/integrations/spring-ai/src/main/java/com/copilotkit/showcase/springai/AgentConfig.java

Lines changed: 1 addition & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,6 @@
99
import com.copilotkit.showcase.springai.tools.ManageSalesTodosTool;
1010
import com.copilotkit.showcase.springai.tools.SearchFlightsTool;
1111
import com.copilotkit.showcase.springai.tools.GenerateA2uiTool;
12-
import org.springframework.ai.chat.memory.ChatMemory;
13-
import org.springframework.ai.chat.memory.InMemoryChatMemoryRepository;
14-
import org.springframework.ai.chat.memory.MessageWindowChatMemory;
1512
import org.springframework.ai.chat.model.ChatModel;
1613
import org.springframework.ai.tool.function.FunctionToolCallback;
1714
import org.springframework.context.annotation.Bean;
@@ -22,14 +19,6 @@
2219
@Configuration
2320
public class AgentConfig {
2421

25-
@Bean
26-
public ChatMemory chatMemory() {
27-
return MessageWindowChatMemory.builder()
28-
.chatMemoryRepository(new InMemoryChatMemoryRepository())
29-
.maxMessages(10)
30-
.build();
31-
}
32-
3322
/**
3423
* Main agentic chat agent. Uses {@link StreamingToolAgent} which streams
3524
* text via {@code .stream()} for real-time delivery and falls back to
@@ -39,7 +28,7 @@ public ChatMemory chatMemory() {
3928
* auto-execute tools).
4029
*/
4130
@Bean
42-
public StreamingToolAgent agent(ChatModel chatModel, ChatMemory chatMemory) {
31+
public StreamingToolAgent agent(ChatModel chatModel) {
4332
// Shared mutable todos list between get/manage tools
4433
var salesTodosTool = new GetSalesTodosTool();
4534
List<SalesTodo> sharedTodos = salesTodosTool.getTodos();
@@ -48,7 +37,6 @@ public StreamingToolAgent agent(ChatModel chatModel, ChatMemory chatMemory) {
4837
return StreamingToolAgent.builder()
4938
.agentId("agentic_chat")
5039
.chatModel(chatModel)
51-
.chatMemory(chatMemory)
5240
.systemMessage("""
5341
You are a helpful assistant for the CopilotKit showcase.
5442
You can check the weather using the get_weather tool.

showcase/integrations/spring-ai/src/main/java/com/copilotkit/showcase/springai/AgentConfigController.java

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,6 @@
44
import com.agui.server.spring.AgUiService;
55
import com.fasterxml.jackson.databind.JsonNode;
66
import com.fasterxml.jackson.databind.ObjectMapper;
7-
import org.springframework.ai.chat.memory.ChatMemory;
8-
import org.springframework.ai.chat.memory.InMemoryChatMemoryRepository;
9-
import org.springframework.ai.chat.memory.MessageWindowChatMemory;
107
import org.springframework.ai.chat.model.ChatModel;
118
import org.springframework.beans.factory.annotation.Autowired;
129
import org.springframework.http.CacheControl;
@@ -46,12 +43,14 @@ public class AgentConfigController {
4643

4744
private final AgUiService agUiService;
4845
private final ChatModel chatModel;
49-
private final ObjectMapper objectMapper = new ObjectMapper();
46+
private final ObjectMapper objectMapper;
5047

5148
@Autowired
52-
public AgentConfigController(AgUiService agUiService, ChatModel chatModel) {
49+
public AgentConfigController(AgUiService agUiService, ChatModel chatModel,
50+
ObjectMapper objectMapper) {
5351
this.agUiService = agUiService;
5452
this.chatModel = chatModel;
53+
this.objectMapper = objectMapper;
5554
}
5655

5756
@PostMapping("/agent-config/run")
@@ -85,14 +84,9 @@ private static String normalize(String value, Set<String> allowed, String fallba
8584
}
8685

8786
private StreamingToolAgent buildAgent(String systemPrompt) {
88-
ChatMemory memory = MessageWindowChatMemory.builder()
89-
.chatMemoryRepository(new InMemoryChatMemoryRepository())
90-
.maxMessages(10)
91-
.build();
9287
return StreamingToolAgent.builder()
9388
.agentId("agent-config-demo")
9489
.chatModel(chatModel)
95-
.chatMemory(memory)
9690
.systemMessage(systemPrompt)
9791
.build();
9892
}
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
package com.copilotkit.showcase.springai;
2+
3+
import com.fasterxml.jackson.databind.JsonNode;
4+
import com.fasterxml.jackson.databind.ObjectMapper;
5+
import com.fasterxml.jackson.databind.node.ArrayNode;
6+
import com.fasterxml.jackson.databind.node.ObjectNode;
7+
import com.fasterxml.jackson.databind.node.TextNode;
8+
import org.slf4j.Logger;
9+
import org.slf4j.LoggerFactory;
10+
import org.springframework.core.MethodParameter;
11+
import org.springframework.http.HttpInputMessage;
12+
import org.springframework.http.converter.HttpMessageConverter;
13+
import org.springframework.web.bind.annotation.ControllerAdvice;
14+
import org.springframework.web.servlet.mvc.method.annotation.RequestBodyAdviceAdapter;
15+
16+
import java.io.ByteArrayInputStream;
17+
import java.io.IOException;
18+
import java.io.InputStream;
19+
import java.lang.reflect.Type;
20+
import java.nio.charset.StandardCharsets;
21+
22+
/**
23+
* Pre-processes AG-UI request bodies to normalize array-format content fields
24+
* before Jackson deserialization.
25+
*
26+
* <p>The CopilotKit runtime re-invokes the agent after processing frontend
27+
* tool calls (e.g. {@code useRenderTool}). In the re-invocation payload,
28+
* assistant messages carry their {@code content} in the OpenAI multi-part
29+
* format:
30+
* <pre>{@code
31+
* "content": [{"type": "text", "text": "actual text here"}]
32+
* }</pre>
33+
*
34+
* <p>The AG-UI Java SDK's {@code BaseMessage.setContent(String)} expects a
35+
* plain {@code String}. Without normalization, Jackson throws
36+
* "Cannot deserialize value of type java.lang.String from Array value"
37+
* and the re-invocation fails with HTTP 400.
38+
*
39+
* <p>This advice intercepts the raw request body, scans for {@code messages[]}
40+
* entries whose {@code content} is an array, extracts the text, and rewrites
41+
* them as plain strings before Jackson processes the body.
42+
*/
43+
@ControllerAdvice
44+
public class ContentNormalizingModule extends RequestBodyAdviceAdapter {
45+
46+
private static final Logger log = LoggerFactory.getLogger(ContentNormalizingModule.class);
47+
private static final ObjectMapper RAW_MAPPER = new ObjectMapper();
48+
49+
@Override
50+
public boolean supports(
51+
MethodParameter methodParameter,
52+
Type targetType,
53+
Class<? extends HttpMessageConverter<?>> converterType) {
54+
// Apply to all request bodies — the normalization is idempotent and
55+
// only modifies messages[] entries with array content.
56+
return true;
57+
}
58+
59+
@Override
60+
public HttpInputMessage beforeBodyRead(
61+
HttpInputMessage inputMessage,
62+
MethodParameter parameter,
63+
Type targetType,
64+
Class<? extends HttpMessageConverter<?>> converterType)
65+
throws IOException {
66+
67+
byte[] body = inputMessage.getBody().readAllBytes();
68+
String bodyStr = new String(body, StandardCharsets.UTF_8);
69+
70+
// Only process JSON that looks like it contains messages
71+
if (!bodyStr.contains("\"messages\"")) {
72+
return createMessage(inputMessage, body);
73+
}
74+
75+
try {
76+
JsonNode root = RAW_MAPPER.readTree(body);
77+
JsonNode messagesNode = root.get("messages");
78+
79+
if (messagesNode == null || !messagesNode.isArray()) {
80+
return createMessage(inputMessage, body);
81+
}
82+
83+
boolean modified = false;
84+
for (JsonNode msg : messagesNode) {
85+
if (msg == null || !msg.isObject()) continue;
86+
87+
JsonNode contentNode = msg.get("content");
88+
if (contentNode != null && contentNode.isArray()) {
89+
String extracted = extractTextFromArray((ArrayNode) contentNode);
90+
((ObjectNode) msg).set("content", new TextNode(extracted));
91+
modified = true;
92+
}
93+
}
94+
95+
if (modified) {
96+
byte[] normalized = RAW_MAPPER.writeValueAsBytes(root);
97+
log.debug("Normalized array content in {} message(s)", "messages");
98+
return createMessage(inputMessage, normalized);
99+
}
100+
} catch (Exception e) {
101+
// If normalization fails, pass the original body through unchanged.
102+
// Jackson will report the error normally.
103+
log.warn("Content normalization failed; passing original body", e);
104+
}
105+
106+
return createMessage(inputMessage, body);
107+
}
108+
109+
/**
110+
* Extracts and concatenates text from an OpenAI-style multi-part
111+
* content array. Each element is expected to have the shape
112+
* {@code {"type": "text", "text": "..."}}. Non-text elements are
113+
* skipped.
114+
*/
115+
private static String extractTextFromArray(ArrayNode arrayNode) {
116+
StringBuilder sb = new StringBuilder();
117+
for (JsonNode elem : arrayNode) {
118+
if (elem.isTextual()) {
119+
sb.append(elem.asText());
120+
} else if (elem.isObject()) {
121+
JsonNode typeNode = elem.get("type");
122+
JsonNode textNode = elem.get("text");
123+
if (typeNode != null && "text".equals(typeNode.asText())
124+
&& textNode != null) {
125+
sb.append(textNode.asText());
126+
}
127+
}
128+
}
129+
return sb.toString();
130+
}
131+
132+
/**
133+
* Creates an HttpInputMessage wrapping the given body bytes while
134+
* preserving the original headers.
135+
*/
136+
private static HttpInputMessage createMessage(
137+
HttpInputMessage original, byte[] body) {
138+
return new HttpInputMessage() {
139+
@Override
140+
public InputStream getBody() {
141+
return new ByteArrayInputStream(body);
142+
}
143+
144+
@Override
145+
public org.springframework.http.HttpHeaders getHeaders() {
146+
return original.getHeaders();
147+
}
148+
};
149+
}
150+
}

showcase/integrations/spring-ai/src/main/java/com/copilotkit/showcase/springai/JacksonConfig.java

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.copilotkit.showcase.springai;
22

3+
import com.agui.json.ObjectMapperFactory;
34
import com.fasterxml.jackson.databind.DeserializationFeature;
45
import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer;
56
import org.springframework.context.annotation.Bean;
@@ -26,15 +27,35 @@
2627
* Downstream code must null-check message lists (see
2728
* {@link MessageListFilter}).</li>
2829
* </ul>
30+
*
31+
* <p>We also explicitly register the AG-UI mixins on Spring's global
32+
* ObjectMapper via a {@code postConfigurer} callback. The AG-UI
33+
* {@code AgUiAutoConfiguration} registers them during bean wiring of
34+
* {@code AgUiService}, but that can race with
35+
* {@code MappingJackson2HttpMessageConverter} capturing the ObjectMapper
36+
* reference. By registering inside the builder customizer's
37+
* {@code postConfigurer}, the mixins are applied during ObjectMapper
38+
* construction — before any other bean sees it.
39+
*
40+
* <p>Array-format content normalization (for CopilotKit re-invocation
41+
* payloads) is handled separately by {@link ContentNormalizingModule},
42+
* a {@code @ControllerAdvice} that pre-processes the raw JSON body
43+
* before Jackson deserialization.
2944
*/
3045
@Configuration
3146
public class JacksonConfig {
3247

3348
@Bean
3449
public Jackson2ObjectMapperBuilderCustomizer tolerantObjectMapperCustomizer() {
35-
return builder -> builder.featuresToDisable(
36-
DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES,
37-
DeserializationFeature.FAIL_ON_INVALID_SUBTYPE
38-
);
50+
return builder -> {
51+
builder.featuresToDisable(
52+
DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES,
53+
DeserializationFeature.FAIL_ON_INVALID_SUBTYPE);
54+
55+
// Register AG-UI mixins (MessageMixin, EventMixin, StateMixin)
56+
// during ObjectMapper construction so they are present before
57+
// any @RequestBody deserialization.
58+
builder.postConfigurer(ObjectMapperFactory::addMixins);
59+
};
3960
}
4061
}

showcase/integrations/spring-ai/src/main/java/com/copilotkit/showcase/springai/SharedStateReadWriteController.java

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,7 @@ protected void run(RunAgentInput input, AgentSubscriber subscriber) {
213213
deferredEvents.add(toolCallResultEvent(
214214
toolCallId,
215215
setNotesHandler.lastResult(),
216-
messageId,
216+
UUID.randomUUID().toString(),
217217
Role.tool));
218218
subscriber.onNewToolCall(call);
219219
});
@@ -234,10 +234,12 @@ protected void run(RunAgentInput input, AgentSubscriber subscriber) {
234234
return;
235235
}
236236

237-
this.emitEvent(textMessageEndEvent(messageId), subscriber);
237+
// Emit tool call events BEFORE textMessageEnd so the frontend's
238+
// useRenderTool sees them while the message is still "open".
238239
for (BaseEvent ev : deferredEvents) {
239240
this.emitEvent(ev, subscriber);
240241
}
242+
this.emitEvent(textMessageEndEvent(messageId), subscriber);
241243
subscriber.onNewMessage(assistantMessage);
242244
this.emitEvent(runFinishedEvent(threadId, runId), subscriber);
243245
subscriber.onRunFinalized(
@@ -352,8 +354,11 @@ public synchronized String apply(SetNotesRequest request) {
352354
deferredEvents.add(toolCallStartEvent(parentMessageId, "set_notes", toolCallId));
353355
deferredEvents.add(toolCallArgsEvent(argsJson, toolCallId));
354356
deferredEvents.add(toolCallEndEvent(toolCallId));
357+
// Tool result message must have its own unique messageId — reusing
358+
// parentMessageId causes React deduplicateMessages() to overwrite
359+
// the assistant message with the tool message in the Map.
355360
deferredEvents.add(toolCallResultEvent(
356-
toolCallId, result, parentMessageId, Role.tool));
361+
toolCallId, result, UUID.randomUUID().toString(), Role.tool));
357362
deferredEvents.add(stateSnapshotEvent(state));
358363

359364
return result;

0 commit comments

Comments
 (0)