mateclaw/mateclaw-server/src/test/java/vip/mate/agent/BaseAgentDirectToolHistoryScrubTest.java
matevip 0ffc224623 chore(test): backfill mateclaw-server/src/test/ that earlier PRIVATE_ITEMS pattern accidentally excluded
The PRIVATE_ITEMS list contained the bare 'test' entry, which rsync
interprets as 'any directory named test at any depth' — so it caught
the root-level /test/ scratch directory (intended) AND every src/test/
under each module (not intended).

Pattern is already anchored to /test (root-only). This commit rsyncs
the accumulated src/test/ tree forward so opensource has the unit tests
that have been written / updated against existing src/main/ code since
the pattern regression. Going forward each per-commit sync will carry
src/test/ files along with the main change.
2026-05-12 17:38:08 +08:00

151 lines
6.0 KiB
Java

package vip.mate.agent;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import vip.mate.workspace.conversation.model.MessageEntity;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
/**
* RFC-052 multi-turn leakage fix: verify that {@link BaseAgent#isDirectToolMessage}
* correctly identifies persisted assistant messages produced by a returnDirect
* tool path, so {@code toSpringMessage} replaces their content with a placeholder
* before the next turn's prompt is built.
*
* <p>The DB row stays unchanged; only the in-memory {@code AssistantMessage}
* handed to the model is scrubbed.
*/
class BaseAgentDirectToolHistoryScrubTest {
@Test
@DisplayName("metadata.directToolNames non-empty list => identified as direct-tool message")
void directToolNamesNonEmpty_recognized() {
MessageEntity msg = new MessageEntity();
msg.setRole("assistant");
msg.setContent("EMPLOYEE-SECRET-DATA");
msg.setMetadata("{\"segments\":[],\"directToolNames\":[\"query_employee_salary\"]}");
assertTrue(BaseAgent.isDirectToolMessage(msg),
"Assistant message with directToolNames must be flagged for scrubbing");
}
@Test
@DisplayName("metadata.directToolNames empty list => NOT treated as direct-tool")
void directToolNamesEmpty_notRecognized() {
MessageEntity msg = new MessageEntity();
msg.setRole("assistant");
msg.setContent("normal answer");
msg.setMetadata("{\"directToolNames\":[]}");
assertFalse(BaseAgent.isDirectToolMessage(msg),
"Empty directToolNames means no direct tool fired — don't scrub");
}
@Test
@DisplayName("metadata without directToolNames => not direct-tool")
void noDirectToolNamesField_notRecognized() {
MessageEntity msg = new MessageEntity();
msg.setRole("assistant");
msg.setContent("regular tool-call answer");
msg.setMetadata("{\"toolCalls\":[{\"name\":\"get_weather\"}]}");
assertFalse(BaseAgent.isDirectToolMessage(msg));
}
@Test
@DisplayName("null/empty metadata => not direct-tool")
void nullOrEmptyMetadata_notRecognized() {
MessageEntity msg = new MessageEntity();
msg.setRole("assistant");
msg.setContent("hi");
assertFalse(BaseAgent.isDirectToolMessage(msg), "null metadata");
msg.setMetadata("");
assertFalse(BaseAgent.isDirectToolMessage(msg), "empty metadata string");
msg.setMetadata("{}");
assertFalse(BaseAgent.isDirectToolMessage(msg), "empty JSON object");
}
@Test
@DisplayName("null entity => safely returns false")
void nullEntity_safe() {
assertFalse(BaseAgent.isDirectToolMessage(null));
}
// ========== OpenClaw-inspired optimization: tool-name-aware placeholder ==========
@Test
@DisplayName("directToolNamesIn extracts the array contents")
void extractToolNames_singleAndMultiple() {
MessageEntity single = new MessageEntity();
single.setRole("assistant");
single.setMetadata("{\"directToolNames\":[\"query_employee_salary\"]}");
assertEquals(List.of("query_employee_salary"),
BaseAgent.directToolNamesIn(single));
MessageEntity multi = new MessageEntity();
multi.setRole("assistant");
multi.setMetadata("{\"directToolNames\":[\"tool_a\",\"tool_b\",\"tool_c\"]}");
assertEquals(List.of("tool_a", "tool_b", "tool_c"),
BaseAgent.directToolNamesIn(multi));
}
@Test
@DisplayName("directToolNamesIn returns empty list for non-direct messages")
void extractToolNames_emptyForNonDirect() {
MessageEntity msg = new MessageEntity();
msg.setRole("assistant");
msg.setMetadata("{\"toolCalls\":[{\"name\":\"get_weather\"}]}");
assertTrue(BaseAgent.directToolNamesIn(msg).isEmpty());
}
@Test
@DisplayName("History placeholder names the tool so the model retains conversational structure")
void placeholder_singleTool_namesIt() {
String placeholder = BaseAgent.directToolHistoryPlaceholder(
List.of("query_employee_salary"));
assertTrue(placeholder.contains("query_employee_salary"),
"Single-tool placeholder must name the tool");
assertTrue(placeholder.contains("withheld"),
"Placeholder must signal the data is withheld");
assertTrue(placeholder.contains("call the tool again"),
"Placeholder must hint at the recovery path");
}
@Test
@DisplayName("Multi-tool placeholder lists every tool")
void placeholder_multipleTools_listAll() {
String placeholder = BaseAgent.directToolHistoryPlaceholder(
List.of("query_employee_salary", "read_medical_record"));
assertTrue(placeholder.contains("query_employee_salary"));
assertTrue(placeholder.contains("read_medical_record"));
}
@Test
@DisplayName("Empty/null tool name list falls back to a generic placeholder")
void placeholder_emptyList_genericFallback() {
String empty = BaseAgent.directToolHistoryPlaceholder(List.of());
String nullList = BaseAgent.directToolHistoryPlaceholder(null);
assertEquals(empty, nullList,
"Both null and empty must produce identical generic placeholders");
assertTrue(empty.contains("withheld"));
}
@Test
@DisplayName("Placeholder MUST NOT echo the original sensitive content")
void placeholder_neverContainsTheSensitivePayload() {
// Sanity: even if the metadata-extracted tool name happens to be
// sensitive-sounding, the placeholder is bounded — it doesn't re-emit
// the message content itself.
String placeholder = BaseAgent.directToolHistoryPlaceholder(
List.of("query_employee_salary"));
assertFalse(placeholder.contains("12345"));
assertFalse(placeholder.contains("SSN"));
assertFalse(placeholder.contains("PWD"));
}
}