package vip.mate.agent;
import org.junit.jupiter.api.Test;
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.ToolResponseMessage;
import org.springframework.ai.chat.messages.UserMessage;
import java.util.ArrayList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Head-side pair repair on the recent-message pagination cut.
*
*
{@code listRecentMessages(conversationId, windowSize)} returns the last N
* rows verbatim. The first row of that page can be a {@link ToolResponseMessage}
* whose owning {@link AssistantMessage} (carrying the matching tool_call_id)
* sat one row earlier — i.e. outside the page. Sending such a sequence to any
* OpenAI-compatible provider returns 400 because every tool response must be
* preceded by an assistant message issuing that tool_call_id.
*
*
{@link BaseAgent#stripHeadOrphanToolResponses} drops leading
* {@code ToolResponseMessage}s whose response ids are unmatched by every
* AssistantMessage still in scope. {@link SystemMessage}s (boundary rows,
* system prompts) at the head are skipped over, not removed.
*/
class BaseAgentHeadOrphanRepairTest {
@Test
void orphanToolResponseAtHeadIsDropped() {
// Window starts with a TOOL response (orphan: no AssistantMessage in this list issued call-X).
List messages = new ArrayList<>(List.of(
toolResponse("call-X"),
new UserMessage("next user turn"),
assistantWithToolCalls("call-Y"),
toolResponse("call-Y")
));
int dropped = BaseAgent.stripHeadOrphanToolResponses(messages, "test");
assertEquals(1, dropped, "leading orphan should be dropped");
assertInstanceOf(UserMessage.class, messages.getFirst(),
"head is now the user turn, not the orphan tool response");
}
@Test
void multipleConsecutiveOrphansAtHeadAllDropped() {
// A single AssistantMessage outside the window may have produced
// several tool calls whose responses landed in two separate
// ToolResponseMessages. Both should be removed.
List messages = new ArrayList<>(List.of(
toolResponse("call-A"),
toolResponse("call-B"),
new UserMessage("here we go"),
assistantWithToolCalls("call-C"),
toolResponse("call-C")
));
int dropped = BaseAgent.stripHeadOrphanToolResponses(messages, "test");
assertEquals(2, dropped);
assertInstanceOf(UserMessage.class, messages.getFirst());
}
@Test
void systemBoundaryAtHeadIsSkippedAndOrphanBehindItIsDropped() {
// After findLatestCompressionBoundary prepends a SystemMessage, the
// orphan tool response now sits at index 1. The repair must skip the
// system row and still drop the orphan.
SystemMessage boundary = new SystemMessage("[compression boundary placeholder]");
List messages = new ArrayList<>(List.of(
boundary,
toolResponse("call-X"),
new UserMessage("after orphan"),
assistantWithToolCalls("call-Y"),
toolResponse("call-Y")
));
int dropped = BaseAgent.stripHeadOrphanToolResponses(messages, "test");
assertEquals(1, dropped);
assertSame(boundary, messages.getFirst(),
"the system boundary stays in place");
assertInstanceOf(UserMessage.class, messages.get(1),
"the orphan that sat behind the boundary is gone");
}
@Test
void matchedHeadToolResponseIsKept() {
// The window happens to start with both the AssistantMessage and its
// tool response — perfectly aligned, nothing to drop.
List messages = new ArrayList<>(List.of(
assistantWithToolCalls("call-A"),
toolResponse("call-A"),
new UserMessage("next")
));
List snapshot = new ArrayList<>(messages);
int dropped = BaseAgent.stripHeadOrphanToolResponses(messages, "test");
assertEquals(0, dropped);
assertEquals(snapshot, messages, "no drops, list unchanged");
}
@Test
void partialOrphanIsKeptToSurfaceUpstreamBug() {
// A ToolResponseMessage with two responses — one orphan, one matched
// in scope. Real pipelines should never produce this (each
// ToolResponseMessage closes ONE assistant turn) but if it happens,
// dropping the whole message would also lose the matched response.
// Stop at the first non-orphan and let the upstream invariant
// violation surface in logs.
ToolResponseMessage mixed = ToolResponseMessage.builder().responses(List.of(
new ToolResponseMessage.ToolResponse("call-orphan", "tool_x", "x"),
new ToolResponseMessage.ToolResponse("call-known", "tool_y", "y")
)).build();
List messages = new ArrayList<>(List.of(
mixed,
assistantWithToolCalls("call-known"),
toolResponse("call-known")
));
int dropped = BaseAgent.stripHeadOrphanToolResponses(messages, "test");
assertEquals(0, dropped,
"mixed orphan/matched responses in one message are NOT dropped — "
+ "the partial-match case is an upstream bug we want to see in logs");
assertSame(mixed, messages.getFirst());
}
@Test
void emptyListIsNoOp() {
List messages = new ArrayList<>();
assertEquals(0, BaseAgent.stripHeadOrphanToolResponses(messages, "test"));
assertTrue(messages.isEmpty());
}
@Test
void purelyUserAssistantHistoryUntouched() {
// No tool responses at all — repair is a no-op.
List messages = new ArrayList<>(List.of(
new UserMessage("hi"),
new AssistantMessage("hello"),
new UserMessage("how are you?"),
new AssistantMessage("good")
));
List snapshot = new ArrayList<>(messages);
int dropped = BaseAgent.stripHeadOrphanToolResponses(messages, "test");
assertEquals(0, dropped);
assertEquals(snapshot, messages);
}
@Test
void stopsAtFirstNonOrphanNonSystem() {
// Once we hit a non-system, non-orphan message, repair stops — we do
// NOT keep walking and look for orphans deeper in the history.
// Deeper orphans imply an upstream bug; this guard is only here to
// protect the pagination cut.
List messages = new ArrayList<>(List.of(
toolResponse("call-A"), // orphan at head — will be dropped
new UserMessage("user"), // stops the scan
toolResponse("call-B"), // orphan but we do NOT touch it
new AssistantMessage("late")
));
int dropped = BaseAgent.stripHeadOrphanToolResponses(messages, "test");
assertEquals(1, dropped);
assertInstanceOf(UserMessage.class, messages.getFirst());
assertFalse(messages.stream().noneMatch(m -> m instanceof ToolResponseMessage),
"the deeper orphan stays in place — it surfaces as an upstream bug elsewhere");
}
// ------------------------------------------------------------------ helpers
private static AssistantMessage assistantWithToolCalls(String... callIds) {
List calls = new ArrayList<>();
for (String id : callIds) {
calls.add(new AssistantMessage.ToolCall(id, "function", "tool_" + id, "{}"));
}
return AssistantMessage.builder().content("").toolCalls(calls).build();
}
private static ToolResponseMessage toolResponse(String callId) {
return ToolResponseMessage.builder().responses(List.of(
new ToolResponseMessage.ToolResponse(callId, "tool_" + callId, "ok")
)).build();
}
}