Skip to content

Commit 50476f0

Browse files
author
Yuriy Bezsonov
committed
feat(java-spring-ai-agents): upgrade to Spring AI 2.0.0-RC2, add tests
- aiagent + backoffice to Spring AI 2.0.0-RC2, AWS SDK BOM 2.46.7 - Models: global Claude Sonnet 4.6 / Opus 4.6 - Fix BrowserArtifacts.url Optional handling and bedrock timeout property - Remove dead null-checks from ChatService (fail-fast) - Add unit tests + env-guarded integration tests (Failsafe)
1 parent 62a53cd commit 50476f0

15 files changed

Lines changed: 314 additions & 54 deletions

File tree

apps/java-spring-ai-agents/aiagent/pom.xml

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
</scm>
2929
<properties>
3030
<java.version>25</java.version>
31-
<spring-ai.version>2.0.0-M6</spring-ai.version>
31+
<spring-ai.version>2.0.0-RC2</spring-ai.version>
3232
</properties>
3333
<dependencies>
3434
<!-- Security - OAuth2 Resource Server for JWT validation -->
@@ -72,7 +72,7 @@
7272
</dependency>
7373
<dependency>
7474
<groupId>org.springframework.ai</groupId>
75-
<artifactId>spring-ai-advisors-vector-store</artifactId>
75+
<artifactId>spring-ai-vector-store-advisor</artifactId>
7676
</dependency>
7777
<!-- AgentCore Memory dependencies -->
7878
<dependency>
@@ -136,7 +136,7 @@
136136
<dependency>
137137
<groupId>software.amazon.awssdk</groupId>
138138
<artifactId>bom</artifactId>
139-
<version>2.46.6</version>
139+
<version>2.46.7</version>
140140
<type>pom</type>
141141
<scope>import</scope>
142142
</dependency>
@@ -149,6 +149,22 @@
149149
<groupId>org.springframework.boot</groupId>
150150
<artifactId>spring-boot-maven-plugin</artifactId>
151151
</plugin>
152+
<!-- Integration tests (*IT) run only in the integration-test phase (mvn verify),
153+
never during the unit test phase (mvn test). They are additionally guarded at
154+
runtime by @EnabledIfEnvironmentVariable(AWS_ACCESS_KEY_ID) so they are skipped
155+
unless AWS credentials are present. -->
156+
<plugin>
157+
<groupId>org.apache.maven.plugins</groupId>
158+
<artifactId>maven-failsafe-plugin</artifactId>
159+
<executions>
160+
<execution>
161+
<goals>
162+
<goal>integration-test</goal>
163+
<goal>verify</goal>
164+
</goals>
165+
</execution>
166+
</executions>
167+
</plugin>
152168
</plugins>
153169
</build>
154170

apps/java-spring-ai-agents/aiagent/src/main/java/com/example/agent/ChatService.java

