Add chat-memory module: MessageWindowChatMemory, JDBC and Redis repositories, per-user conversation IDs

Real PostgreSQL 16 and Redis Stack 7.4; 24 tests write output/01-24. Covers the 20-message
default window with no property, the 36-character conversation_id, tool messages dropped by
JdbcChatMemoryRepository, concurrent add() on one conversation, a 1.x table under the 2.0
repository, the Redis repository silently backing off for a custom ChatMemory, and the
removal of PromptChatMemoryAdvisor.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Ja4jkzrbQ4LQZBNrb5mkZE
This commit is contained in:
Claude
2026-09-24 15:56:47 +00:00
parent c1e36c6c09
commit 823f6fac5b
65 changed files with 2548 additions and 0 deletions
+1
View File
@@ -12,5 +12,6 @@ Runnable companion code for the Spring AI articles on [ankurm.com](https://ankur
| [`tool-calling/`](tool-calling) | `@Tool` methods, `ToolCallingAdvisor` (the advisor-layer replacement for Spring AI 1.x's per-model tool loop), `returnDirect`, `ToolContext`, and `ToolSearchToolCallingAdvisor` for progressive disclosure across a 230-tool synthetic library -- every test driven by a hand-written `ScriptedChatModel`, no live model anywhere. Spring Boot 4.1.1, Spring AI 2.0.1, Java 25. | [Tool Calling in Spring AI 2.0](https://ankurm.com/spring-ai-2-0-tool-calling/) |
| [`structured-output/`](structured-output) | `ChatClient.entity()` mapping LLM responses to Java records, lists and maps; `StructuredOutputValidationAdvisor` retrying non-conforming JSON with a real enum-constrained schema, including a captured run that exhausts every retry without throwing. Spring Boot 4.1.1, Spring AI 2.0.1, Java 25. | [Structured Output in Spring AI 2.0](https://ankurm.com/spring-ai-2-0-structured-output/) |
| [`ollama-local/`](ollama-local) | Chat and embeddings against a real local `qwen2.5:0.5b`/`all-minilm`, no API key, driven by a Testcontainers-managed Ollama container started from a baked image; a confirmed model unload via `keep_alive: 0` and `/api/ps`, not a scripted model anywhere. Spring Boot 4.1.1, Spring AI 2.0.1, Testcontainers 2.0.5, Java 25. | [Run LLMs Locally with Spring AI and Ollama](https://ankurm.com/spring-ai-2-0-ollama-local/) |
| [`chat-memory/`](chat-memory) | `MessageChatMemoryAdvisor`, `MessageWindowChatMemory`, the JDBC and Redis `ChatMemoryRepository`, per-user conversation IDs and a token-budget memory of our own, with the traps reproduced against a real PostgreSQL 16 and Redis Stack: a 36-character `conversation_id`, tool messages dropped on save, concurrent writers, a 1.x table under the 2.0 repository, and a Redis repository that silently steps aside for a custom `ChatMemory`. Spring Boot 4.1.1, Spring AI 2.0.1, Java 25. | [Chat Memory in Spring AI 2.0: JDBC, Redis and Windowed Conversations](https://ankurm.com/spring-ai-2-0-chat-memory-jdbc-redis-windowed-conversations/) |
Upgrading from Spring AI 1.x: [migration guide](https://ankurm.com/spring-ai-1-to-2-migration-guide/).
+1
View File
@@ -0,0 +1 @@
target/
+67
View File
@@ -0,0 +1,67 @@
# chat-memory
Companion code for [Chat Memory in Spring AI 2.0: JDBC, Redis and Windowed Conversations](https://ankurm.com/spring-ai-2-0-chat-memory-jdbc-redis-windowed-conversations/), part of the [Spring AI series](../README.md) on ankurm.com.
`MessageChatMemoryAdvisor` + `MessageWindowChatMemory` + a `ChatMemoryRepository` (in-memory, PostgreSQL through JDBC, or Redis Stack), with per-user conversation IDs, a token-budget memory of our own, and every trap the article describes reproduced by a test that writes its own transcript.
There is no live model anywhere: [`ScriptedChatModel`](src/test/java/com/ankurm/chatmemory/support/ScriptedChatModel.java) records the prompts it is sent, which is what a memory test needs to prove -- *which messages reached the model*. The databases are real.
## Versions
| Component | Version |
|---|---|
| Spring Boot | 4.1.1 |
| Spring AI | 2.0.1 (GA 2026-06-12 for 2.0.0; 2.0.1 on Maven Central 2026-08-20) |
| PostgreSQL | 16.13 |
| Redis Stack | 7.4.7 (RediSearch 2.10.20, RedisJSON) |
| Jedis (pulled in by the Redis starter) | 7.4.1 |
| Java | 25 (LTS) |
## Quickstart
```bash
scripts/services-up.sh # PostgreSQL 16 (apt), Redis Stack (docker), plain Redis (apt) -- optional
scripts/run-all.sh # runs all 24 tests and regenerates output/01 .. 24
```
Tests that need a service which is not listening are **skipped with a message**, not failed, so the in-memory tests still run with nothing installed. Run the app itself with `mvn spring-boot:run` (in-memory), `-Dspring-boot.run.profiles=jdbc` or `=redis`; it needs `OPENAI_API_KEY` for real answers.
## What's here
| File | What it shows |
|---|---|
| [`config/MemoryConfig.java`](src/main/java/com/ankurm/chatmemory/config/MemoryConfig.java) | The `ChatMemory` bean (window size is not configurable by property) and the `ChatClient` with `MessageChatMemoryAdvisor` |
| [`config/RedisMemoryConfig.java`](src/main/java/com/ankurm/chatmemory/config/RedisMemoryConfig.java) | Why the Redis repository is built by hand: the autoconfigured one silently steps aside for a custom `ChatMemory` |
| [`memory/ConversationRegistry.java`](src/main/java/com/ankurm/chatmemory/memory/ConversationRegistry.java) | Who owns which conversation; `app.ownership.enforce=false` reproduces the leak |
| [`memory/ConversationService.java`](src/main/java/com/ankurm/chatmemory/memory/ConversationService.java) | Passing `ChatMemory.CONVERSATION_ID` per request |
| [`memory/TokenBudgetChatMemory.java`](src/main/java/com/ankurm/chatmemory/memory/TokenBudgetChatMemory.java) | A `ChatMemory` that prunes by tokens instead of by message count |
| [`web/ChatController.java`](src/main/java/com/ankurm/chatmemory/web/ChatController.java) | Four endpoints; the user comes from an `X-User` header only so the tests need no login |
| [`application*.yml`](src/main/resources) | Profiles `jdbc` and `redis`; the default excludes both repository autoconfigurations |
| [`src/broken/PromptAdvisorFrom1x.java`](src/broken/PromptAdvisorFrom1x.java) | Not compiled by the build; `scripts/capture-1x-compile.sh` compiles it against 2.0.1 and 1.1.8 |
## Output files
Every file is written by the test named in the right column (or by the script), and every console block in the article is quoted from one of them.
| File | Written by |
|---|---|
| `01-window-semantics.txt` | `WindowSemanticsTest` |
| `02-advisor-conversations.txt`, `03-missing-conversation-id.txt` | `AdvisorConversationTest` |
| `04-autoconfigured-defaults.txt`, `05-no-window-property.txt` | `DefaultWindowTest` |
| `06-token-growth.txt` | `TokenGrowthTest` |
| `07-token-budget-memory.txt` | `TokenBudgetChatMemoryTest` |
| `08` .. `14` (JDBC round trip, 36-character limit, dropped tool messages, lost update, overlapping saves, 1.x table upgrade, locked fix) | `JdbcPostgresTest` |
| `15-redis-round-trip.txt`, `16-redis-lost-update.txt` | `RedisStackTest` |
| `17-redis-silent-fallback.txt` | `RedisSilentFallbackTest` |
| `18-redis-ttl-and-cap.txt` | `RedisCapsAndTtlTest` |
| `19-redis-plain-fails.txt` | `RedisPlainFailureTest` |
| `20-ownership-enforced.txt`, `21-ownership-unenforced.txt` | `OwnershipTest`, `OwnershipLeakTest` |
| `22-config-keys.txt` | `ConfigKeysTest` |
| `23-prompt-advisor-removed.txt` | `scripts/capture-1x-compile.sh` |
| `24-memory-with-tool-calling.txt` | `ToolCallingMemoryTest` |
Two consecutive `mvn test` runs produce byte-identical files. `12-jdbc-overlapping-saves.txt` deliberately prints no exact count: two `saveAll` calls that overlap produce 3 or 6 messages depending on timing, and the file says so instead of pinning one run.
## Requirements
JDK 25, Maven. PostgreSQL 16 and Redis Stack are optional (see above); Docker is only used to run Redis Stack.
@@ -0,0 +1,16 @@
# MessageWindowChatMemory, maxMessages = 4 and 5
maxMessages = 4. One system message, then one user+assistant turn at a time:
after turn 1: S:SYS-1 | U:u1 | A:a1
after turn 2: S:SYS-1 | U:u2 | A:a2
after turn 3: S:SYS-1 | U:u3 | A:a3
after turn 4: S:SYS-1 | U:u4 | A:a4
A second, different system message arrives:
after SYS-2: U:u4 | A:a4 | S:SYS-2
maxMessages = 5 (odd), no system message:
after turn 1: U:u1 | A:a1
after turn 2: U:u1 | A:a1 | U:u2 | A:a2
after turn 3: U:u2 | A:a2 | U:u3 | A:a3
after turn 4: U:u3 | A:a3 | U:u4 | A:a4
@@ -0,0 +1,19 @@
# MessageChatMemoryAdvisor: what the model is sent
conv-A, call 1: "My name is Priya"
reply: Nice to meet you, Priya.
model was sent: S:You are a terse assistant. | U:My name is Priya
conv-A, call 2: "What is my name?"
reply: Your name is Priya.
model was sent: S:You are a terse assistant. | U:My name is Priya | A:Nice to meet you, Priya. | U:What is my name?
conv-B, call 1: "What is my name?" (different conversation ID)
reply: I do not know your name yet.
model was sent: S:You are a terse assistant. | U:What is my name?
Stored for conv-A afterwards:
USER My name is Priya
ASSISTANT Nice to meet you, Priya.
USER What is my name?
ASSISTANT Your name is Priya.
@@ -0,0 +1,5 @@
# No conversation ID on the request
chat.prompt().user("hello").call().content() // no advisors(...) param
threw java.lang.IllegalArgumentException
message: conversationId cannot be null
@@ -0,0 +1,5 @@
# ChatMemoryAutoConfiguration, nothing configured
ChatMemory bean: MessageWindowChatMemory
ChatMemoryRepository bean: InMemoryChatMemoryRepository
15 turns (30 messages) added -> 20 stored, oldest kept: u6
@@ -0,0 +1,7 @@
# Guessing a window-size property
spring.ai.chat.memory.max-messages = 6 -> 30 messages added, 20 stored
spring.ai.chat.memory.window-size = 6 -> 30 messages added, 20 stored
spring.ai.chat.memory.repository.max-messages = 6 -> 30 messages added, 20 stored
Spring Boot ignores unknown keys silently, so none of these did anything.
+18
View File
@@ -0,0 +1,18 @@
# Input tokens per call, 30 turns, four pruning strategies
Encoding: o200k_base (JTokkit estimate). Each turn: ~25-token question, ~40-token answer, ~6-token system prompt.
turn unbounded window=10 window=20 budget=400tok
1 26 26 26 26
2 79 79 79 79
5 238 238 238 238
10 503 291 503 397
11 556 291 556 397
15 768 291 556 397
20 1033 291 556 397
21 1086 291 556 397
25 1298 291 556 397
30 1563 291 556 397
sum 23835 7935 13765 10426
sum as % of unbounded: window=10 33%, window=20 58%, budget=400tok 44%
@@ -0,0 +1,4 @@
# TokenBudgetChatMemory, budget = 120 tokens
three short turns: S:Be terse. | U:short question 1 | A:short answer 1 | U:short question 2 | A:short answer 2 | U:short question 3 | A:short answer 3
then one pasted trace: 7 messages kept: S:Be terse. | U:short question 2 | A:short answer 2 | U:short question 3 | A:short answer 3 | U:<pasted stack trace, 89 tokens> | A:Check the datasource URL.
+16
View File
@@ -0,0 +1,16 @@
# JdbcChatMemoryRepository on PostgreSQL 16
Table created by initialize-schema=always:
conversation_id character varying (36)
content text
type character varying (10)
timestamp timestamp without time zone
sequence_id bigint
After one call, rows for the conversation:
seq=0 USER My name is Priya
seq=1 ASSISTANT Nice to meet you, Priya.
New repository + new memory over the same database, then "What is my name?":
reply: Your name is Priya.
findConversationIds() returns every conversation in the table: [6f0a1c9e-2b7d-4c55-9a53-0d1f3e7a2b10]
@@ -0,0 +1,7 @@
# conversation_id is VARCHAR(36)
conversation ID: "user:[email protected]:thread:2026-09-24" (43 characters)
model calls made for this request: 0 (the user message is saved before the model is called)
thrown: org.springframework.dao.DataIntegrityViolationException
root cause: ERROR: value too long for type character varying(36)
rows stored for it afterwards: 0
@@ -0,0 +1,13 @@
# What JdbcChatMemoryRepository.saveAll keeps
saveAll() given 4 messages:
USER Weather in Mumbai?
ASSISTANT (tool call weather)
TOOL (tool call weather)
ASSISTANT 31C and humid.
findByConversationId() returns 2:
USER Weather in Mumbai?
ASSISTANT 31C and humid.
Logged by the repository: WARN JdbcChatMemoryRepository does not support tool call messages. Some messages were filtered out for conversation: tool-conv
@@ -0,0 +1,8 @@
# Two concurrent add() calls on one conversation
seeded with: u0, a0
two writers each add one message; both read the conversation before either writes it back
first writer to save: adds a 3-message list, saved
second writer to save: adds a different 3-message list, replaces the first
stored afterwards: [u0, a0, <second writer's message>] (3 messages, 4 were sent)
the first writer's message is GONE, the second writer's is kept
@@ -0,0 +1,7 @@
# Two saveAll() calls released at the same instant
seeded with u0, a0; two writers add from-A and from-B; both saveAll() calls start together
The outcome differs from run to run, so the exact count is not printed here. Across repeated runs on
PostgreSQL 16 (READ COMMITTED) it is one of two things, and never the 4 messages that were sent:
3 messages -> one writer's message is gone
6 messages -> both transactions inserted, history is duplicated, sequence_id values collide
@@ -0,0 +1,16 @@
# A Spring AI 1.x table under the 2.0 repository
A 1.1.x-shaped table (no sequence_id column) holds 2 rows for "old-conv".
2.0.1 repository, findByConversationId("old-conv"):
ERROR: column "sequence_id" does not exist
Migration: add the column, backfill it from the timestamp order, then make it NOT NULL:
ALTER TABLE SPRING_AI_CHAT_MEMORY ADD COLUMN sequence_id BIGINT
UPDATE SPRING_AI_CHAT_MEMORY m SET sequence_id = s.rn FROM (SELECT ctid AS c, row_number() OVER (PARTITION ...
ALTER TABLE SPRING_AI_CHAT_MEMORY ALTER COLUMN sequence_id SET NOT NULL
CREATE INDEX IF NOT EXISTS SPRING_AI_CHAT_MEMORY_CONVERSATION_ID_SEQUENCE_ID_IDX ON SPRING_AI_CHAT_MEMORY(c...
findByConversationId("old-conv") after the migration:
USER My name is Priya
ASSISTANT Nice to meet you, Priya.
@@ -0,0 +1,4 @@
# Same two writers, per-conversation lock
stored afterwards: 4 messages
contains from-A: true, from-B: true
@@ -0,0 +1,16 @@
# RedisChatMemoryRepository on Redis Stack
ChatMemoryRepository bean: RedisChatMemoryRepository
call 1 reply: Nice to meet you, Priya.
Keys under the default prefix "chat-memory:" (3: one JSON document per message, plus a counter):
chat-memory:6f0a1c9e-2b7d-4c55-9a53-0d1f3e7a2b10:<n>
chat-memory:6f0a1c9e-2b7d-4c55-9a53-0d1f3e7a2b10:<n>
chat-memory:counter:6f0a1c9e-2b7d-4c55-9a53-0d1f3e7a2b10
Fields of one document: [content, conversation_id, metadata, timestamp, type]
TTL on each key (spring.ai.chat.memory.repository.redis.time-to-live=24h): between 86390 and 86400 s -> true
Same conversation, second call "What is my name?":
reply: Your name is Priya.
stored: U:My name is Priya | A:Nice to meet you, Priya. | U:What is my name? | A:Your name is Priya.
@@ -0,0 +1,4 @@
# Two concurrent add() calls, Redis repository
seeded with u0, a0; two writers add one message each, both read before either writes back
stored afterwards: [u0, a0, <second writer's message>] (3 messages, 4 were sent)
@@ -0,0 +1,11 @@
# Redis starter + your own ChatMemory bean
application has: spring-ai-starter-model-chat-memory-repository-redis, profile "redis", Redis Stack reachable,
and its own @Bean ChatMemory (needed to set a window other than 20)
ChatMemoryRepository bean in the context: InMemoryChatMemoryRepository
Redis keys under "chat-memory:" before / after one chat call: 0 / 0
Condition report for the autoconfigured Redis repository bean:
@ConditionalOnMissingBean (types: org.springframework.ai.chat.memory.repository.redis.RedisChatMemoryRepository,org.springframework.ai.chat.memory.ChatMemory,org.springframework.ai.chat.memory.ChatMemoryRepository; SearchStrategy: all) found beans of type 'org.springframework.ai.chat.memory.ChatMemory' chatMemory
WARN/ERROR log lines mentioning Redis or ChatMemory, captured over the whole test class: 0
@@ -0,0 +1,8 @@
# Redis time-to-live = 2s, max-messages-per-conversation = 4
saved 2 messages; readable now: U:u1 | A:a1
3 seconds later: (gone: Redis expired the keys)
saved 6 messages with max-messages-per-conversation=4, read back 4:
U:u1 | A:a1 | U:u2 | A:a2
keys in Redis for it: 7
@@ -0,0 +1,6 @@
# RedisChatMemoryRepository against plain Redis 7.0 (no modules)
server: redis_version:7.0.15
RedisChatMemoryRepository.builder()...initializeSchema(true).build() threw:
java.lang.IllegalStateException
root cause: JedisDataException: ERR unknown command 'FT._LIST', with args beginning with:
@@ -0,0 +1,7 @@
# app.ownership.enforce=true (default)
alice starts a conversation and says "My name is Priya"
bob: GET /conversations/<alice's id>/messages -> 403
bob: POST /conversations/<alice's id>/messages -> 403
alice: GET /conversations/<her id>/messages -> 200, 2 messages
@@ -0,0 +1,7 @@
# app.ownership.enforce=false
alice starts a conversation and says "My name is Priya"
bob: GET /conversations/<alice's id>/messages -> 200
body: [{"role":"USER","text":"My name is Priya"},{"role":"ASSISTANT","text":"Nice to meet you, Priya."}]
bob: POST "What is my name?" -> 200 {"reply":"Your name is Priya."}
+11
View File
@@ -0,0 +1,11 @@
# Do the chat-memory property keys bind?
spring.ai.chat.memory.repository.jdbc.initialize-schema=always -> initializeSchema = ALWAYS
redis.host / port / time-to-live / max-messages-per-conversation = cache.internal / 6390 / PT24H / 200
Three guesses, each of which looks right and binds nothing:
spring.ai.chat.memory.redis.port=6390 (no "repository" segment)
spring.ai.chat.memory.repository.redis.ttl=24h (the key is time-to-live)
spring.data.redis.port=6390 (Spring Data Redis's key; this repository builds its own Jedis client)
-> port = 6379, time-to-live = null
@@ -0,0 +1,21 @@
# PromptChatMemoryAdvisor: Spring AI 1.1.8 vs 2.0.1
$ javac PromptAdvisorFrom1x.java # classpath: spring-ai-client-chat 2.0.1
PromptAdvisorFrom1x.java:3: error: cannot find symbol
import org.springframework.ai.chat.client.advisor.PromptChatMemoryAdvisor;
^
symbol: class PromptChatMemoryAdvisor
location: package org.springframework.ai.chat.client.advisor
PromptAdvisorFrom1x.java:10: error: cannot find symbol
return PromptChatMemoryAdvisor.builder(chatMemory).build();
^
symbol: variable PromptChatMemoryAdvisor
location: class PromptAdvisorFrom1x
2 errors
$ javac PromptAdvisorFrom1x.java # classpath: spring-ai-client-chat 1.1.8 first
PromptAdvisorFrom1x.java:10: warning: [removal] PromptChatMemoryAdvisor in org.springframework.ai.chat.client.advisor has been deprecated and marked for removal
return PromptChatMemoryAdvisor.builder(chatMemory).build();
^
1 warning
compiled: PromptAdvisorFrom1x.class
@@ -0,0 +1,15 @@
# MessageChatMemoryAdvisor + ToolCallingAdvisor
Advisor.DEFAULT_CHAT_MEMORY_PRECEDENCE_ORDER = HIGHEST_PRECEDENCE + 200
ToolCallingAdvisor.DEFAULT_ORDER = HIGHEST_PRECEDENCE + 300
answer: It is 31C in Mumbai.
model calls: 2
What the model was sent on each call:
call 1: U:Weather in Mumbai?
call 2: U:Weather in Mumbai? | A:[tool call currentWeather] | T:[tool result "31C in Mumbai"]
Stored in memory afterwards:
USER Weather in Mumbai?
ASSISTANT It is 31C in Mumbai.
+82
View File
@@ -0,0 +1,82 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
<relativePath/>
</parent>
<groupId>com.ankurm</groupId>
<artifactId>chat-memory</artifactId>
<version>1.0.0</version>
<name>chat-memory</name>
<description>Chat memory in Spring AI 2.0: MessageWindowChatMemory, JDBC and Redis ChatMemoryRepository, per-user conversation IDs, pruning.</description>
<properties>
<java.version>25</java.version>
<spring-ai.version>2.0.1</spring-ai.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>${spring-ai.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-openai</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-chat-memory-repository-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-chat-memory-repository-redis</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<argLine>-Duser.timezone=UTC -Dstdout.encoding=UTF-8 -Dfile.encoding=UTF-8</argLine>
</configuration>
</plugin>
</plugins>
</build>
</project>
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env bash
# Compiles src/broken/PromptAdvisorFrom1x.java against the 2.0.1 classpath, then against the
# 1.1.8 spring-ai-client-chat + spring-ai-model jars, and writes both compiler results to
# output/23-prompt-advisor-removed.txt. Needs the module built once online (mvn -q test-compile).
set -euo pipefail
cd "$(dirname "$0")/.."
OUT=output/23-prompt-advisor-removed.txt
mvn -q -B dependency:build-classpath -Dmdep.outputFile=target/classpath.txt >/dev/null 2>&1
CP2="$(cat target/classpath.txt)"
WORK="$(mktemp -d)"
for a in spring-ai-client-chat spring-ai-model; do
# Maven Central occasionally answers 429/5xx to a burst of requests: retry with a pause.
for attempt in 1 2 3 4 5; do
curl -sfL -o "$WORK/$a-1.1.8.jar" "https://repo1.maven.org/maven2/org/springframework/ai/$a/1.1.8/$a-1.1.8.jar" && break
sleep $((attempt * 3))
done
done
CP1="$WORK/spring-ai-client-chat-1.1.8.jar:$WORK/spring-ai-model-1.1.8.jar:$CP2"
# put the 1.1.8 jars FIRST so their classes win over the 2.0.1 ones on the classpath
{
echo "# PromptChatMemoryAdvisor: Spring AI 1.1.8 vs 2.0.1"
echo
echo "\$ javac PromptAdvisorFrom1x.java # classpath: spring-ai-client-chat 2.0.1"
javac -proc:none -d "$WORK/out2" -cp "$CP2" src/broken/PromptAdvisorFrom1x.java 2>&1 | grep -v JAVA_TOOL | sed "s#src/broken/##" || true
echo
echo "\$ javac PromptAdvisorFrom1x.java # classpath: spring-ai-client-chat 1.1.8 first"
mkdir -p "$WORK/out1"
if javac -proc:none -d "$WORK/out1" -cp "$CP1" src/broken/PromptAdvisorFrom1x.java 2>&1 | grep -v JAVA_TOOL | sed "s#src/broken/##"; then :; fi
ls "$WORK/out1" | sed 's/^/compiled: /'
} > "$OUT"
cat "$OUT"
+13
View File
@@ -0,0 +1,13 @@
#!/usr/bin/env bash
# Regenerates every file under output/. The test suite writes 01-22 and 24 itself through the
# Transcript helper; 23 comes from scripts/capture-1x-compile.sh.
#
# Backing services: scripts/services-up.sh starts PostgreSQL 16, Redis Stack (docker) and a plain
# Redis. Tests that need one that is not listening are SKIPPED with a message, not failed, so the
# in-memory tests (01-07, 20-22, 24) still run without any of them.
set -euo pipefail
cd "$(dirname "$0")/.."
rm -rf target
mvn -q -B test
scripts/capture-1x-compile.sh >/dev/null
ls output
+23
View File
@@ -0,0 +1,23 @@
#!/usr/bin/env bash
# Starts the three backing services the tests need, with no docker-compose:
# * PostgreSQL 16 on 127.0.0.1:5440 (apt: postgresql-16; runs as the postgres user)
# * Redis Stack on 127.0.0.1:6390 (docker: redis/redis-stack-server -- Redis + RediSearch + RedisJSON)
# * plain Redis on 127.0.0.1:6391 (apt: redis-server, NO modules; only used to capture a failure)
# If you already have these, skip this script and set CM_PG_URL / CM_REDIS_STACK_PORT / CM_REDIS_PLAIN_PORT.
set -euo pipefail
PGDATA="${PGDATA:-/tmp/pgchat/data}"; PGPORT="${PGPORT:-5440}"; SOCKDIR="$(dirname "$PGDATA")"
BIN="$(ls -d /usr/lib/postgresql/*/bin 2>/dev/null | sort -V | tail -1)"
as_pg() { if [ "$(id -u)" = 0 ]; then su postgres -c "$*"; else bash -c "$*"; fi; }
mkdir -p "$SOCKDIR"; [ "$(id -u)" = 0 ] && chown postgres "$SOCKDIR"
[ -d "$PGDATA/base" ] || as_pg "'$BIN/initdb' -D '$PGDATA' -A trust >'$SOCKDIR/initdb.log' 2>&1"
as_pg "'$BIN/pg_ctl' -D '$PGDATA' status" >/dev/null 2>&1 || \
as_pg "'$BIN/pg_ctl' -D '$PGDATA' -o \"-p $PGPORT -c listen_addresses=127.0.0.1 -c unix_socket_directories=$SOCKDIR\" -l '$SOCKDIR/pg.log' -w start"
PSQL="$BIN/psql -h $SOCKDIR -p $PGPORT -U postgres -v ON_ERROR_STOP=1 -q"
as_pg "$PSQL -d postgres -tc \"select 1 from pg_roles where rolname='chat'\"" | grep -q 1 || as_pg "$PSQL -d postgres -c \"create role chat login superuser password 'chat'\""
as_pg "$PSQL -d postgres -tc \"select 1 from pg_database where datname='chatdb'\"" | grep -q 1 || as_pg "$PSQL -d postgres -c 'create database chatdb owner chat'"
docker rm -f cm-redis-stack >/dev/null 2>&1 || true
docker run -d --name cm-redis-stack -p 6390:6379 redis/redis-stack-server:latest >/dev/null
redis-server --port 6391 --save "" --appendonly no --daemonize yes >/dev/null
echo "postgres 127.0.0.1:$PGPORT (chatdb / chat / chat), redis-stack :6390, plain redis :6391"
@@ -0,0 +1,12 @@
// Deliberately NOT part of the build (src/broken is not a source root). Compiled by
// scripts/capture-1x-compile.sh to capture the compiler's own message.
import org.springframework.ai.chat.client.advisor.PromptChatMemoryAdvisor;
import org.springframework.ai.chat.memory.ChatMemory;
class PromptAdvisorFrom1x {
Object build(ChatMemory chatMemory) {
// Spring AI 1.x: memory rendered into the system prompt as text
return PromptChatMemoryAdvisor.builder(chatMemory).build();
}
}
@@ -0,0 +1,12 @@
package com.ankurm.chatmemory;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class ChatMemoryApplication {
public static void main(String[] args) {
SpringApplication.run(ChatMemoryApplication.class, args);
}
}
@@ -0,0 +1,44 @@
package com.ankurm.chatmemory.config;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.chat.memory.ChatMemoryRepository;
import org.springframework.ai.chat.memory.MessageWindowChatMemory;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Two beans, and both are choices the autoconfiguration would otherwise make for you:
*
* <ul>
* <li>{@link ChatMemory}: Spring AI 2.0.1 autoconfigures a {@code MessageWindowChatMemory}
* with a hard-coded window of 20 messages and no property to change it, so a window of
* any other size means declaring this bean yourself. It sits on whichever
* {@link ChatMemoryRepository} bean exists (in-memory, JDBC or Redis).</li>
* <li>{@link ChatClient}: one advisor, {@link MessageChatMemoryAdvisor}, that loads the
* conversation before every call and appends the new turn after it.</li>
* </ul>
*/
@Configuration
public class MemoryConfig {
@Bean
ChatMemory chatMemory(ChatMemoryRepository repository,
@Value("${app.memory.max-messages:20}") int maxMessages) {
return MessageWindowChatMemory.builder()
.chatMemoryRepository(repository)
.maxMessages(maxMessages)
.build();
}
@Bean
ChatClient chatClient(ChatModel chatModel, ChatMemory chatMemory) {
return ChatClient.builder(chatModel)
.defaultSystem("You are a terse assistant.")
.defaultAdvisors(MessageChatMemoryAdvisor.builder(chatMemory).build())
.build();
}
}
@@ -0,0 +1,55 @@
package com.ankurm.chatmemory.config;
import java.time.Duration;
import org.springframework.ai.chat.memory.repository.redis.RedisChatMemoryRepository;
import org.springframework.ai.model.chat.memory.repository.redis.autoconfigure.RedisChatMemoryRepositoryProperties;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import redis.clients.jedis.RedisClient;
/**
* Builds the {@link RedisChatMemoryRepository} by hand from the autoconfigured properties and
* client. Why this class exists at all is the second-most surprising thing in the article: in
* Spring AI 2.0.1 the autoconfigured Redis repository bean is
* {@code @ConditionalOnMissingBean(RedisChatMemoryRepository, ChatMemory, ChatMemoryRepository)}.
* The moment the application declares its own {@code ChatMemory} bean (which it must, to change
* the window from 20), the autoconfigured Redis repository silently steps aside and the
* {@code InMemoryChatMemoryRepository} default takes over. Nothing is logged and everything
* appears to work until the first restart.
*
* <p>Set {@code app.memory.redis.explicit-repository=false} to remove this class from the
* context and reproduce that.
*/
@Configuration
@Profile("redis")
@ConditionalOnProperty(name = "app.memory.redis.explicit-repository", havingValue = "true", matchIfMissing = true)
public class RedisMemoryConfig {
@Bean
RedisChatMemoryRepository redisChatMemoryRepository(RedisClient jedisClient, RedisChatMemoryRepositoryProperties props) {
RedisChatMemoryRepository.Builder builder = RedisChatMemoryRepository.builder()
.jedisClient(jedisClient)
.indexName(props.getIndexName())
.keyPrefix(props.getKeyPrefix());
Duration ttl = props.getTimeToLive();
if (ttl != null) {
builder.timeToLive(ttl);
}
if (props.getInitializeSchema() != null) {
builder.initializeSchema(props.getInitializeSchema());
}
if (props.getMaxMessagesPerConversation() != null) {
builder.maxMessagesPerConversation(props.getMaxMessagesPerConversation());
}
if (props.getMaxConversationIds() != null) {
builder.maxConversationIds(props.getMaxConversationIds());
}
if (props.getMetadataFields() != null) {
builder.metadataFields(props.getMetadataFields());
}
return builder.build();
}
}
@@ -0,0 +1,57 @@
package com.ankurm.chatmemory.memory;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
/**
* Who owns which conversation. {@code ChatMemory} has no idea: it stores whatever it is handed
* under whatever ID it is handed, so "user A must not read user B's history" is entirely the
* application's job. This map is the smallest honest version of that job. In production it is a
* table ({@code conversation_id, user_id}) next to the memory table.
*
* <p>Set {@code app.ownership.enforce=false} to switch the check off and reproduce the leak the
* article shows.
*/
@Component
public class ConversationRegistry {
private final Map<String, String> ownerByConversation = new ConcurrentHashMap<>();
private final boolean enforce;
public ConversationRegistry(@Value("${app.ownership.enforce:true}") boolean enforce) {
this.enforce = enforce;
}
/** A UUID is exactly 36 characters, the width of the JDBC repository's conversation_id column. */
public String create(String user) {
String id = UUID.randomUUID().toString();
ownerByConversation.put(id, user);
return id;
}
public void requireOwner(String user, String conversationId) {
if (!enforce) {
return;
}
if (!user.equals(ownerByConversation.get(conversationId))) {
throw new NotYourConversationException(conversationId);
}
}
public List<String> conversationsOf(String user) {
return ownerByConversation.entrySet().stream()
.filter(e -> e.getValue().equals(user))
.map(Map.Entry::getKey)
.sorted()
.toList();
}
public void forget(String conversationId) {
ownerByConversation.remove(conversationId);
}
}
@@ -0,0 +1,51 @@
package com.ankurm.chatmemory.memory;
import java.util.List;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.chat.messages.Message;
import org.springframework.stereotype.Service;
/**
* The only class that talks to both the {@link ChatClient} and the {@link ChatMemory}. The
* conversation ID reaches the advisor as a request parameter ({@link ChatMemory#CONVERSATION_ID});
* the advisor reads it back out of the request context.
*/
@Service
public class ConversationService {
private final ChatClient chatClient;
private final ChatMemory chatMemory;
private final ConversationRegistry registry;
public ConversationService(ChatClient chatClient, ChatMemory chatMemory, ConversationRegistry registry) {
this.chatClient = chatClient;
this.chatMemory = chatMemory;
this.registry = registry;
}
public String start(String user) {
return registry.create(user);
}
public String send(String user, String conversationId, String text) {
registry.requireOwner(user, conversationId);
return chatClient.prompt()
.user(text)
.advisors(a -> a.param(ChatMemory.CONVERSATION_ID, conversationId))
.call()
.content();
}
public List<Message> history(String user, String conversationId) {
registry.requireOwner(user, conversationId);
return chatMemory.get(conversationId);
}
public void delete(String user, String conversationId) {
registry.requireOwner(user, conversationId);
chatMemory.clear(conversationId);
registry.forget(conversationId);
}
}
@@ -0,0 +1,12 @@
package com.ankurm.chatmemory.memory;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;
@ResponseStatus(HttpStatus.FORBIDDEN)
public class NotYourConversationException extends RuntimeException {
public NotYourConversationException(String conversationId) {
super("conversation " + conversationId + " does not belong to this user");
}
}
@@ -0,0 +1,88 @@
package com.ankurm.chatmemory.memory;
import java.util.ArrayList;
import java.util.List;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.chat.memory.ChatMemoryRepository;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.MessageType;
import org.springframework.ai.tokenizer.TokenCountEstimator;
/**
* A {@link ChatMemory} that prunes by tokens instead of by message count. {@code
* MessageWindowChatMemory} keeps "the last N messages", which is a poor proxy for cost: ten
* short messages and ten pasted stack traces are both "10". This one drops the oldest
* non-system messages until the whole conversation fits a token budget, and never leaves a
* reply at the front with its question gone.
*
* <p>It is a {@code ChatMemory}, so it drops into {@code MessageChatMemoryAdvisor.builder(...)}
* exactly where the window did. The token count is an estimate (the estimator you pass in), not
* the provider's own billing count.
*/
public final class TokenBudgetChatMemory implements ChatMemory {
private final ChatMemoryRepository repository;
private final TokenCountEstimator estimator;
private final int maxTokens;
public TokenBudgetChatMemory(ChatMemoryRepository repository, TokenCountEstimator estimator, int maxTokens) {
this.repository = repository;
this.estimator = estimator;
this.maxTokens = maxTokens;
}
@Override
public void add(String conversationId, List<Message> messages) {
List<Message> all = new ArrayList<>(repository.findByConversationId(conversationId));
all.addAll(messages);
repository.saveAll(conversationId, prune(all));
}
@Override
public List<Message> get(String conversationId) {
return repository.findByConversationId(conversationId);
}
@Override
public void clear(String conversationId) {
repository.deleteByConversationId(conversationId);
}
List<Message> prune(List<Message> all) {
List<Message> kept = new ArrayList<>(all);
while (tokens(kept) > maxTokens) {
int oldest = firstIndexOfNonSystem(kept);
if (oldest < 0) {
break; // only system messages left: nothing more can be dropped
}
kept.remove(oldest);
// A reply whose question was just dropped is noise; drop leading non-user messages too.
while (!kept.isEmpty()) {
int first = firstIndexOfNonSystem(kept);
if (first < 0 || kept.get(first).getMessageType() == MessageType.USER) {
break;
}
kept.remove(first);
}
}
return kept;
}
private int tokens(List<Message> messages) {
int total = 0;
for (Message m : messages) {
total += estimator.estimate(m.getText());
}
return total;
}
private static int firstIndexOfNonSystem(List<Message> messages) {
for (int i = 0; i < messages.size(); i++) {
if (messages.get(i).getMessageType() != MessageType.SYSTEM) {
return i;
}
}
return -1;
}
}
@@ -0,0 +1,57 @@
package com.ankurm.chatmemory.web;
import java.util.List;
import java.util.Map;
import com.ankurm.chatmemory.memory.ConversationService;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RestController;
/**
* The user comes from an {@code X-User} header purely so the tests need no login. In a real
* service it is {@code Principal#getName()} from Spring Security -- never a header the client
* chooses.
*/
@RestController
public class ChatController {
public record SendRequest(String text) {
}
public record MessageView(String role, String text) {
}
private final ConversationService conversations;
public ChatController(ConversationService conversations) {
this.conversations = conversations;
}
@PostMapping("/conversations")
Map<String, String> start(@RequestHeader("X-User") String user) {
return Map.of("conversationId", conversations.start(user));
}
@PostMapping("/conversations/{id}/messages")
Map<String, String> send(@RequestHeader("X-User") String user, @PathVariable String id,
@RequestBody SendRequest request) {
return Map.of("reply", conversations.send(user, id, request.text()));
}
@GetMapping("/conversations/{id}/messages")
List<MessageView> history(@RequestHeader("X-User") String user, @PathVariable String id) {
return conversations.history(user, id).stream()
.map(m -> new MessageView(m.getMessageType().name(), m.getText()))
.toList();
}
@DeleteMapping("/conversations/{id}")
void delete(@RequestHeader("X-User") String user, @PathVariable String id) {
conversations.delete(user, id);
}
}
@@ -0,0 +1,14 @@
spring:
datasource:
url: ${CM_PG_URL:jdbc:postgresql://127.0.0.1:5440/chatdb}
username: chat
password: chat
ai:
chat:
memory:
repository:
jdbc:
initialize-schema: always
autoconfigure:
exclude:
- org.springframework.ai.model.chat.memory.repository.redis.autoconfigure.RedisChatMemoryRepositoryAutoConfiguration
@@ -0,0 +1,14 @@
spring:
ai:
chat:
memory:
repository:
redis:
host: 127.0.0.1
port: ${CM_REDIS_STACK_PORT:6390}
time-to-live: 24h
max-messages-per-conversation: 200
autoconfigure:
exclude:
- org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration
- org.springframework.ai.model.chat.memory.repository.jdbc.autoconfigure.JdbcChatMemoryRepositoryAutoConfiguration
@@ -0,0 +1,25 @@
spring:
application:
name: chat-memory
ai:
model:
chat: openai
openai:
api-key: ${OPENAI_API_KEY:sk-not-set}
chat:
model: gpt-5-mini
# Default profile = the in-memory repository. Spring AI's JDBC and Redis repository
# autoconfigurations are on the classpath (both starters are dependencies) and would otherwise
# demand a DataSource / a Redis server at startup, so they are excluded here and re-enabled per
# profile in application-jdbc.yml and application-redis.yml.
autoconfigure:
exclude:
- org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration
- org.springframework.ai.model.chat.memory.repository.jdbc.autoconfigure.JdbcChatMemoryRepositoryAutoConfiguration
- org.springframework.ai.model.chat.memory.repository.redis.autoconfigure.RedisChatMemoryRepositoryAutoConfiguration
app:
memory:
max-messages: 20
ownership:
enforce: true
@@ -0,0 +1,100 @@
package com.ankurm.chatmemory;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.util.List;
import com.ankurm.chatmemory.support.ScriptedChatModel;
import com.ankurm.chatmemory.support.Show;
import com.ankurm.chatmemory.support.TestModelConfig;
import com.ankurm.chatmemory.support.Transcript;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
/**
* The advisor end to end on the in-memory repository: which messages reach the model on each
* call, what ends up in storage, and what happens when the conversation ID is left out.
*/
@SpringBootTest(properties = "spring.ai.model.chat=none")
@Import(TestModelConfig.class)
class AdvisorConversationTest {
@Autowired
ChatClient chat;
@Autowired
ChatMemory memory;
@Autowired
ScriptedChatModel model;
private String ask(String conversationId, String text) {
return chat.prompt().user(text)
.advisors(a -> a.param(ChatMemory.CONVERSATION_ID, conversationId))
.call().content();
}
private static String sent(Prompt prompt) {
return Show.inline(prompt.getInstructions());
}
@Test
void memoryIsPerConversationAndCarriesTheHistoryIntoEveryPrompt() {
try (Transcript t = new Transcript("02-advisor-conversations.txt", "MessageChatMemoryAdvisor: what the model is sent")) {
String a = "conv-A";
String b = "conv-B";
t.line("conv-A, call 1: \"My name is Priya\"");
t.line(" reply: %s", ask(a, "My name is Priya"));
t.line(" model was sent: %s", sent(model.lastPrompt()));
t.blank().line("conv-A, call 2: \"What is my name?\"");
String replyA = ask(a, "What is my name?");
t.line(" reply: %s", replyA);
t.line(" model was sent: %s", sent(model.lastPrompt()));
t.blank().line("conv-B, call 1: \"What is my name?\" (different conversation ID)");
String replyB = ask(b, "What is my name?");
t.line(" reply: %s", replyB);
t.line(" model was sent: %s", sent(model.lastPrompt()));
t.blank().line("Stored for conv-A afterwards:");
memory.get(a).forEach(m -> t.line(" %s", Show.one(m)));
assertThat(replyA).isEqualTo("Your name is Priya.");
assertThat(replyB).isEqualTo("I do not know your name yet.");
}
}
@Test
void leavingOutTheConversationIdIsAnErrorNotASharedDefault() {
try (Transcript t = new Transcript("03-missing-conversation-id.txt", "No conversation ID on the request")) {
t.line("chat.prompt().user(\"hello\").call().content() // no advisors(...) param");
Throwable thrown = null;
try {
chat.prompt().user("hello").call().content();
}
catch (RuntimeException e) {
thrown = e;
}
assertThat(thrown).isNotNull();
t.line(" threw %s", thrown.getClass().getName());
t.line(" message: %s", thrown.getMessage());
assertThatThrownBy(() -> chat.prompt().user("hello").call().content())
.isInstanceOf(RuntimeException.class);
}
}
@Test
void oneCallOnAFreshConversationSendsOnlyTheSystemPromptAndTheUserTurn() {
List<Prompt> before = List.copyOf(model.prompts());
ask("fresh", "hi");
Prompt p = model.lastPrompt();
assertThat(model.prompts()).hasSize(before.size() + 1);
assertThat(p.getInstructions()).hasSize(2);
}
}
@@ -0,0 +1,59 @@
package com.ankurm.chatmemory;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.Map;
import com.ankurm.chatmemory.support.Transcript;
import org.junit.jupiter.api.Test;
import org.springframework.ai.model.chat.memory.repository.jdbc.autoconfigure.JdbcChatMemoryRepositoryProperties;
import org.springframework.ai.model.chat.memory.repository.redis.autoconfigure.RedisChatMemoryRepositoryProperties;
import org.springframework.boot.context.properties.bind.Bindable;
import org.springframework.boot.context.properties.bind.Binder;
import org.springframework.boot.context.properties.source.MapConfigurationPropertySource;
/**
* Spring Boot silently ignores property keys nothing binds to. This test binds the keys the
* application's YAML uses to the real properties classes, then binds two plausible-looking wrong
* spellings to show that they change nothing.
*/
class ConfigKeysTest {
private static <T> T bind(String prefix, T target, Map<String, String> props) {
new Binder(new MapConfigurationPropertySource(props)).bind(prefix, Bindable.ofInstance(target));
return target;
}
@Test
void theKeysInTheYamlBindAndTheGuessesDoNot() {
try (Transcript t = new Transcript("22-config-keys.txt", "Do the chat-memory property keys bind?")) {
JdbcChatMemoryRepositoryProperties jdbc = bind("spring.ai.chat.memory.repository.jdbc",
new JdbcChatMemoryRepositoryProperties(), Map.of("spring.ai.chat.memory.repository.jdbc.initialize-schema", "always"));
t.line("spring.ai.chat.memory.repository.jdbc.initialize-schema=always -> initializeSchema = %s", jdbc.getInitializeSchema());
assertThat(jdbc.getInitializeSchema().name()).isEqualTo("ALWAYS");
RedisChatMemoryRepositoryProperties redis = bind("spring.ai.chat.memory.repository.redis",
new RedisChatMemoryRepositoryProperties(), Map.of(
"spring.ai.chat.memory.repository.redis.host", "cache.internal",
"spring.ai.chat.memory.repository.redis.port", "6390",
"spring.ai.chat.memory.repository.redis.time-to-live", "24h",
"spring.ai.chat.memory.repository.redis.max-messages-per-conversation", "200"));
t.blank().line("redis.host / port / time-to-live / max-messages-per-conversation = %s / %d / %s / %d",
redis.getHost(), redis.getPort(), redis.getTimeToLive(), redis.getMaxMessagesPerConversation());
assertThat(redis.getPort()).isEqualTo(6390);
RedisChatMemoryRepositoryProperties guess = bind("spring.ai.chat.memory.repository.redis",
new RedisChatMemoryRepositoryProperties(), Map.of(
"spring.ai.chat.memory.redis.port", "6390",
"spring.ai.chat.memory.repository.redis.ttl", "24h",
"spring.data.redis.port", "6390"));
t.blank().line("Three guesses, each of which looks right and binds nothing:");
t.line(" spring.ai.chat.memory.redis.port=6390 (no \"repository\" segment)");
t.line(" spring.ai.chat.memory.repository.redis.ttl=24h (the key is time-to-live)");
t.line(" spring.data.redis.port=6390 (Spring Data Redis's key; this repository builds its own Jedis client)");
t.line(" -> port = %d, time-to-live = %s", guess.getPort(), guess.getTimeToLive());
assertThat(guess.getPort()).isEqualTo(6379);
assertThat(guess.getTimeToLive()).isNull();
}
}
}
@@ -0,0 +1,62 @@
package com.ankurm.chatmemory;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.List;
import com.ankurm.chatmemory.support.Transcript;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.chat.memory.ChatMemoryRepository;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.model.chat.memory.autoconfigure.ChatMemoryAutoConfiguration;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
/** What you get with no configuration at all -- and what happens to the properties you might expect to exist. */
class DefaultWindowTest {
private final ApplicationContextRunner runner = new ApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(ChatMemoryAutoConfiguration.class));
private static int storedAfter30Messages(ChatMemory memory) {
for (int n = 1; n <= 15; n++) {
memory.add("c", List.of(new UserMessage("u" + n), new AssistantMessage("a" + n)));
}
List<Message> kept = memory.get("c");
return kept.size();
}
@Test
void autoconfigurationGivesAnInMemoryRepositoryAndAWindowOfTwenty() {
try (Transcript t = new Transcript("04-autoconfigured-defaults.txt", "ChatMemoryAutoConfiguration, nothing configured")) {
runner.run(ctx -> {
ChatMemory memory = ctx.getBean(ChatMemory.class);
ChatMemoryRepository repo = ctx.getBean(ChatMemoryRepository.class);
t.line("ChatMemory bean: %s", memory.getClass().getSimpleName());
t.line("ChatMemoryRepository bean: %s", repo.getClass().getSimpleName());
int kept = storedAfter30Messages(memory);
t.line("15 turns (30 messages) added -> %d stored, oldest kept: %s", kept, memory.get("c").getFirst().getText());
assertThat(memory).isInstanceOf(org.springframework.ai.chat.memory.MessageWindowChatMemory.class);
assertThat(kept).isEqualTo(20);
});
}
}
@Test
void thereIsNoSpringAiPropertyForTheWindowSize() {
try (Transcript t = new Transcript("05-no-window-property.txt", "Guessing a window-size property")) {
for (String key : List.of("spring.ai.chat.memory.max-messages", "spring.ai.chat.memory.window-size",
"spring.ai.chat.memory.repository.max-messages")) {
runner.withPropertyValues(key + "=6").run(ctx -> {
int kept = storedAfter30Messages(ctx.getBean(ChatMemory.class));
t.line("%-52s = 6 -> 30 messages added, %d stored", key, kept);
assertThat(kept).isEqualTo(20);
});
}
t.blank().line("Spring Boot ignores unknown keys silently, so none of these did anything.");
}
}
}
@@ -0,0 +1,295 @@
package com.ankurm.chatmemory;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.util.List;
import java.util.Map;
import java.util.concurrent.BrokenBarrierException;
import java.util.concurrent.CyclicBarrier;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.stream.Collectors;
import com.ankurm.chatmemory.support.InterleavingRepository;
import com.ankurm.chatmemory.support.ScriptedChatModel;
import com.ankurm.chatmemory.support.Services;
import com.ankurm.chatmemory.support.Show;
import com.ankurm.chatmemory.support.TestModelConfig;
import com.ankurm.chatmemory.support.Transcript;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.chat.memory.ChatMemoryRepository;
import org.springframework.ai.chat.memory.MessageWindowChatMemory;
import org.springframework.ai.chat.memory.repository.jdbc.JdbcChatMemoryRepository;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.ToolResponseMessage;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.system.CapturedOutput;
import org.springframework.boot.test.system.OutputCaptureExtension;
import org.springframework.context.annotation.Import;
import org.springframework.jdbc.core.JdbcTemplate;
/** The autoconfigured JdbcChatMemoryRepository against a real PostgreSQL 16 (scripts/services-up.sh). */
@SpringBootTest(properties = { "spring.ai.model.chat=none", "spring.profiles.active=jdbc" })
@Import(TestModelConfig.class)
@ExtendWith(OutputCaptureExtension.class)
class JdbcPostgresTest {
@BeforeAll
static void needPostgres() {
Services.require("PostgreSQL", Services.PG_PORT);
}
@Autowired
ChatClient chat;
@Autowired
ChatMemory memory;
@Autowired
ChatMemoryRepository repository;
@Autowired
JdbcTemplate jdbc;
@Autowired
ScriptedChatModel model;
@BeforeEach
void clean() {
jdbc.update("DELETE FROM SPRING_AI_CHAT_MEMORY");
}
private String ask(String id, String text) {
return chat.prompt().user(text).advisors(a -> a.param(ChatMemory.CONVERSATION_ID, id)).call().content();
}
@Test
void historySurvivesARestartBecauseItLivesInThePostgresTable() {
try (Transcript t = new Transcript("08-jdbc-round-trip.txt", "JdbcChatMemoryRepository on PostgreSQL 16")) {
List<Map<String, Object>> columns = jdbc.queryForList("""
SELECT column_name, data_type, character_maximum_length
FROM information_schema.columns WHERE table_name = 'spring_ai_chat_memory' ORDER BY ordinal_position""");
t.line("Table created by initialize-schema=always:");
columns.forEach(c -> t.line(" %-16s %-28s %s", c.get("column_name"), c.get("data_type"),
c.get("character_maximum_length") == null ? "" : "(" + c.get("character_maximum_length") + ")"));
String id = "6f0a1c9e-2b7d-4c55-9a53-0d1f3e7a2b10";
ask(id, "My name is Priya");
t.blank().line("After one call, rows for the conversation:");
jdbc.queryForList("SELECT sequence_id, type, content FROM SPRING_AI_CHAT_MEMORY WHERE conversation_id = ? ORDER BY sequence_id", id)
.forEach(r -> t.line(" seq=%s %-9s %s", r.get("sequence_id"), r.get("type"), r.get("content")));
// "Restart": a brand-new repository and memory over the same database. Nothing is shared in the JVM.
JdbcChatMemoryRepository fresh = JdbcChatMemoryRepository.builder().jdbcTemplate(jdbc)
.dialect(new org.springframework.ai.chat.memory.repository.jdbc.PostgresChatMemoryRepositoryDialect()).build();
ChatMemory freshMemory = MessageWindowChatMemory.builder().chatMemoryRepository(fresh).build();
ChatClient freshClient = ChatClient.builder(model)
.defaultAdvisors(MessageChatMemoryAdvisor.builder(freshMemory).build()).build();
String reply = freshClient.prompt().user("What is my name?")
.advisors(a -> a.param(ChatMemory.CONVERSATION_ID, id)).call().content();
t.blank().line("New repository + new memory over the same database, then \"What is my name?\":");
t.line(" reply: %s", reply);
assertThat(reply).isEqualTo("Your name is Priya.");
t.line(" findConversationIds() returns every conversation in the table: %s", fresh.findConversationIds());
}
}
@Test
void aConversationIdLongerThan36CharactersFailsBeforeTheModelIsCalled() {
try (Transcript t = new Transcript("09-jdbc-conversation-id-limit.txt", "conversation_id is VARCHAR(36)")) {
String tooLong = "user:[email protected]:thread:2026-09-24"; // 43 characters
t.line("conversation ID: \"%s\" (%d characters)", tooLong, tooLong.length());
int callsBefore = model.prompts().size();
Throwable thrown = null;
try {
ask(tooLong, "hello");
}
catch (RuntimeException e) {
thrown = e;
}
assertThat(thrown).isNotNull();
Throwable root = thrown;
while (root.getCause() != null) {
root = root.getCause();
}
t.line("model calls made for this request: %d (the user message is saved before the model is called)", model.prompts().size() - callsBefore);
t.line("thrown: %s", thrown.getClass().getName());
t.line("root cause: %s", root.getMessage().lines().findFirst().orElse(""));
t.line("rows stored for it afterwards: %d",
jdbc.queryForObject("SELECT count(*) FROM SPRING_AI_CHAT_MEMORY", Integer.class));
assertThat(model.prompts().size() - callsBefore).isZero();
}
}
@Test
void toolCallMessagesAreDroppedOnSave(CapturedOutput output) {
try (Transcript t = new Transcript("10-jdbc-drops-tool-messages.txt", "What JdbcChatMemoryRepository.saveAll keeps")) {
String id = "tool-conv";
AssistantMessage withToolCall = AssistantMessage.builder().content("")
.toolCalls(List.of(new AssistantMessage.ToolCall("call-1", "function", "weather", "{\"city\":\"Mumbai\"}"))).build();
ToolResponseMessage toolResult = ToolResponseMessage.builder()
.responses(List.of(new ToolResponseMessage.ToolResponse("call-1", "weather", "31C"))).build();
List<Message> turn = List.of(new UserMessage("Weather in Mumbai?"), withToolCall, toolResult,
new AssistantMessage("31C and humid."));
t.line("saveAll() given 4 messages:");
turn.forEach(m -> t.line(" %-9s %s", m.getMessageType(), m.getText().isEmpty() ? "(tool call weather)" : m.getText()));
repository.saveAll(id, turn);
List<Message> back = repository.findByConversationId(id);
t.blank().line("findByConversationId() returns %d:", back.size());
back.forEach(m -> t.line(" %s", Show.one(m)));
String warning = output.getAll().lines().filter(l -> l.contains("JdbcChatMemoryRepository")).findFirst().orElse("(none)");
t.blank().line("Logged by the repository: WARN %s", warning.replaceAll("^.*JdbcChatMemoryRepository\\s+:\\s+", ""));
assertThat(back).hasSize(2);
}
}
private List<String> twoWritersOnOneConversation(ChatMemory memoryUnderTest) throws Exception {
repository.saveAll("shared", List.of(new UserMessage("u0"), new AssistantMessage("a0"))); // seed without the barrier
Thread a = Thread.ofPlatform().start(() -> memoryUnderTest.add("shared", new UserMessage("from-A")));
Thread b = Thread.ofPlatform().start(() -> memoryUnderTest.add("shared", new UserMessage("from-B")));
a.join();
b.join();
return memoryUnderTest.get("shared").stream().map(Message::getText).collect(Collectors.toList());
}
@Test
void twoWritersOnOneConversationLoseAMessageBecauseSaveAllReplacesTheWholeConversation() throws Exception {
try (Transcript t = new Transcript("11-jdbc-lost-update.txt", "Two concurrent add() calls on one conversation")) {
InterleavingRepository racy = new InterleavingRepository(repository, false);
List<String> stored = twoWritersOnOneConversation(
MessageWindowChatMemory.builder().chatMemoryRepository(racy).build());
String first = racy.saveOrder.getFirst();
String last = racy.saveOrder.getLast();
t.line("seeded with: u0, a0");
t.line("two writers each add one message; both read the conversation before either writes it back");
t.line("first writer to save: adds a 3-message list, saved");
t.line("second writer to save: adds a different 3-message list, replaces the first");
t.line("stored afterwards: %s (%d messages, 4 were sent)",
stored.toString().replace(last, "<second writer's message>"), stored.size());
t.line("the first writer's message is %s, the second writer's is %s",
stored.contains(first) ? "kept" : "GONE", stored.contains(last) ? "kept" : "gone");
assertThat(stored).containsExactly("u0", "a0", last);
}
}
@Test
void saveAllsThatOverlapInTimeAreWorseThanALostUpdate() throws Exception {
try (Transcript t = new Transcript("12-jdbc-overlapping-saves.txt", "Two saveAll() calls released at the same instant")) {
InterleavingRepository racy = new InterleavingRepository(repository, true);
List<String> stored = twoWritersOnOneConversation(
MessageWindowChatMemory.builder().chatMemoryRepository(racy).build());
t.line("seeded with u0, a0; two writers add from-A and from-B; both saveAll() calls start together");
t.line("The outcome differs from run to run, so the exact count is not printed here. Across repeated runs on");
t.line("PostgreSQL 16 (READ COMMITTED) it is one of two things, and never the 4 messages that were sent:");
t.line(" 3 messages -> one writer's message is gone");
t.line(" 6 messages -> both transactions inserted, history is duplicated, sequence_id values collide");
assertThat(stored.size()).isIn(3, 6);
}
}
/** The smallest possible fix that works inside one JVM: one lock per conversation. */
private static final class LockingChatMemory implements ChatMemory {
private final ChatMemory delegate;
private final java.util.concurrent.ConcurrentHashMap<String, Object> locks = new java.util.concurrent.ConcurrentHashMap<>();
LockingChatMemory(ChatMemory delegate) {
this.delegate = delegate;
}
@Override
public void add(String id, List<Message> messages) {
synchronized (locks.computeIfAbsent(id, k -> new Object())) {
delegate.add(id, messages);
}
}
@Override
public List<Message> get(String id) {
return delegate.get(id);
}
@Override
public void clear(String id) {
delegate.clear(id);
}
}
@Test
void aPerConversationLockRemovesTheLossOnASingleInstance() throws Exception {
try (Transcript t = new Transcript("14-jdbc-lost-update-locked.txt", "Same two writers, per-conversation lock")) {
MessageWindowChatMemory racy = MessageWindowChatMemory.builder()
.chatMemoryRepository(new InterleavingRepository(repository, false)).build();
List<String> stored = twoWritersOnOneConversation(new LockingChatMemory(racy));
t.line("stored afterwards: %d messages", stored.size());
t.line("contains from-A: %s, from-B: %s", stored.contains("from-A"), stored.contains("from-B"));
assertThat(stored).hasSize(4).contains("from-A", "from-B");
}
}
@Test
void aOneXTableStopsWorkingAfterTheUpgrade() {
Services.require("PostgreSQL", Services.PG_PORT);
try (Transcript t = new Transcript("13-jdbc-1x-table-upgrade.txt", "A Spring AI 1.x table under the 2.0 repository")) {
jdbc.execute("DROP SCHEMA IF EXISTS legacy1x CASCADE");
jdbc.execute("CREATE SCHEMA legacy1x");
jdbc.execute("""
CREATE TABLE legacy1x.SPRING_AI_CHAT_MEMORY (
conversation_id VARCHAR(36) NOT NULL,
content TEXT NOT NULL,
type VARCHAR(10) NOT NULL CHECK (type IN ('USER', 'ASSISTANT', 'SYSTEM', 'TOOL')),
"timestamp" TIMESTAMP NOT NULL)""");
jdbc.update("INSERT INTO legacy1x.SPRING_AI_CHAT_MEMORY VALUES ('old-conv', 'My name is Priya', 'USER', now() - interval '2 minutes')");
jdbc.update("INSERT INTO legacy1x.SPRING_AI_CHAT_MEMORY VALUES ('old-conv', 'Nice to meet you, Priya.', 'ASSISTANT', now() - interval '1 minute')");
t.line("A 1.1.x-shaped table (no sequence_id column) holds 2 rows for \"old-conv\".");
var ds = new org.springframework.jdbc.datasource.DriverManagerDataSource(
"jdbc:postgresql://127.0.0.1:" + Services.PG_PORT + "/chatdb?currentSchema=legacy1x", "chat", "chat");
JdbcTemplate legacy = new JdbcTemplate(ds);
JdbcChatMemoryRepository repo = JdbcChatMemoryRepository.builder().jdbcTemplate(legacy)
.dialect(new org.springframework.ai.chat.memory.repository.jdbc.PostgresChatMemoryRepositoryDialect()).build();
t.blank().line("2.0.1 repository, findByConversationId(\"old-conv\"):");
Throwable read = null;
try {
repo.findByConversationId("old-conv");
}
catch (RuntimeException e) {
read = e;
}
assertThat(read).isNotNull();
t.line(" %s", firstLine(read));
t.blank().line("Migration: add the column, backfill it from the timestamp order, then make it NOT NULL:");
String[] steps = {
"ALTER TABLE SPRING_AI_CHAT_MEMORY ADD COLUMN sequence_id BIGINT",
"UPDATE SPRING_AI_CHAT_MEMORY m SET sequence_id = s.rn FROM (SELECT ctid AS c, row_number() OVER (PARTITION BY conversation_id ORDER BY \"timestamp\") - 1 AS rn FROM SPRING_AI_CHAT_MEMORY) s WHERE m.ctid = s.c",
"ALTER TABLE SPRING_AI_CHAT_MEMORY ALTER COLUMN sequence_id SET NOT NULL",
"CREATE INDEX IF NOT EXISTS SPRING_AI_CHAT_MEMORY_CONVERSATION_ID_SEQUENCE_ID_IDX ON SPRING_AI_CHAT_MEMORY(conversation_id, sequence_id)"
};
for (String step : steps) {
legacy.execute(step);
t.line(" %s", step.length() > 110 ? step.substring(0, 107) + "..." : step);
}
List<Message> migrated = repo.findByConversationId("old-conv");
t.blank().line("findByConversationId(\"old-conv\") after the migration:");
migrated.forEach(m -> t.line(" %s", Show.one(m)));
assertThat(migrated).extracting(Message::getText).containsExactly("My name is Priya", "Nice to meet you, Priya.");
jdbc.execute("DROP SCHEMA legacy1x CASCADE");
}
}
private static String firstLine(Throwable t) {
Throwable root = t;
while (root.getCause() != null) {
root = root.getCause();
}
return root.getMessage().lines().findFirst().orElse("");
}
}
@@ -0,0 +1,41 @@
package com.ankurm.chatmemory;
import static org.assertj.core.api.Assertions.assertThat;
import com.ankurm.chatmemory.support.Http;
import com.ankurm.chatmemory.support.TestModelConfig;
import com.ankurm.chatmemory.support.Transcript;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
/** The same endpoints with the ownership check off: a conversation ID is a bearer token for someone's history. */
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
properties = { "spring.ai.model.chat=none", "app.ownership.enforce=false" })
@Import(TestModelConfig.class)
class OwnershipLeakTest {
@Value("${local.server.port}")
int port;
@Test
void anyoneWhoHoldsTheConversationIdCanReadAndContinueIt() {
try (Transcript t = new Transcript("21-ownership-unenforced.txt", "app.ownership.enforce=false")) {
Http http = new Http(port);
String id = Http.field(http.post("alice", "/conversations", "").body(), "conversationId");
http.post("alice", "/conversations/" + id + "/messages", "{\"text\":\"My name is Priya\"}");
t.line("alice starts a conversation and says \"My name is Priya\"");
Http.Reply read = http.get("bob", "/conversations/" + id + "/messages");
t.blank().line("bob: GET /conversations/<alice's id>/messages -> %d", read.status());
t.line(" body: %s", read.body());
Http.Reply write = http.post("bob", "/conversations/" + id + "/messages", "{\"text\":\"What is my name?\"}");
t.line("bob: POST \"What is my name?\" -> %d %s", write.status(), write.body());
assertThat(read.status()).isEqualTo(200);
assertThat(read.body()).contains("My name is Priya");
assertThat(write.body()).contains("Your name is Priya.");
}
}
}
@@ -0,0 +1,42 @@
package com.ankurm.chatmemory;
import static org.assertj.core.api.Assertions.assertThat;
import com.ankurm.chatmemory.support.Http;
import com.ankurm.chatmemory.support.TestModelConfig;
import com.ankurm.chatmemory.support.Transcript;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
/** Per-user conversations over HTTP, with the ownership check on (the default). */
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, properties = "spring.ai.model.chat=none")
@Import(TestModelConfig.class)
class OwnershipTest {
@Value("${local.server.port}")
int port;
@Test
void anotherUserGetsA403ForSomeoneElsesConversationId() {
try (Transcript t = new Transcript("20-ownership-enforced.txt", "app.ownership.enforce=true (default)")) {
Http http = new Http(port);
String id = Http.field(http.post("alice", "/conversations", "").body(), "conversationId");
http.post("alice", "/conversations/" + id + "/messages", "{\"text\":\"My name is Priya\"}");
t.line("alice starts a conversation and says \"My name is Priya\"");
Http.Reply read = http.get("bob", "/conversations/" + id + "/messages");
t.blank().line("bob: GET /conversations/<alice's id>/messages -> %d", read.status());
Http.Reply write = http.post("bob", "/conversations/" + id + "/messages", "{\"text\":\"What is my name?\"}");
t.line("bob: POST /conversations/<alice's id>/messages -> %d", write.status());
Http.Reply own = http.get("alice", "/conversations/" + id + "/messages");
t.line("alice: GET /conversations/<her id>/messages -> %d, %d messages", own.status(),
own.body().split("\"role\"").length - 1);
assertThat(read.status()).isEqualTo(403);
assertThat(write.status()).isEqualTo(403);
assertThat(own.status()).isEqualTo(200);
}
}
}
@@ -0,0 +1,68 @@
package com.ankurm.chatmemory;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.List;
import java.util.Set;
import com.ankurm.chatmemory.support.Services;
import com.ankurm.chatmemory.support.Show;
import com.ankurm.chatmemory.support.TestModelConfig;
import com.ankurm.chatmemory.support.Transcript;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.memory.ChatMemoryRepository;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import redis.clients.jedis.RedisClient;
/** time-to-live and max-messages-per-conversation, with a 2-second TTL so the expiry is observable. */
@SpringBootTest(properties = { "spring.ai.model.chat=none", "spring.profiles.active=redis",
"spring.ai.chat.memory.repository.redis.time-to-live=2s",
"spring.ai.chat.memory.repository.redis.max-messages-per-conversation=4" })
@Import(TestModelConfig.class)
class RedisCapsAndTtlTest {
@BeforeAll
static void needRedisStack() {
Services.require("Redis Stack", Services.REDIS_STACK_PORT);
}
@Autowired
ChatMemoryRepository repository;
@Autowired
RedisClient redis;
@BeforeEach
void clean() {
Set<String> keys = redis.keys("chat-memory:*");
if (!keys.isEmpty()) {
redis.del(keys.toArray(new String[0]));
}
}
@Test
void conversationsExpireAndTheRepositoryHasItsOwnMessageCap() throws Exception {
try (Transcript t = new Transcript("18-redis-ttl-and-cap.txt", "Redis time-to-live = 2s, max-messages-per-conversation = 4")) {
repository.saveAll("ttl-conv", List.of(new UserMessage("u1"), new AssistantMessage("a1")));
t.line("saved 2 messages; readable now: %s", Show.inline(repository.findByConversationId("ttl-conv")));
Thread.sleep(3000);
List<Message> after = repository.findByConversationId("ttl-conv");
t.line("3 seconds later: %s", after.isEmpty() ? "(gone: Redis expired the keys)" : Show.inline(after));
assertThat(after).isEmpty();
repository.saveAll("cap-conv", List.of(new UserMessage("u1"), new AssistantMessage("a1"), new UserMessage("u2"),
new AssistantMessage("a2"), new UserMessage("u3"), new AssistantMessage("a3")));
List<Message> capped = repository.findByConversationId("cap-conv");
t.blank().line("saved 6 messages with max-messages-per-conversation=4, read back %d:", capped.size());
t.line(" %s", Show.inline(capped));
Set<String> keys = redis.keys("chat-memory:*");
t.line("keys in Redis for it: %d", keys.size());
}
}
}
@@ -0,0 +1,42 @@
package com.ankurm.chatmemory;
import static org.assertj.core.api.Assertions.assertThat;
import com.ankurm.chatmemory.support.Services;
import com.ankurm.chatmemory.support.Transcript;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.memory.repository.redis.RedisChatMemoryRepository;
import redis.clients.jedis.RedisClient;
/** The Redis repository is not built on plain Redis: it needs RediSearch and RedisJSON. Point it at a plain server and read the error. */
class RedisPlainFailureTest {
@BeforeAll
static void needPlainRedis() {
Services.require("plain Redis", Services.REDIS_PLAIN_PORT);
}
@Test
void plainRedisWithoutModulesFailsAtStartup() {
try (Transcript t = new Transcript("19-redis-plain-fails.txt", "RedisChatMemoryRepository against plain Redis 7.0 (no modules)")) {
RedisClient plain = RedisClient.create("redis://127.0.0.1:" + Services.REDIS_PLAIN_PORT);
t.line("server: %s", plain.info("server").lines().filter(l -> l.startsWith("redis_version")).findFirst().orElse("?"));
Throwable thrown = null;
try {
RedisChatMemoryRepository.builder().jedisClient(plain).initializeSchema(true).build();
}
catch (RuntimeException e) {
thrown = e;
}
assertThat(thrown).isNotNull();
Throwable root = thrown;
while (root.getCause() != null) {
root = root.getCause();
}
t.line("RedisChatMemoryRepository.builder()...initializeSchema(true).build() threw:");
t.line(" %s", thrown.getClass().getName());
t.line(" root cause: %s: %s", root.getClass().getSimpleName(), root.getMessage());
}
}
}
@@ -0,0 +1,82 @@
package com.ankurm.chatmemory;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.Set;
import com.ankurm.chatmemory.support.Services;
import com.ankurm.chatmemory.support.TestModelConfig;
import com.ankurm.chatmemory.support.Transcript;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.boot.test.system.CapturedOutput;
import org.springframework.boot.test.system.OutputCaptureExtension;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.chat.memory.ChatMemoryRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionEvaluationReport;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.context.annotation.Import;
import redis.clients.jedis.RedisClient;
/**
* The Redis starter, the Redis profile, a running Redis Stack -- and the history goes to the JVM heap.
* Same application as RedisStackTest with one property flipped: app.memory.redis.explicit-repository=false.
*/
@SpringBootTest(properties = { "spring.ai.model.chat=none", "spring.profiles.active=redis",
"app.memory.redis.explicit-repository=false" })
@Import(TestModelConfig.class)
@ExtendWith(OutputCaptureExtension.class)
class RedisSilentFallbackTest {
@BeforeAll
static void needRedisStack() {
Services.require("Redis Stack", Services.REDIS_STACK_PORT);
}
@Autowired
ChatClient chat;
@Autowired
ChatMemoryRepository repository;
@Autowired
ConfigurableApplicationContext context;
@Test
void aCustomChatMemoryBeanMakesTheRedisRepositoryStepAsideWithoutAWord(CapturedOutput output) {
try (Transcript t = new Transcript("17-redis-silent-fallback.txt", "Redis starter + your own ChatMemory bean")) {
RedisClient probe = RedisClient.create("redis://127.0.0.1:" + Services.REDIS_STACK_PORT);
Set<String> leftovers = probe.keys("chat-memory:*");
if (!leftovers.isEmpty()) {
probe.del(leftovers.toArray(new String[0]));
}
Set<String> before = probe.keys("chat-memory:*");
t.line("application has: spring-ai-starter-model-chat-memory-repository-redis, profile \"redis\", Redis Stack reachable,");
t.line("and its own @Bean ChatMemory (needed to set a window other than 20)");
t.blank().line("ChatMemoryRepository bean in the context: %s", repository.getClass().getSimpleName());
chat.prompt().user("hello").advisors(a -> a.param(ChatMemory.CONVERSATION_ID, "fallback-conv")).call().content();
Set<String> after = probe.keys("chat-memory:*");
t.line("Redis keys under \"chat-memory:\" before / after one chat call: %d / %d", before.size(), after.size());
ConditionEvaluationReport report = ConditionEvaluationReport.get(context.getBeanFactory());
String outcome = report.getConditionAndOutcomesBySource().entrySet().stream()
.filter(e -> e.getKey().endsWith("RedisChatMemoryRepositoryAutoConfiguration#redisChatMemoryRepository"))
.flatMap(e -> e.getValue().stream())
.map(o -> o.getOutcome().getMessage()).findFirst().orElse("(no condition report)");
t.blank().line("Condition report for the autoconfigured Redis repository bean:");
t.line(" %s", outcome.replace("SearchStrategy: all", "SearchStrategy: all"));
long warnings = output.getAll().lines()
.filter(l -> (l.contains(" WARN ") || l.contains(" ERROR ")) && (l.contains("Redis") || l.contains("ChatMemory")))
.count();
t.line("WARN/ERROR log lines mentioning Redis or ChatMemory, captured over the whole test class: %d", warnings);
assertThat(warnings).isZero();
assertThat(repository.getClass().getSimpleName()).isEqualTo("InMemoryChatMemoryRepository");
assertThat(after).hasSameSizeAs(before);
assertThat(outcome).contains("org.springframework.ai.chat.memory.ChatMemory");
}
}
}
@@ -0,0 +1,108 @@
package com.ankurm.chatmemory;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import com.ankurm.chatmemory.support.InterleavingRepository;
import com.ankurm.chatmemory.support.ScriptedChatModel;
import com.ankurm.chatmemory.support.Services;
import com.ankurm.chatmemory.support.Show;
import com.ankurm.chatmemory.support.TestModelConfig;
import com.ankurm.chatmemory.support.Transcript;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.chat.memory.ChatMemoryRepository;
import org.springframework.ai.chat.memory.MessageWindowChatMemory;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import redis.clients.jedis.RedisClient;
/** RedisChatMemoryRepository against a real Redis Stack 7.4 (Redis + RediSearch + RedisJSON). */
@SpringBootTest(properties = { "spring.ai.model.chat=none", "spring.profiles.active=redis" })
@Import(TestModelConfig.class)
class RedisStackTest {
@BeforeAll
static void needRedisStack() {
Services.require("Redis Stack", Services.REDIS_STACK_PORT);
}
@Autowired
ChatClient chat;
@Autowired
ChatMemory memory;
@Autowired
ChatMemoryRepository repository;
@Autowired
ScriptedChatModel model;
@Autowired
RedisClient redis;
@BeforeEach
void clean() {
Set<String> keys = redis.keys("chat-memory:*");
if (!keys.isEmpty()) {
redis.del(keys.toArray(new String[0]));
}
}
@Test
@SuppressWarnings("unchecked")
void historyLivesInRedisJsonDocumentsWithAnExpiry() {
try (Transcript t = new Transcript("15-redis-round-trip.txt", "RedisChatMemoryRepository on Redis Stack")) {
t.line("ChatMemoryRepository bean: %s", repository.getClass().getSimpleName());
String id = "6f0a1c9e-2b7d-4c55-9a53-0d1f3e7a2b10";
String reply = chat.prompt().user("My name is Priya").advisors(a -> a.param(ChatMemory.CONVERSATION_ID, id)).call().content();
t.line("call 1 reply: %s", reply);
List<String> keys = new ArrayList<>(redis.keys("chat-memory:*"));
keys.sort(String::compareTo);
t.blank().line("Keys under the default prefix \"chat-memory:\" (%d: one JSON document per message, plus a counter):", keys.size());
for (String key : keys) {
t.line(" %s", key.replaceAll("\\d{10,}", "<n>"));
}
Object doc = redis.jsonGet(keys.get(0));
List<String> fields = new ArrayList<>(((java.util.Map<String, Object>) doc).keySet());
fields.sort(String::compareTo);
t.blank().line("Fields of one document: %s", fields);
long ttl = redis.ttl(keys.getFirst());
t.line("TTL on each key (spring.ai.chat.memory.repository.redis.time-to-live=24h): between 86390 and 86400 s -> %s",
ttl > 86390 && ttl <= 86400);
assertThat(ttl).isBetween(86390L, 86400L);
t.blank().line("Same conversation, second call \"What is my name?\":");
t.line(" reply: %s", chat.prompt().user("What is my name?").advisors(a -> a.param(ChatMemory.CONVERSATION_ID, id)).call().content());
t.line(" stored: %s", Show.inline(memory.get(id)));
assertThat(memory.get(id)).hasSize(4);
assertThat(repository.findConversationIds()).containsExactly(id);
}
}
@Test
void twoWritersOnOneConversationLoseAMessageInRedisToo() throws Exception {
try (Transcript t = new Transcript("16-redis-lost-update.txt", "Two concurrent add() calls, Redis repository")) {
repository.saveAll("shared", List.of(new UserMessage("u0"), new AssistantMessage("a0")));
InterleavingRepository racy = new InterleavingRepository(repository, false);
MessageWindowChatMemory racyMemory = MessageWindowChatMemory.builder().chatMemoryRepository(racy).build();
Thread a = Thread.ofPlatform().start(() -> racyMemory.add("shared", new UserMessage("from-A")));
Thread b = Thread.ofPlatform().start(() -> racyMemory.add("shared", new UserMessage("from-B")));
a.join();
b.join();
List<String> stored = racyMemory.get("shared").stream().map(Message::getText).toList();
String last = racy.saveOrder.getLast();
t.line("seeded with u0, a0; two writers add one message each, both read before either writes back");
t.line("stored afterwards: %s (%d messages, 4 were sent)", stored.toString().replace(last, "<second writer's message>"), stored.size());
assertThat(stored).containsExactly("u0", "a0", last);
}
}
}
@@ -0,0 +1,48 @@
package com.ankurm.chatmemory;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.List;
import com.ankurm.chatmemory.memory.TokenBudgetChatMemory;
import com.ankurm.chatmemory.support.Show;
import com.ankurm.chatmemory.support.Transcript;
import com.knuddels.jtokkit.api.EncodingType;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.memory.InMemoryChatMemoryRepository;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.MessageType;
import org.springframework.ai.chat.messages.SystemMessage;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.tokenizer.JTokkitTokenCountEstimator;
import org.springframework.ai.tokenizer.TokenCountEstimator;
/** The custom token-budget memory: one long message costs what it costs, however few messages there are. */
class TokenBudgetChatMemoryTest {
private static final TokenCountEstimator ESTIMATOR = new JTokkitTokenCountEstimator(EncodingType.O200K_BASE);
@Test
void aLongMessageEvictsMoreHistoryThanAWindowOfTheSameCountWould() {
try (Transcript t = new Transcript("07-token-budget-memory.txt", "TokenBudgetChatMemory, budget = 120 tokens")) {
TokenBudgetChatMemory memory = new TokenBudgetChatMemory(new InMemoryChatMemoryRepository(), ESTIMATOR, 120);
memory.add("c", new SystemMessage("Be terse."));
for (int n = 1; n <= 3; n++) {
memory.add("c", List.of(new UserMessage("short question " + n), new AssistantMessage("short answer " + n)));
}
t.line("three short turns: %s", Show.inline(memory.get("c")));
String pasted = "java.lang.IllegalStateException: Failed to load ApplicationContext ".repeat(8);
memory.add("c", List.of(new UserMessage(pasted), new AssistantMessage("Check the datasource URL.")));
List<Message> after = memory.get("c");
t.line("then one pasted trace: %d messages kept: %s", after.size(),
Show.inline(after).replace(pasted, "<pasted stack trace, " + ESTIMATOR.estimate(pasted) + " tokens>"));
assertThat(after.getFirst().getMessageType()).isEqualTo(MessageType.SYSTEM);
assertThat(after.get(1).getMessageType()).isEqualTo(MessageType.USER);
assertThat(after.stream().mapToInt(m -> ESTIMATOR.estimate(m.getText())).sum()).isLessThanOrEqualTo(120);
assertThat(after.size()).isLessThan(9);
}
}
}
@@ -0,0 +1,109 @@
package com.ankurm.chatmemory;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Supplier;
import com.ankurm.chatmemory.memory.TokenBudgetChatMemory;
import com.ankurm.chatmemory.support.Transcript;
import com.knuddels.jtokkit.api.EncodingType;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.chat.memory.InMemoryChatMemoryRepository;
import org.springframework.ai.chat.memory.MessageWindowChatMemory;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.model.Generation;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.tokenizer.JTokkitTokenCountEstimator;
import org.springframework.ai.tokenizer.TokenCountEstimator;
import reactor.core.publisher.Flux;
/**
* The cost side of memory: how many input tokens each call carries as a conversation grows,
* under four pruning strategies. Token counts are JTokkit estimates with the o200k_base encoding
* (the family OpenAI's current models use), not a provider's invoice.
*/
class TokenGrowthTest {
private static final int TURNS = 30;
private static final TokenCountEstimator ESTIMATOR = new JTokkitTokenCountEstimator(EncodingType.O200K_BASE);
private static final String ANSWER = "The staging deploy failed because the readiness probe timed out before the new pods "
+ "finished warming their caches, so the rollout was halted and the previous ReplicaSet kept serving traffic.";
/** Answers with a fixed paragraph and records how many tokens each prompt carried. */
private static final class CountingModel implements ChatModel {
final List<Integer> promptTokens = new ArrayList<>();
@Override
public ChatResponse call(Prompt prompt) {
int total = 0;
for (Message m : prompt.getInstructions()) {
total += ESTIMATOR.estimate(m.getText());
}
promptTokens.add(total);
return new ChatResponse(List.of(new Generation(new AssistantMessage(ANSWER))));
}
@Override
public Flux<ChatResponse> stream(Prompt prompt) {
return Flux.just(call(prompt));
}
}
private static List<Integer> run(Supplier<ChatMemory> memoryFactory) {
CountingModel model = new CountingModel();
ChatMemory memory = memoryFactory.get();
ChatClient client = ChatClient.builder(model)
.defaultSystem("You are a terse assistant.")
.defaultAdvisors(MessageChatMemoryAdvisor.builder(memory).build())
.build();
for (int turn = 1; turn <= TURNS; turn++) {
client.prompt()
.user("Question " + turn + " about the pipeline: why did stage " + turn + " fail on the staging cluster last night?")
.advisors(a -> a.param(ChatMemory.CONVERSATION_ID, "c"))
.call().content();
}
return model.promptTokens;
}
private static MessageWindowChatMemory window(int max) {
return MessageWindowChatMemory.builder().chatMemoryRepository(new InMemoryChatMemoryRepository()).maxMessages(max).build();
}
@Test
void inputTokensPerCallGrowLinearlyUntilThePruningStrategyCapsThem() {
List<Integer> unbounded = run(() -> window(10_000));
List<Integer> w10 = run(() -> window(10));
List<Integer> w20 = run(() -> window(20));
List<Integer> budget = run(() -> new TokenBudgetChatMemory(new InMemoryChatMemoryRepository(), ESTIMATOR, 400));
try (Transcript t = new Transcript("06-token-growth.txt", "Input tokens per call, 30 turns, four pruning strategies")) {
t.line("Encoding: o200k_base (JTokkit estimate). Each turn: ~25-token question, ~40-token answer, ~6-token system prompt.");
t.blank().line("%-6s %10s %10s %10s %14s", "turn", "unbounded", "window=10", "window=20", "budget=400tok");
for (int turn : new int[] {1, 2, 5, 10, 11, 15, 20, 21, 25, 30}) {
t.line("%-6d %10d %10d %10d %14d", turn, unbounded.get(turn - 1), w10.get(turn - 1), w20.get(turn - 1), budget.get(turn - 1));
}
long su = unbounded.stream().mapToLong(Integer::longValue).sum();
long s10 = w10.stream().mapToLong(Integer::longValue).sum();
long s20 = w20.stream().mapToLong(Integer::longValue).sum();
long sb = budget.stream().mapToLong(Integer::longValue).sum();
t.blank().line("%-6s %10d %10d %10d %14d", "sum", su, s10, s20, sb);
t.line("sum as %% of unbounded: window=10 %.0f%%, window=20 %.0f%%, budget=400tok %.0f%%",
100.0 * s10 / su, 100.0 * s20 / su, 100.0 * sb / su);
assertThat(unbounded.get(29)).isGreaterThan(unbounded.get(0) * 20);
assertThat(w10.get(29)).isEqualTo(w10.get(15));
assertThat(w20.get(29)).isEqualTo(w20.get(25));
assertThat(budget.get(29)).isLessThanOrEqualTo(400 + 90);
assertThat(s10).isLessThan(su);
}
}
}
@@ -0,0 +1,74 @@
package com.ankurm.chatmemory;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.List;
import com.ankurm.chatmemory.support.ScriptedToolModel;
import com.ankurm.chatmemory.support.Show;
import com.ankurm.chatmemory.support.Transcript;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor;
import org.springframework.ai.chat.client.advisor.ToolCallingAdvisor;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.chat.memory.InMemoryChatMemoryRepository;
import org.springframework.ai.chat.memory.MessageWindowChatMemory;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.support.ToolCallbacks;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
/**
* Memory and tool calling together. The 2.0 advisor precedence puts the memory advisor
* (HIGHEST_PRECEDENCE + 200) outside the ToolCallingAdvisor (+ 300), so the memory sees one
* user message going in and one final answer coming out -- not the tool round trips between them.
*/
class ToolCallingMemoryTest {
static class Weather {
@Tool(description = "Current temperature for a city")
String currentWeather(@ToolParam(description = "city name") String city) {
return "31C in " + city;
}
}
private static ScriptedToolModel model() {
return ScriptedToolModel.builder()
.thenCallTools(new AssistantMessage.ToolCall("call-1", "function", "currentWeather", "{\"city\":\"Mumbai\"}"))
.thenRespond("It is 31C in Mumbai.")
.build();
}
@Test
void memoryOutsideTheToolLoopStoresOnlyTheQuestionAndTheFinalAnswer() {
try (Transcript t = new Transcript("24-memory-with-tool-calling.txt", "MessageChatMemoryAdvisor + ToolCallingAdvisor")) {
ScriptedToolModel model = model();
ChatMemory memory = MessageWindowChatMemory.builder().chatMemoryRepository(new InMemoryChatMemoryRepository()).build();
ChatClient client = ChatClient.builder(model)
.defaultToolCallbacks(ToolCallbacks.from(new Weather()))
.defaultAdvisors(ToolCallingAdvisor.builder().build(), MessageChatMemoryAdvisor.builder(memory).build())
.build();
t.line("Advisor.DEFAULT_CHAT_MEMORY_PRECEDENCE_ORDER = HIGHEST_PRECEDENCE + %d",
org.springframework.ai.chat.client.advisor.api.Advisor.DEFAULT_CHAT_MEMORY_PRECEDENCE_ORDER - org.springframework.core.Ordered.HIGHEST_PRECEDENCE);
t.line("ToolCallingAdvisor.DEFAULT_ORDER = HIGHEST_PRECEDENCE + %d",
ToolCallingAdvisor.DEFAULT_ORDER - org.springframework.core.Ordered.HIGHEST_PRECEDENCE);
t.blank();
String answer = client.prompt().user("Weather in Mumbai?")
.advisors(a -> a.param(ChatMemory.CONVERSATION_ID, "c")).call().content();
t.line("answer: %s", answer);
t.line("model calls: %d", model.callCount());
t.blank().line("What the model was sent on each call:");
for (int i = 0; i < model.capturedPrompts().size(); i++) {
t.line(" call %d: %s", i + 1, Show.inline(model.capturedPrompts().get(i).getInstructions()));
}
List<Message> stored = memory.get("c");
t.blank().line("Stored in memory afterwards:");
stored.forEach(m -> t.line(" %s", Show.one(m)));
assertThat(stored).hasSize(2);
assertThat(stored.get(1).getText()).isEqualTo("It is 31C in Mumbai.");
}
}
}
@@ -0,0 +1,62 @@
package com.ankurm.chatmemory;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.List;
import com.ankurm.chatmemory.support.Show;
import com.ankurm.chatmemory.support.Transcript;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.memory.InMemoryChatMemoryRepository;
import org.springframework.ai.chat.memory.MessageWindowChatMemory;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.SystemMessage;
import org.springframework.ai.chat.messages.UserMessage;
/**
* What {@code MessageWindowChatMemory} actually keeps, measured. No ChatClient, no advisor, no
* model: the memory object on its own, fed one turn at a time.
*/
class WindowSemanticsTest {
private static MessageWindowChatMemory window(int max) {
return MessageWindowChatMemory.builder()
.chatMemoryRepository(new InMemoryChatMemoryRepository())
.maxMessages(max)
.build();
}
private static void turn(MessageWindowChatMemory memory, int n) {
memory.add("c", List.of(new UserMessage("u" + n), new AssistantMessage("a" + n)));
}
@Test
void windowKeepsTheSystemMessageAndTrimsToAUserBoundary() {
try (Transcript t = new Transcript("01-window-semantics.txt", "MessageWindowChatMemory, maxMessages = 4 and 5")) {
MessageWindowChatMemory four = window(4);
four.add("c", new SystemMessage("SYS-1"));
t.line("maxMessages = 4. One system message, then one user+assistant turn at a time:");
for (int n = 1; n <= 4; n++) {
turn(four, n);
t.line(" after turn %d: %s", n, Show.inline(four.get("c")));
}
assertThat(Show.inline(four.get("c"))).isEqualTo("S:SYS-1 | U:u4 | A:a4");
four.add("c", new SystemMessage("SYS-2"));
t.blank().line("A second, different system message arrives:");
t.line(" after SYS-2: %s", Show.inline(four.get("c")));
assertThat(four.get("c")).noneMatch(m -> m.getText().equals("SYS-1"));
MessageWindowChatMemory five = window(5);
t.blank().line("maxMessages = 5 (odd), no system message:");
for (int n = 1; n <= 4; n++) {
turn(five, n);
t.line(" after turn %d: %s", n, Show.inline(five.get("c")));
}
List<Message> kept = five.get("c");
assertThat(kept).hasSize(4);
assertThat(kept.getFirst().getText()).isEqualTo("u3");
}
}
}
@@ -0,0 +1,46 @@
package com.ankurm.chatmemory.support;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/** A synchronous JDK HttpClient -- one send() per request, so a transcript line is one request. */
public final class Http {
public record Reply(int status, String body) {
}
private final HttpClient client = HttpClient.newHttpClient();
private final String base;
public Http(int port) {
this.base = "http://127.0.0.1:" + port;
}
public Reply post(String user, String path, String json) {
return send(HttpRequest.newBuilder(URI.create(base + path)).header("X-User", user)
.header("Content-Type", "application/json").POST(HttpRequest.BodyPublishers.ofString(json)).build());
}
public Reply get(String user, String path) {
return send(HttpRequest.newBuilder(URI.create(base + path)).header("X-User", user).GET().build());
}
private Reply send(HttpRequest request) {
try {
HttpResponse<String> r = client.send(request, HttpResponse.BodyHandlers.ofString());
return new Reply(r.statusCode(), r.body());
}
catch (Exception e) {
throw new IllegalStateException(e);
}
}
public static String field(String json, String name) {
Matcher m = Pattern.compile("\"" + name + "\":\"([^\"]*)\"").matcher(json);
return m.find() ? m.group(1) : null;
}
}
@@ -0,0 +1,87 @@
package com.ankurm.chatmemory.support;
import java.util.List;
import java.util.concurrent.BrokenBarrierException;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.CyclicBarrier;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicBoolean;
import org.springframework.ai.chat.memory.ChatMemoryRepository;
import org.springframework.ai.chat.messages.Message;
/**
* Holds every reader at a barrier until both have read, reproducing the interleaving a busy
* database produces by chance. {@code overlapWrites} additionally releases both writers into
* {@code saveAll} at the same instant; otherwise the second writer waits for the first to finish.
*/
public final class InterleavingRepository implements ChatMemoryRepository {
private final ChatMemoryRepository delegate;
private final boolean overlapWrites;
private final CyclicBarrier bothHaveRead = new CyclicBarrier(2);
private final CyclicBarrier bothAboutToWrite = new CyclicBarrier(2);
private final CountDownLatch firstWriterDone = new CountDownLatch(1);
private final AtomicBoolean firstWriter = new AtomicBoolean(true);
public final List<String> saveOrder = new CopyOnWriteArrayList<>();
public InterleavingRepository(ChatMemoryRepository delegate, boolean overlapWrites) {
this.delegate = delegate;
this.overlapWrites = overlapWrites;
}
@Override
public List<String> findConversationIds() {
return delegate.findConversationIds();
}
@Override
public List<Message> findByConversationId(String id) {
List<Message> read = delegate.findByConversationId(id);
await(bothHaveRead);
return read;
}
@Override
public void saveAll(String id, List<Message> messages) {
String writer = messages.getLast().getText();
if (overlapWrites) {
await(bothAboutToWrite);
delegate.saveAll(id, messages);
return;
}
if (firstWriter.compareAndSet(true, false)) {
saveOrder.add(writer);
delegate.saveAll(id, messages);
firstWriterDone.countDown();
}
else {
try {
firstWriterDone.await(1500, TimeUnit.MILLISECONDS);
}
catch (InterruptedException e) {
throw new IllegalStateException(e);
}
saveOrder.add(writer);
delegate.saveAll(id, messages);
}
}
@Override
public void deleteByConversationId(String id) {
delegate.deleteByConversationId(id);
}
private static void await(CyclicBarrier barrier) {
try {
barrier.await(1500, TimeUnit.MILLISECONDS);
}
catch (TimeoutException | BrokenBarrierException e) {
// the other writer is blocked behind a lock (or the barrier already gave up): carry on
}
catch (InterruptedException e) {
throw new IllegalStateException(e);
}
}
}
@@ -0,0 +1,75 @@
package com.ankurm.chatmemory.support;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.MessageType;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.model.Generation;
import org.springframework.ai.chat.prompt.Prompt;
import reactor.core.publisher.Flux;
/**
* A scripted {@link ChatModel}: no network, no API key, no randomness. It records every
* {@link Prompt} it is sent, which is the whole point -- what a memory test needs to prove is
* <em>which messages reached the model</em>, and that is exactly what a recorded prompt shows.
*
* <p>Its only "intelligence" is one rule that makes memory observable: if the last user message
* asks for the user's name, it looks for "my name is X" in the <em>earlier</em> messages of the
* same prompt and repeats X, or says it does not know. Nothing here says anything about what a
* real model would reply; it proves what the model would have been given.
*/
public class ScriptedChatModel implements ChatModel {
private static final Pattern NAME = Pattern.compile("(?i)my name is (\\w+)");
private final List<Prompt> prompts = new CopyOnWriteArrayList<>();
public List<Prompt> prompts() {
return Collections.unmodifiableList(prompts);
}
public Prompt lastPrompt() {
return prompts.getLast();
}
@Override
public ChatResponse call(Prompt prompt) {
prompts.add(prompt);
return new ChatResponse(List.of(new Generation(new AssistantMessage(reply(prompt)))));
}
/** {@code ChatModel#stream} does not delegate to {@code call}; it throws unless overridden. */
@Override
public Flux<ChatResponse> stream(Prompt prompt) {
return Flux.just(call(prompt));
}
private static String reply(Prompt prompt) {
List<Message> messages = prompt.getInstructions();
Message last = messages.getLast();
String text = last.getText();
Matcher self = NAME.matcher(text);
if (self.find()) {
return "Nice to meet you, " + self.group(1) + ".";
}
if (text.toLowerCase().contains("what is my name")) {
for (Message m : messages.subList(0, messages.size() - 1)) {
if (m.getMessageType() == MessageType.USER) {
Matcher earlier = NAME.matcher(m.getText());
if (earlier.find()) {
return "Your name is " + earlier.group(1) + ".";
}
}
}
return "I do not know your name yet.";
}
return "Noted.";
}
}
@@ -0,0 +1,117 @@
package com.ankurm.chatmemory.support;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.model.Generation;
import org.springframework.ai.chat.prompt.ChatOptions;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.model.tool.ToolCallingChatOptions;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.List;
import java.util.Queue;
import java.util.concurrent.CopyOnWriteArrayList;
/**
* A hand-written {@link ChatModel} that returns a pre-programmed queue of {@link ChatResponse}s,
* one per {@link #call(Prompt)} invocation, instead of calling a real LLM API. {@code ChatModel}
* has exactly one abstract method -- confirmed with {@code javap} against
* {@code spring-ai-model-2.0.1.jar}, everything else on the interface has a default implementation
* -- so this is the entire surface a deterministic test double needs to implement.
*
* <p>{@link org.springframework.ai.chat.client.advisor.ToolCallingAdvisor ToolCallingAdvisor} (and
* its subclass, {@code ToolSearchToolCallingAdvisor}) drive the tool-calling loop by calling the
* underlying {@link ChatModel} once per round: once to get the model's first response (which may
* contain tool calls), then once more per round of tool results fed back in, until a response
* comes back with no tool calls. Queuing responses here lets a test assert the exact shape of that
* loop -- how many rounds it took, what tool calls appeared, what the final answer was -- without
* an API key, network access, or the nondeterminism of an actual model.
*
* <p>Every {@link Prompt} the advisor sends is recorded in {@link #capturedPrompts()} so a test can
* also assert on what the advisor sent back on the next round -- in particular, that a
* {@code ToolResponseMessage} was appended after tool execution.
*/
public class ScriptedToolModel implements ChatModel {
private final Queue<ChatResponse> script;
private final List<Prompt> capturedPrompts = new CopyOnWriteArrayList<>();
private ScriptedToolModel(Deque<ChatResponse> script) {
this.script = script;
}
public static Builder builder() {
return new Builder();
}
@Override
public ChatResponse call(Prompt prompt) {
this.capturedPrompts.add(prompt);
ChatResponse next = this.script.poll();
if (next == null) {
throw new IllegalStateException(
"ScriptedChatModel ran out of queued responses after " + this.capturedPrompts.size()
+ " calls. Prompts so far: " + this.capturedPrompts);
}
return next;
}
public List<Prompt> capturedPrompts() {
return List.copyOf(this.capturedPrompts);
}
/**
* {@code DefaultChatClientUtils} builds every outgoing {@link Prompt}'s options from
* {@code chatModel.getOptions().mutate()} -- not {@code getDefaultOptions()}, which is a
* separate default method nobody in the request-building path actually calls. Confirmed by
* disassembling both: {@link ChatModel#getOptions()}'s default body is a bare
* {@code ChatOptions.builder().build()}, a plain {@link ChatOptions} that is not a
* {@link ToolCallingChatOptions}. Since {@code ToolCallingAdvisor.adviseCall} starts with an
* {@code instanceof ToolCallingChatOptions} check on that exact object and falls straight
* through to the underlying model with no tool loop at all when it fails, leaving this method's
* default in place silently turns every tool call in this repository into a no-op -- confirmed
* the hard way, by a first version of this class that overrode {@code getDefaultOptions()}
* instead and watched every test below get back an empty answer after exactly one model call.
* Real providers (OpenAI, Anthropic) return their own {@code ToolCallingChatOptions}
* implementation from {@code getOptions()} for the same reason.
*/
@Override
public ChatOptions getOptions() {
return ToolCallingChatOptions.builder().build();
}
public int callCount() {
return this.capturedPrompts.size();
}
public static final class Builder {
private final Deque<ChatResponse> script = new ArrayDeque<>();
private Builder() {
}
/** Queues a plain-text final answer with no tool calls -- ends the tool-calling loop. */
public Builder thenRespond(String text) {
this.script.add(new ChatResponse(List.of(new Generation(new AssistantMessage(text)))));
return this;
}
/** Queues an assistant turn that calls one or more tools, continuing the loop. */
public Builder thenCallTools(AssistantMessage.ToolCall... toolCalls) {
AssistantMessage message = AssistantMessage.builder()
.content("")
.toolCalls(List.of(toolCalls))
.build();
this.script.add(new ChatResponse(List.of(new Generation(message))));
return this;
}
public ScriptedToolModel build() {
return new ScriptedToolModel(new ArrayDeque<>(this.script));
}
}
}
@@ -0,0 +1,29 @@
package com.ankurm.chatmemory.support;
import java.net.InetSocketAddress;
import java.net.Socket;
import org.junit.jupiter.api.Assumptions;
/** Skips a test class (with a pointer to the script that fixes it) when a backing service is not running. */
public final class Services {
public static final int PG_PORT = 5440;
public static final int REDIS_STACK_PORT = 6390;
public static final int REDIS_PLAIN_PORT = 6391;
private Services() {
}
public static void require(String name, int port) {
boolean up;
try (Socket s = new Socket()) {
s.connect(new InetSocketAddress("127.0.0.1", port), 500);
up = true;
}
catch (Exception e) {
up = false;
}
Assumptions.assumeTrue(up, name + " is not listening on 127.0.0.1:" + port + " -- run scripts/services-up.sh first");
}
}
@@ -0,0 +1,31 @@
package com.ankurm.chatmemory.support;
import java.util.List;
import org.springframework.ai.chat.messages.Message;
/** One-line renderings of messages so transcripts stay short and diff cleanly. */
public final class Show {
private Show() {
}
public static String one(Message m) {
return String.format("%-9s %s", m.getMessageType(), m.getText());
}
public static String inline(List<Message> messages) {
return messages.stream().map(Show::brief).reduce((a, b) -> a + " | " + b).orElse("(empty)");
}
private static String brief(Message m) {
char kind = m.getMessageType().name().charAt(0);
if (m instanceof org.springframework.ai.chat.messages.AssistantMessage a && a.hasToolCalls()) {
return kind + ":[tool call " + a.getToolCalls().getFirst().name() + "]";
}
if (m instanceof org.springframework.ai.chat.messages.ToolResponseMessage r) {
return kind + ":[tool result " + r.getResponses().getFirst().responseData() + "]";
}
return kind + ":" + m.getText();
}
}
@@ -0,0 +1,14 @@
package com.ankurm.chatmemory.support;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
/** Puts one shared {@link ScriptedChatModel} in the context so a test can inspect what it was sent. */
@TestConfiguration(proxyBeanMethods = false)
public class TestModelConfig {
@Bean
ScriptedChatModel scriptedChatModel() {
return new ScriptedChatModel();
}
}
@@ -0,0 +1,47 @@
package com.ankurm.chatmemory.support;
import java.io.IOException;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.nio.file.Files;
import java.nio.file.Path;
/**
* Writes a numbered transcript under {@code output/} (repository root, not {@code docs/}) and
* echoes it to the console. Every console block quoted in the article comes out of one of these
* files verbatim.
*/
public final class Transcript implements AutoCloseable {
private final Path path;
private final StringWriter buffer = new StringWriter();
private final PrintWriter out = new PrintWriter(buffer);
public Transcript(String fileName, String title) {
this.path = Path.of("output", fileName);
out.println("# " + title);
out.println();
}
public Transcript line(String format, Object... args) {
out.println(args.length == 0 ? format : String.format(format, args));
return this;
}
public Transcript blank() {
out.println();
return this;
}
@Override
public void close() {
out.flush();
try {
Files.createDirectories(path.getParent());
Files.writeString(path, buffer.toString());
} catch (IOException e) {
throw new IllegalStateException("could not write " + path, e);
}
System.out.print(buffer);
}
}