Tests pin down chain ordering (including ties), BaseAdvisor stream behaviour, redaction order versus memory and logging, the tool loop, and how a refusal surfaces on calls, streams and over HTTP. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Ja4jkzrbQ4LQZBNrb5mkZE
advisors
Companion code for Writing Custom Advisors in Spring AI 2.0: Logging, PII Redaction and Token Budgets, part of the Spring AI series on ankurm.com.
Three advisors of our own -- a logger, a PII redactor and a token budget -- plus the tests that pin down how the advisor chain orders them, what happens on a stream, and what a caller sees when one of them refuses a request.
There is no live model anywhere. RecordingModel records every prompt it is sent and answers with a fixed sentence, which is exactly what an advisor test needs: what reached the model, and what came back. No database, no Docker, no API key.
Versions
| Component | Version |
|---|---|
| Spring Boot | 4.1.1 |
| Spring AI | 2.0.1 (spring-ai-client-chat 2.0.1) |
| Reactor Core | 3.8.7 |
| JTokkit (token estimates) | 1.1.0 |
| Java | 25 (LTS) |
Quickstart
scripts/run-all.sh # runs all 21 tests and regenerates output/01 .. 21
Two consecutive runs produce byte-identical files. To run the app itself: OPENAI_API_KEY=... mvn spring-boot:run, then POST /chat with an X-User header and {"text": "..."}.
What's here
| File | What it shows |
|---|---|
advisor/Orders.java |
The order numbers, and the layers they sit between (memory +200, tool loop +300, model last) |
advisor/LoggingAdvisor.java |
Request / response / stream / failure lines; content off by default |
advisor/PiiRedactor.java |
Email, Luhn-checked card number and phone patterns; <EMAIL_1> placeholders |
advisor/PiiRedactionAdvisor.java |
Redact on the way in, restore on the way out, on calls and on streams (buffers a split placeholder) |
advisor/TokenBudgetAdvisor.java |
Per-request and per-user limits, checked before the model is called |
advisor/Texts.java |
Rewriting request and response text with the mutate() copies |
config/AdvisorConfig.java |
The advisors as beans and one ChatClient that uses them with Spring AI's memory advisor |
web/ChatController.java |
POST /chat; the user comes from a header only so the tests need no login |
Output files
Every file is written by the test named in the right column, and every console block in the article is quoted from one of them.
| File | Written by |
|---|---|
01-chain-order.txt, 02-chain-order-ties.txt, 03-chain-contents.txt |
ChainOrderTest |
04-base-advisor-stream.txt |
BaseAdvisorStreamTest |
05-logging-advisor.txt, 06-logging-failure.txt |
LoggingAdvisorTest |
07-pii-redaction.txt, 08-pii-limits.txt, 09-pii-stream-boundary.txt |
PiiRedactionTest |
10-pii-order.txt |
PiiOrderTest |
11-pii-multi-turn.txt |
PiiMultiTurnTest |
12-token-budget.txt, 13-token-budget-stream.txt, 14-token-budget-stream-refusal.txt |
TokenBudgetTest |
15-tool-loop-order.txt |
ToolLoopOrderTest |
16-error-propagation.txt |
ErrorPropagationTest |
17-context.txt |
ContextAndImmutabilityTest |
18-unit-test-no-model.txt |
UnitTestingWithoutModelTest |
19-web-429.txt |
AdvisorWebTest |
20-built-in-orders.txt |
BuiltInOrdersTest |
21-tool-loop-budget.txt |
ToolLoopBudgetTest |
TokenBudgetTest.aStreamIsRefusedAsAnErrorSignal makes Spring AI's MessageAggregator log an ERROR ... Aggregation Error stack trace on the console. That is the refusal being reported, not a test failure.
Requirements
JDK 25 and Maven.