Lines changed: 14 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ public ChatService(AgentCoreMemory agentCoreMemory,
6565
@Qualifier("codeInterpreterArtifactStore") ArtifactStore<GeneratedFile> codeInterpreterArtifactStore,
6666
@Qualifier("mcpToolCallbacks") ToolCallbackProvider mcpTools,
6767
ChatModel chatModel,
68-
@Value("${app.ai.document.model:global.anthropic.claude-opus-4-5-20251101-v1:0}") String documentModel,
68+
@Value("${app.ai.document.model:global.anthropic.claude-opus-4-6-v1}") String documentModel,
6969
ChatClient.Builder chatClientBuilder) {
7070

7171
List<Advisor> advisors = new ArrayList<>();
@@ -75,41 +75,31 @@ public ChatService(AgentCoreMemory agentCoreMemory,
7575
logger.info("Memory enabled: {} advisors", agentCoreMemory.advisors.size());
7676

7777
// Knowledge Base (RAG)
78-
if (kbVectorStore != null) {
79-
advisors.add(QuestionAnswerAdvisor.builder(kbVectorStore).build());
80-
logger.info("KB RAG enabled");
81-
}
78+
advisors.add(QuestionAnswerAdvisor.builder(kbVectorStore).build());
79+
logger.info("KB RAG enabled");
8280

8381
// ContextAdvisor
8482
advisors.add(contextAdvisor);
85-
logger.info("Context Advisor enabled");
83+
logger.info("Context Advisor enabled");
8684

8785
// Tools
8886
List<Object> localTools = new ArrayList<>();
89-
if (webGroundingTools != null) {
90-
localTools.add(webGroundingTools);
91-
logger.info("Web Grounding enabled");
92-
}
87+
localTools.add(webGroundingTools);
88+
logger.info("Web Grounding enabled");
9389

9490
// Tool Callback Providers
9591
this.browserArtifactStore = browserArtifactStore;
9692
List<ToolCallbackProvider> toolCallbackProviders = new ArrayList<>();
97-
if (browserTools != null) {
98-
toolCallbackProviders.add(browserTools);
99-
logger.info("Browser enabled");
100-
}
93+
toolCallbackProviders.add(browserTools);
94+
logger.info("Browser enabled");
10195

10296
this.codeInterpreterArtifactStore = codeInterpreterArtifactStore;
103-
if (codeInterpreterTools != null) {
104-
toolCallbackProviders.add(codeInterpreterTools);
105-
logger.info("Code Interpreter enabled");
106-
}
97+
toolCallbackProviders.add(codeInterpreterTools);
98+
logger.info("Code Interpreter enabled");
10799

108100
// MCP Tools
109-
if (mcpTools != null) {
110-
toolCallbackProviders.add(mcpTools);
111-
logger.info("MCP tools enabled");
112-
}
101+
toolCallbackProviders.add(mcpTools);
102+
logger.info("MCP tools enabled");
113103

114104
this.documentModel = documentModel;
115105
this.documentClient = ChatClient.builder(chatModel).build();
@@ -151,9 +141,6 @@ private String getConversationId(AgentCoreContext context) {
151141
}
152142

153143
private Flux<String> appendScreenshots(String sessionId) {
154-
if (browserArtifactStore == null) {
155-
return Flux.empty();
156-
}
157144
List<GeneratedFile> screenshots = browserArtifactStore.retrieve(sessionId);
158145
if (screenshots == null || screenshots.isEmpty()) {
159146
return Flux.empty();
@@ -165,7 +152,7 @@ private String formatScreenshotsAsMarkdown(List<GeneratedFile> screenshots) {
165152
StringBuilder sb = new StringBuilder();
166153
for (GeneratedFile screenshot : screenshots) {
167154
sb.append("\n\n![Screenshot of ")
168-
.append(BrowserArtifacts.url(screenshot))
155+
.append(BrowserArtifacts.url(screenshot).orElse("unknown"))
169156
.append("](")
170157
.append(screenshot.toDataUrl())
171158
.append(")");
@@ -174,9 +161,6 @@ private String formatScreenshotsAsMarkdown(List<GeneratedFile> screenshots) {
174161
}
175162

176163
private Flux<String> appendGeneratedFiles(String sessionId) {
177-
if (codeInterpreterArtifactStore == null) {
178-
return Flux.empty();
179-
}
180164
List<GeneratedFile> files = codeInterpreterArtifactStore.retrieve(sessionId);
181165
if (files == null || files.isEmpty()) {
182166
return Flux.empty();
@@ -227,4 +211,4 @@ private MimeType determineMimeType(String fileName) {
227211
}
228212
return MimeTypeUtils.APPLICATION_OCTET_STREAM;
229213
}
230-
}
214+
}

apps/java-spring-ai-agents/aiagent/src/main/java/com/example/agent/WebGroundingTools.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ public String searchWeb(@ToolParam(description = "Search query") String query) {
5353
}
5454
}
5555

56-
private String extractResponse(ConverseResponse response) {
56+
static String extractResponse(ConverseResponse response) {
5757
var result = new StringBuilder();
5858
var citations = new StringBuilder();
5959

apps/java-spring-ai-agents/aiagent/src/main/resources/application.properties

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@ logging.level.org.springaicommunity.agentcore=DEBUG
55
logging.level.com.example.agent=DEBUG
66
logging.pattern.console=%msg%n
77
# Amazon Bedrock Configuration
8-
spring.ai.bedrock.converse.chat.timeout=120s
8+
spring.ai.bedrock.aws.timeout=120s
99
spring.ai.bedrock.converse.chat.options.max-tokens=4096
10-
spring.ai.bedrock.converse.chat.options.model=global.anthropic.claude-sonnet-4-5-20250929-v1:0
10+
spring.ai.bedrock.converse.chat.options.model=global.anthropic.claude-sonnet-4-6
1111
spring.ai.bedrock.converse.chat.options.temperature=0.7
1212
# AgentCore Browser - tool descriptions
1313
agentcore.browser.browse-url-description=Browse a web page and extract its text content. Returns the page title and body text. Use this to read and extract data from websites. For interactive sites, combine with fillForm and clickElement to navigate, then call browseUrl again to read the results.

apps/java-spring-ai-agents/aiagent/src/test/java/com/example/agent/AgentApplicationTests.java

Lines changed: 0 additions & 13 deletions
This file was deleted.
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package com.example.agent;
2+
3+
import org.junit.jupiter.api.Test;
4+
import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
5+
import org.springframework.boot.test.context.SpringBootTest;
6+
7+
/**
8+
* Full application-context integration test ({@code contextLoads}).
9+
*
10+
* <p>This wires the entire bean graph: Bedrock Converse, AgentCore Memory, Knowledge Base (RAG),
11+
* browser / code-interpreter tool providers, and the MCP client. Those beans only exist when the
12+
* application is fully provisioned — in particular the AgentCore Memory beans are gated by
13+
* {@code @ConditionalOnProperty(prefix = "agentcore.memory", name = "memory-id")}, and
14+
* {@link ChatService} hard-requires the resulting {@code AgentCoreMemory} bean. The deploy script
15+
* {@code 02-memory.sh} writes {@code agentcore.memory.memory-id} (and other resource ids) into
16+
* {@code application.properties}.
17+
*
18+
* <p>Because of that, this test is opt-in and never runs in the normal build:
19+
* <ul>
20+
* <li>Named {@code *IT} so the Maven Failsafe plugin only considers it during {@code mvn verify}.</li>
21+
* <li>{@link EnabledIfSystemProperty} skips it unless {@code -Dit.agentcore=true} is passed.</li>
22+
* </ul>
23+
*
24+
* <p>Run it only from a provisioned workspace (resource ids present in {@code application.properties})
25+
* with valid AWS credentials in the environment:
26+
* <pre>{@code mvn verify -Dit.agentcore=true }</pre>
27+
*/
28+
@EnabledIfSystemProperty(named = "it.agentcore", matches = "true")
29+
@SpringBootTest
30+
class AgentContextIT {
31+
32+
@Test
33+
void contextLoads() {
34+
}
35+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
package com.example.agent;
2+
3+
import org.junit.jupiter.api.Test;
4+
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
5+
import org.springframework.ai.bedrock.converse.BedrockProxyChatModel;
6+
import org.springframework.ai.chat.client.ChatClient;
7+
import software.amazon.awssdk.regions.Region;
8+
import software.amazon.awssdk.regions.providers.DefaultAwsRegionProviderChain;
9+
10+
import static org.assertj.core.api.Assertions.assertThat;
11+
12+
/**
13+
* Integration test that performs a real Amazon Bedrock Converse call through Spring AI's
14+
* {@link BedrockProxyChatModel}.
15+
*
16+
* <p>Double-guarded so it never runs in environments without AWS access:
17+
* <ul>
18+
* <li>Named {@code *IT} so the Maven Failsafe plugin only runs it during {@code mvn verify}
19+
* (the integration-test phase), not during {@code mvn test}.</li>
20+
* <li>{@link EnabledIfEnvironmentVariable} skips it unless AWS credentials are present in the
21+
* environment (e.g. after sourcing the workshop {@code .env}).</li>
22+
* </ul>
23+
* Requires Bedrock model access for the configured model in the resolved region.
24+
*/
25+
@EnabledIfEnvironmentVariable(named = "AWS_ACCESS_KEY_ID", matches = ".+")
26+
class BedrockConverseChatIT {
27+
28+
private static final String MODEL_ID = "global.anthropic.claude-sonnet-4-6";
29+
30+
private ChatClient newChatClient() {
31+
Region region = new DefaultAwsRegionProviderChain().getRegion();
32+
BedrockProxyChatModel model = BedrockProxyChatModel.builder()
33+
.region(region)
34+
.build();
35+
return ChatClient.builder(model).build();
36+
}
37+
38+
@Test
39+
void converse_returnsNonEmptyResponse() {
40+
String answer = newChatClient().prompt()
41+
.options(org.springframework.ai.bedrock.converse.BedrockChatOptions.builder()
42+
.model(MODEL_ID)
43+
.maxTokens(64))
44+
.user("Reply with exactly the word: PONG")
45+
.call()
46+
.content();
47+
48+
assertThat(answer).isNotBlank();
49+
assertThat(answer.toUpperCase()).contains("PONG");
50+
}
51+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package com.example.agent;
2+
3+
import org.junit.jupiter.api.Test;
4+
5+
import static org.assertj.core.api.Assertions.assertThat;
6+
7+
/**
8+
* Unit tests for the {@link ChatRequest} record's {@code hasFile()} logic. No credentials required.
9+
*/
10+
class ChatRequestTest {
11+
12+
@Test
13+
void hasFile_trueWhenBothFieldsPresent() {
14+
assertThat(new ChatRequest("prompt", "ZmlsZQ==", "doc.pdf").hasFile()).isTrue();
15+
}
16+
17+
@Test
18+
void hasFile_falseWhenBase64Missing() {
19+
assertThat(new ChatRequest("prompt", null, "doc.pdf").hasFile()).isFalse();
20+
assertThat(new ChatRequest("prompt", "", "doc.pdf").hasFile()).isFalse();
21+
}
22+
23+
@Test
24+
void hasFile_falseWhenFileNameMissing() {
25+
assertThat(new ChatRequest("prompt", "ZmlsZQ==", null).hasFile()).isFalse();
26+
assertThat(new ChatRequest("prompt", "ZmlsZQ==", "").hasFile()).isFalse();
27+
}
28+
29+
@Test
30+
void hasFile_falseWhenPromptOnly() {
31+
assertThat(new ChatRequest("just a prompt", null, null).hasFile()).isFalse();
32+
}
33+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
package com.example.agent;
2+
3+
import org.junit.jupiter.api.Test;
4+
import org.springframework.ai.chat.client.ChatClientRequest;
5+
import org.springframework.ai.chat.memory.ChatMemory;
6+
import org.springframework.ai.chat.messages.Message;
7+
import org.springframework.ai.chat.messages.SystemMessage;
8+
import org.springframework.ai.chat.messages.UserMessage;
9+
import org.springframework.ai.chat.prompt.Prompt;
10+
11+
import java.util.List;
12+
import java.util.Map;
13+
14+
import static org.assertj.core.api.Assertions.assertThat;
15+
16+
/**
17+
* Pure unit tests for {@link ContextAdvisor}. No Spring context, no AWS credentials required.
18+
* Verifies the prompt is augmented with the current timestamp and the resolved user id.
19+
*/
20+
class ContextAdvisorTest {
21+
22+
private final ContextAdvisor advisor = new ContextAdvisor();
23+
24+
@Test
25+
void before_withConversationId_injectsTimestampAndUserId() {
26+
Prompt prompt = new Prompt(List.of(
27+
new SystemMessage("system prompt"),
28+
new UserMessage("hello world")));
29+
ChatClientRequest request = new ChatClientRequest(
30+
prompt, Map.of(ChatMemory.CONVERSATION_ID, "user-123:session-abc"));
31+
32+
ChatClientRequest result = advisor.before(request, null);
33+
34+
List<Message> messages = result.prompt().getInstructions();
35+
List<String> texts = messages.stream().map(Message::getText).toList();
36+
37+
// Original system message is preserved.
38+
assertThat(texts).anyMatch(t -> t.equals("system prompt"));
39+
// Timestamp message is added.
40+
assertThat(texts).anyMatch(t -> t.startsWith("[Current date and time:"));
41+
// User id is extracted from the conversation id (before the colon) and the original text retained.
42+
assertThat(texts).anyMatch(t -> t.startsWith("[UserId: user-123]") && t.contains("hello world"));
43+
// The bare original user message was replaced (not left as plain "hello world").
44+
assertThat(texts).doesNotContain("hello world");
45+
}
46+
47+
@Test
48+
void before_withoutConversationId_usesUnknownUserId() {
49+
Prompt prompt = new Prompt(List.of(new UserMessage("hi")));
50+
ChatClientRequest request = new ChatClientRequest(prompt, Map.of());
51+
52+
ChatClientRequest result = advisor.before(request, null);
53+
54+
List<String> texts = result.prompt().getInstructions().stream().map(Message::getText).toList();
55+
assertThat(texts).anyMatch(t -> t.startsWith("[UserId: unknown]") && t.contains("hi"));
56+
}
57+
58+
@Test
59+
void after_returnsResponseUnchanged() {
60+
// after() is a pass-through; passing null chain is fine since it is unused.
61+
assertThat(advisor.getOrder()).isZero();
62+
}
63+
}

0 commit comments

Comments
 (0)