AssistantMessage mutation appends replacement content and retains cleared tool calls
Bug description
After #6997, AssistantMessage.mutate().content(...) appends the replacement text to the original text, and mutate().toolCalls(List.of()) does not clear the original tool calls. These setters replaced the corresponding fields before the message-parts change.
This affects response post-processing (for example, replacing a formatted response before parsing it) and applications removing tool calls before replaying a message. It is a regression on main, not a claim about the latest stable 2.0.1 release.
Environment
- Spring AI
mainatb6b5767eb62b61ceaaac78ad103a0ad96e8f8272,2.1.0-SNAPSHOT - Introduced by
0c815f0bca7583a3b6e6e212d38442e288109f1e(#6997) - Windows, Liberica JDK 17.0.20.1+1; repository Maven wrapper
- No model provider, API key or network request is needed by the reproducer.
Steps to reproduce
var original = AssistantMessage.builder()
.content("original")
.toolCalls(List.of(new AssistantMessage.ToolCall(
"call-1", "function", "weather", "{}")))
.build();
var rewritten = original.mutate().content("replacement").build();
var cleared = original.mutate().toolCalls(List.of()).build();
assertThat(rewritten.getText()).isEqualTo("replacement");
assertThat(cleared.getToolCalls()).isEmpty();Actual results: rewritten.getText() is "originalreplacement"; cleared.getToolCalls() still contains call-1.
I added two focused JUnit tests locally and ran:
./mvnw -B -pl spring-ai-model -am -Dtest=AssistantMessageMutationRegressionTests -Dsurefire.failIfNoSpecifiedTests=false -Dmaven.build.cache.enabled=false testResult: 2 tests, 2 assertion failures, 0 errors, 0 skipped. A standalone Java program compiled against the current message sources reproduces the same results.
Expected behavior
The existing mutation setters should replace or clear the selected content, preserving unrelated parts and the original message. Explicit part(...) calls should remain append operations.
Root cause and proposed direction
mutate() now seeds a builder using parts(getParts()). The legacy setters populate separate fields, and buildParts() appends those fields after the seeded parts. An empty replacement list therefore removes nothing. The same mechanism also applies to media.
Keep the documented append behavior for a fresh parts builder, but make mutation setters replace their corresponding parts, preserving other parts and their order. UserMessage.mutate() already follows this replacement principle.
I searched open/closed reports and read #6997's discussion. #4888 discusses response mutation APIs more generally; this report is specifically about the new replacement regression. I am working on a focused regression fix.
Investigation and reproductions were prepared with Codex assistance; the outputs above were executed locally.
Source: spring-projects/spring-ai