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.
This commit is contained in:
matevip 2026-05-12 17:38:08 +08:00
parent ed788e9e42
commit 0ffc224623
243 changed files with 36469 additions and 140 deletions

View File

@ -645,28 +645,5 @@
</plugins>
</build>
</profile>
<!--
Profile: skip test files that don't compile against the current main
sources (pre-existing baseline drift unrelated to a given branch).
Activate with `mvn test -P skip-baseline-drift` when you need to run
a focused suite without first hand-fixing every stale test ctor.
-->
<profile>
<id>skip-baseline-drift</id>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<testExcludes>
<testExclude>vip/mate/llm/oauth/OpenAIOAuthServiceFlowModeTest.java</testExclude>
<testExclude>vip/mate/wiki/service/WikiEmbeddingCircuitBreakerTest.java</testExclude>
</testExcludes>
</configuration>
</plugin>
</plugins>
</build>
</profile>
</profiles>
</project>

View File

@ -0,0 +1,89 @@
package vip.mate.acp.client;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.DisabledOnOs;
import org.junit.jupiter.api.condition.OS;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.PosixFilePermission;
import java.util.List;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.*;
/**
* RFC-090 Phase 7 connection-test smoke for {@link AcpStdioClient}.
*
* <p>Runs a tiny shell-script "agent" that mimics the {@code initialize}
* handshake: reads one JSON-RPC request, replies with a matching id and
* the expected protocol version. Locks in:
* <ul>
* <li>spawn request response close all happen cleanly,</li>
* <li>protocolVersion is parsed from the result,</li>
* <li>the reader thread doesn't leak past close.</li>
* </ul>
*
* <p>POSIX-only: relies on {@code sh} + executable bit. Windows agents
* are exercised via the real CLI integration smoke (manual). The
* client itself is OS-neutral; the script harness is what's POSIXy.
*/
@DisabledOnOs(OS.WINDOWS)
class AcpStdioClientTest {
@Test
@DisplayName("initialize handshake completes against a scripted agent")
void initializeHandshake() throws Exception {
Path script = writeScriptedAgent();
try (AcpStdioClient client = AcpStdioClient.spawn(
new ObjectMapper(), "sh", List.of(script.toString()),
AcpStdioClient.emptyEnv(), null)) {
JsonNode result = client.initialize(5_000);
assertNotNull(result);
assertEquals(AcpStdioClient.PROTOCOL_VERSION,
result.path("protocolVersion").asInt());
} finally {
Files.deleteIfExists(script);
}
}
@Test
@DisplayName("spawn fails fast for a missing command")
void spawnFailsFastForMissingCommand() {
assertThrows(IOException.class, () ->
AcpStdioClient.spawn(new ObjectMapper(),
"/definitely/does/not/exist/acp-test-bin",
List.of(), AcpStdioClient.emptyEnv(), null));
}
/**
* Tiny shell-script agent: read one JSON-RPC line on stdin and
* write a response with a hard-coded result. Just enough surface
* to exercise the framing path.
*/
private Path writeScriptedAgent() throws IOException {
Path script = Files.createTempFile("acp-fake-agent-", ".sh");
String body = "" +
"#!/bin/sh\n" +
"read line\n" +
// Pull the id; assume integer id at this position.
"id=$(printf '%s' \"$line\" | sed -n 's/.*\"id\":\\([0-9]\\+\\).*/\\1/p')\n" +
"if [ -z \"$id\" ]; then id=1; fi\n" +
"printf '{\"jsonrpc\":\"2.0\",\"id\":%s,\"result\":{\"protocolVersion\":1,\"agentCapabilities\":{}}}\\n' \"$id\"\n";
Files.writeString(script, body, StandardCharsets.UTF_8);
try {
Files.setPosixFilePermissions(script, Set.of(
PosixFilePermission.OWNER_READ,
PosixFilePermission.OWNER_WRITE,
PosixFilePermission.OWNER_EXECUTE));
} catch (UnsupportedOperationException ignore) {
// Filesystem doesn't support POSIX perms sh ... still works.
}
return script;
}
}

View File

@ -0,0 +1,145 @@
package vip.mate.agent;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.TestPropertySource;
import vip.mate.MateClawApplication;
import vip.mate.agent.model.AgentEntity;
import vip.mate.exception.MateClawException;
import java.util.concurrent.atomic.AtomicLong;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
/**
* Pre-flight {@code (workspace_id, name)} uniqueness check that
* accompanies the V102 unique index.
*
* <p>The DB index alone would surface as {@code DataIntegrityViolation}
* with a vendor-specific message; the service-layer pre-check converts
* that to a stable {@code err.agent.duplicate_name} business code so the
* UI can show a localized message and clients can branch deterministically.
* These tests pin both the rejection paths and the false-positive guard
* (a same-name UPDATE on the row itself must not block).
*/
@SpringBootTest(
classes = MateClawApplication.class,
webEnvironment = SpringBootTest.WebEnvironment.NONE
)
@TestPropertySource(properties = {
"spring.datasource.url=jdbc:h2:mem:agent_unique_test_${random.uuid};MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE;DB_CLOSE_DELAY=-1",
"spring.ai.dashscope.api-key=test-key",
"spring.main.web-application-type=none"
})
class AgentServiceUniquenessTest {
/**
* Tests share one in-memory DB instance (a single
* {@code @SpringBootTest} class shares its application context across
* methods). Workspace ids are derived from this counter so concurrent
* methods can't trample one another's rows.
*/
private static final AtomicLong WS_SEQ = new AtomicLong(50_000L);
@Autowired
private AgentService agentService;
private long workspaceA;
private long workspaceB;
@BeforeEach
void setUp() {
workspaceA = WS_SEQ.getAndIncrement();
workspaceB = WS_SEQ.getAndIncrement();
}
private AgentEntity newAgent(String name, long workspaceId) {
AgentEntity a = new AgentEntity();
a.setName(name);
a.setDescription("uniqueness test agent");
a.setAgentType("react");
a.setSystemPrompt("");
a.setMaxIterations(10);
a.setWorkspaceId(workspaceId);
return a;
}
@Test
@DisplayName("createAgent 拒绝同 workspace 同名body code = 409 / msgKey = err.agent.duplicate_name")
void createRejectsDuplicateNameInSameWorkspace() {
agentService.createAgent(newAgent("Alpha", workspaceA));
MateClawException ex = assertThrows(MateClawException.class,
() -> agentService.createAgent(newAgent("Alpha", workspaceA)));
assertEquals(409, ex.getCode(), "应返回 409 业务码");
assertEquals("err.agent.duplicate_name", ex.getMsgKey());
}
@Test
@DisplayName("createAgent 允许不同 workspace 同名(隔离边界生效)")
void createAllowsSameNameInDifferentWorkspace() {
AgentEntity a = agentService.createAgent(newAgent("Bravo", workspaceA));
AgentEntity b = agentService.createAgent(newAgent("Bravo", workspaceB));
assertNotNull(a.getId());
assertNotNull(b.getId());
assertNotEquals(a.getId(), b.getId());
}
@Test
@DisplayName("createAgent 拒绝空名fail-fast 在 unique 检查之前)")
void createRejectsBlankName() {
AgentEntity blank = newAgent(null, workspaceA);
MateClawException ex = assertThrows(MateClawException.class,
() -> agentService.createAgent(blank));
assertEquals(400, ex.getCode());
assertEquals("err.agent.name_required", ex.getMsgKey());
}
@Test
@DisplayName("updateAgent 拒绝把名字改成 workspace 内已有的别人")
void updateRejectsRenamingToExistingName() {
AgentEntity first = agentService.createAgent(newAgent("Charlie", workspaceA));
AgentEntity second = agentService.createAgent(newAgent("Delta", workspaceA));
// Try renaming "Delta" "Charlie" inside the same workspace.
second.setName("Charlie");
MateClawException ex = assertThrows(MateClawException.class,
() -> agentService.updateAgent(second));
assertEquals(409, ex.getCode());
assertEquals("err.agent.duplicate_name", ex.getMsgKey());
// The other row must not have been touched.
assertEquals("Charlie", agentService.getAgent(first.getId()).getName());
}
@Test
@DisplayName("updateAgent 元数据修改不触发误报excludeId 跳过自己)")
void updateAllowsMetadataEditWithoutFalsePositive() {
AgentEntity created = agentService.createAgent(newAgent("Echo", workspaceA));
// Edit description only, keep the same name. The unique check
// should detect "no name change" and skip the SELECT entirely;
// even if it didn't, the excludeId branch would filter self out.
created.setDescription("edited");
assertDoesNotThrow(() -> agentService.updateAgent(created));
assertEquals("edited", agentService.getAgent(created.getId()).getDescription());
}
@Test
@DisplayName("updateAgent 改名为新值(不冲突)允许")
void updateAllowsRenameToUnusedName() {
AgentEntity created = agentService.createAgent(newAgent("Foxtrot", workspaceA));
created.setName("Foxtrot-renamed");
assertDoesNotThrow(() -> agentService.updateAgent(created));
assertEquals("Foxtrot-renamed",
agentService.getAgent(created.getId()).getName());
}
}

View File

@ -0,0 +1,123 @@
package vip.mate.agent;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.ai.support.ToolCallbacks;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import java.util.List;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* Regression test for <a href="https://github.com/matevip/mateclaw/issues/24">issue #24</a>.
* <p>
* Symptom: agent tool bindings persisted under the Java class name
* (e.g. {@code BrowserUseTool}, which is what {@code mate_tool.name} stores) or the
* Spring bean name (e.g. {@code browserUseTool}) had no effect at runtime, because the
* graph runtime matches against the {@code @Tool} function name (e.g. {@code browser_use}).
* <p>
* Fix: {@link AgentToolSet} builds an alias index so any of these three identifiers
* resolves to the same callback. This test pins that contract.
*/
class AgentToolSetTest {
/** Fixture: a bean exposing two {@code @Tool} methods, mirroring real tools like
* {@code BrowserUseTool} ({@code browser_use}, {@code browser_screenshot}, ...). */
static class FakeBrowserTool {
@Tool(description = "Open a URL in the browser")
public String browser_use(@ToolParam(description = "url to open") String url) {
return "opened " + url;
}
@Tool(description = "Take a screenshot")
public String browser_screenshot() {
return "shot.png";
}
}
@Test
@DisplayName("Issue #24: class name and bean name resolve to the same callbacks as the @Tool function name")
void aliasIndex_resolvesClassNameAndBeanNameToFunctionCallbacks() {
FakeBrowserTool bean = new FakeBrowserTool();
List<ToolCallback> callbacks = List.of(ToolCallbacks.from(bean));
assertEquals(2, callbacks.size(), "fixture should expose 2 @Tool methods");
AgentToolSet base = AgentToolSet.fromCallbacks(
List.of(bean),
callbacks,
b -> "fakeBrowserTool" // simulate Spring bean-name lookup
);
assertEquals(2, base.size());
// (A) Function name the historically-correct form
AgentToolSet byFn = base.withAllowedToolsOnly(Set.of("browser_use"));
assertEquals(1, byFn.size());
assertEquals("browser_use", byFn.callbacks().get(0).getToolDefinition().name());
// (B) Spring bean name expands to ALL @Tool methods on that bean
AgentToolSet byBean = base.withAllowedToolsOnly(Set.of("fakeBrowserTool"));
assertEquals(2, byBean.size(),
"bean name should pull in every @Tool method on the class");
// (C) Java class simple name (this is what mate_tool.name actually stores
// e.g. 'BrowserUseTool' and what the legacy bug saved into mate_agent_tool.tool_name)
AgentToolSet byClass = base.withAllowedToolsOnly(Set.of("FakeBrowserTool"));
assertEquals(2, byClass.size(),
"class simple name should expand to all bean methods (this is the issue #24 fix)");
// (D) Mixed: known + unknown aliases. Unknowns are silently dropped callers persist
// stale data and we'd rather degrade gracefully than throw.
AgentToolSet mixed = base.withAllowedToolsOnly(Set.of("FakeBrowserTool", "nonexistent_tool"));
assertEquals(2, mixed.size());
// (E) Empty allow-list yields empty tool set (NOT global default only null does that)
AgentToolSet none = base.withAllowedToolsOnly(Set.of());
assertEquals(0, none.size());
// (F) null = no per-agent binding fall back to global default (every tool visible)
AgentToolSet allDefault = base.withAllowedToolsOnly(null);
assertEquals(2, allDefault.size());
}
@Test
@DisplayName("withDeniedToolsFiltered accepts function / bean / class names interchangeably")
void deniedAliases_areToleranceOfNamingConvention() {
FakeBrowserTool bean = new FakeBrowserTool();
List<ToolCallback> callbacks = List.of(ToolCallbacks.from(bean));
AgentToolSet base = AgentToolSet.fromCallbacks(
List.of(bean),
callbacks,
b -> "fakeBrowserTool"
);
// Deny by class name: removes both @Tool methods on that class
AgentToolSet none = base.withDeniedToolsFiltered(Set.of("FakeBrowserTool"));
assertEquals(0, none.size());
// Deny by single function name: only that method drops
AgentToolSet justOne = base.withDeniedToolsFiltered(Set.of("browser_screenshot"));
assertEquals(1, justOne.size());
assertEquals("browser_use", justOne.callbacks().get(0).getToolDefinition().name());
}
@Test
@DisplayName("Two-arg fromCallbacks (no bean-name resolver): function name + class simple name still indexed (Spring bean name is not)")
void twoArgFactory_indexesByFunctionNameAndClassName() {
FakeBrowserTool bean = new FakeBrowserTool();
List<ToolCallback> callbacks = List.of(ToolCallbacks.from(bean));
AgentToolSet noResolver = AgentToolSet.fromCallbacks(List.of(bean), callbacks);
// Function name resolves
assertEquals(1, noResolver.withAllowedToolsOnly(Set.of("browser_use")).size());
// Class simple name resolves too derived from bean.getClass() reflection, no resolver needed
assertEquals(2, noResolver.withAllowedToolsOnly(Set.of("FakeBrowserTool")).size());
// Spring bean name does NOT resolve without a resolver (no source for it)
assertEquals(0, noResolver.withAllowedToolsOnly(Set.of("fakeBrowserTool")).size());
}
}

View File

@ -0,0 +1,139 @@
package vip.mate.agent;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
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.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* RFC-049 PR-2: {@link AssistantThinkingRelay} RelayEntry carries both
* per-assistant thinking and the caller's original {@code user} field, so the
* consumer can restore it when rebuilding the outbound request.
*/
class AssistantThinkingRelayTest {
@BeforeEach
void clear() {
AssistantThinkingRelay.clearAll();
}
@AfterEach
void tearDown() {
AssistantThinkingRelay.clearAll();
}
@Test
@DisplayName("stash returns token with expected prefix")
void stash_returnsTokenWithPrefix() {
String token = AssistantThinkingRelay.stash(List.of("thinking-a"), null);
assertTrue(AssistantThinkingRelay.isToken(token));
assertTrue(token.startsWith(AssistantThinkingRelay.TOKEN_PREFIX));
}
@Test
@DisplayName("stash + take roundtrips thinkings in order and originalUser")
void stashTake_roundtrip() {
List<String> thinkings = List.of("one", "", "three");
String token = AssistantThinkingRelay.stash(thinkings, "caller-user-42");
AssistantThinkingRelay.RelayEntry entry = AssistantThinkingRelay.take(token);
assertNotNull(entry);
assertEquals(List.of("one", "", "three"), entry.thinkings());
assertEquals("caller-user-42", entry.originalUser());
}
@Test
@DisplayName("stash + take with null originalUser preserves null")
void stashTake_nullOriginalUser() {
String token = AssistantThinkingRelay.stash(List.of("x"), null);
AssistantThinkingRelay.RelayEntry entry = AssistantThinkingRelay.take(token);
assertNotNull(entry);
assertNull(entry.originalUser());
}
@Test
@DisplayName("take removes entry — subsequent take returns null")
void take_removesEntry() {
String token = AssistantThinkingRelay.stash(List.of("x"), "u");
assertNotNull(AssistantThinkingRelay.take(token));
assertNull(AssistantThinkingRelay.take(token));
}
@Test
@DisplayName("take on non-token user returns null")
void take_onNonToken_returnsNull() {
assertNull(AssistantThinkingRelay.take(null));
assertNull(AssistantThinkingRelay.take(""));
assertNull(AssistantThinkingRelay.take("some-real-user-id"));
}
@Test
@DisplayName("isToken: prefix-based detection")
void isToken_prefixDetection() {
assertFalse(AssistantThinkingRelay.isToken(null));
assertFalse(AssistantThinkingRelay.isToken(""));
assertFalse(AssistantThinkingRelay.isToken("regular-user"));
assertTrue(AssistantThinkingRelay.isToken(AssistantThinkingRelay.TOKEN_PREFIX + "anything"));
}
@Test
@DisplayName("discard after take is a no-op (idempotent)")
void discard_idempotent() {
String token = AssistantThinkingRelay.stash(List.of("x"), "u");
AssistantThinkingRelay.take(token);
// Should not throw and not affect other entries
AssistantThinkingRelay.discard(token);
assertEquals(0, AssistantThinkingRelay.size());
}
@Test
@DisplayName("discard without take removes the entry (producer failure path)")
void discard_withoutTake_removes() {
String token = AssistantThinkingRelay.stash(List.of("x"), "u");
assertEquals(1, AssistantThinkingRelay.size());
AssistantThinkingRelay.discard(token);
assertEquals(0, AssistantThinkingRelay.size());
// Subsequent take still returns null
assertNull(AssistantThinkingRelay.take(token));
}
@Test
@DisplayName("concurrent stashes produce distinct tokens")
void stash_distinctTokens() {
String a = AssistantThinkingRelay.stash(List.of("a"), "ua");
String b = AssistantThinkingRelay.stash(List.of("b"), "ub");
assertNotEquals(a, b);
AssistantThinkingRelay.RelayEntry ea = AssistantThinkingRelay.take(a);
AssistantThinkingRelay.RelayEntry eb = AssistantThinkingRelay.take(b);
assertEquals(List.of("a"), ea.thinkings());
assertEquals("ua", ea.originalUser());
assertEquals(List.of("b"), eb.thinkings());
assertEquals("ub", eb.originalUser());
}
@Test
@DisplayName("RelayEntry.thinkings is immutable (defensive copy)")
void relayEntry_thinkingsImmutable() {
java.util.ArrayList<String> mutable = new java.util.ArrayList<>(List.of("a", "b"));
String token = AssistantThinkingRelay.stash(mutable, "u");
mutable.set(0, "mutated"); // should not affect the stashed copy
AssistantThinkingRelay.RelayEntry entry = AssistantThinkingRelay.take(token);
assertEquals(List.of("a", "b"), entry.thinkings());
// thinkings returned is also unmodifiable
assertThrows(UnsupportedOperationException.class,
() -> entry.thinkings().set(0, "x"));
}
}

View File

@ -0,0 +1,76 @@
package vip.mate.agent;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import vip.mate.workspace.conversation.model.MessageEntity;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Closure regression test for RFC-067 PR 7.
* <p>
* PR 1 §4.1.5 flips {@code MessageEntity.status} from {@code awaiting_approval}
* to {@code completed} (approve) or {@code stopped} (deny) inside
* {@link vip.mate.workspace.conversation.ConversationService#markPendingApprovalsResolved}.
* That status flip travels into history sanitization on subsequent LLM turns;
* if any sanitizer stage accidentally treated the post-flip status as a stub
* marker, the original assistant content would be dropped from history and the
* user-visible conversation would lose context after every approval.
* <p>
* These tests pin the boundary: only the explicit {@code [等待审批]} content
* placeholder is dropped by stage 1; a message that carries real streamed text
* + {@code status=awaiting_approval | completed | stopped} is preserved exactly
* regardless of where in the approval lifecycle it sits.
*/
class BaseAgentApprovalSanitizationTest {
@Test
@DisplayName("Real assistant content with status=awaiting_approval is NOT a Stage 1 placeholder")
void realContentDuringAwaiting() {
// Common shape: streamed partial answer + tool_approval_requested mid-flight,
// doOnComplete persists with status=awaiting_approval (PR 5).
MessageEntity msg = entity("我准备读取你的简历文件。", "awaiting_approval");
assertFalse(BaseAgent.isApprovalPlaceholder(msg.getContent()),
"real text must not match the placeholder regex — sanitizer would drop it otherwise");
}
@Test
@DisplayName("Post-approve message (status=completed, real content) is NOT a placeholder")
void postApproveMessageSurvives() {
// After PR 1 §4.1.5 reconciles approval: status flips awaiting_approval completed,
// metadata.pendingApproval.status flips pending_approval approved, content is unchanged.
MessageEntity msg = entity("已读取简历,关键信息: ...", "completed");
assertFalse(BaseAgent.isApprovalPlaceholder(msg.getContent()),
"approved-and-completed history entry must survive sanitization for the next LLM turn");
}
@Test
@DisplayName("Post-deny message (status=stopped, real content) is NOT a placeholder")
void postDenyMessageSurvives() {
// Deny path: status flips awaiting_approval stopped, content stays as the
// partial assistant text. The LLM should still see this on the next turn so
// it understands "I started reading then was denied" rather than amnesia.
MessageEntity msg = entity("用户拒绝执行工具 write_file", "stopped");
assertFalse(BaseAgent.isApprovalPlaceholder(msg.getContent()),
"denied turn's assistant text must survive history sanitization");
}
@Test
@DisplayName("Pure placeholder content IS dropped (Stage 1's actual job)")
void placeholderStubIsFiltered() {
// The "[等待审批]" stub is the historical placeholder format that Stage 1 catches
// those rows have no streamed content and add no value to the LLM context.
MessageEntity msg = entity("[等待审批]", "awaiting_approval");
assertTrue(BaseAgent.isApprovalPlaceholder(msg.getContent()),
"stub-only placeholder content must still match so Stage 1 keeps filtering it");
}
private static MessageEntity entity(String content, String status) {
MessageEntity m = new MessageEntity();
m.setRole("assistant");
m.setContent(content);
m.setStatus(status);
return m;
}
}

View File

@ -0,0 +1,150 @@
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"));
}
}

View File

@ -0,0 +1,192 @@
package vip.mate.agent;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.messages.UserMessage;
import vip.mate.llm.service.ModelCapabilityService;
import vip.mate.workspace.conversation.ConversationService;
import vip.mate.workspace.conversation.model.MessageContentPart;
import vip.mate.workspace.conversation.model.MessageEntity;
import java.util.EnumSet;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* Issue #44 regression: when a video attachment cannot be passed to the model
* (because the agent's resolved {@link ModelCapabilityService.Modality#VIDEO}
* capability is absent), the user message must include a system notice listing
* the skipped attachment and instructing the agent NOT to invent a tool call to
* read it.
*
* <p>The original bug: silent skip ({@code log.debug} only) agent saw
* {@code [附件] xxx.mp4} placeholder text in history but no actual media it
* picked {@code BrowserUseTool} or similar to "open" the file, which never
* produced useful results.
*
* <p>These tests pin the contract that the skip path mutates the prompt text,
* not just a log line.
*
* <p>Issue #87 update: the previous "禁止调用任何工具" sentence is no longer
* emitted unconditionally when the agent has any media-capable tool bound,
* the LLM is allowed to delegate to it. With no tools (this test scaffold's
* default), the notice falls back to a "switch models" suggestion only.
*/
class BaseAgentMultimodalSkipNoticeTest {
@Test
@DisplayName("Video attachment + model lacks VIDEO capability → skipped, system notice in prompt text")
void videoSkipped_emitsSystemNotice() {
TestAgent agent = newAgentWithCaps(EnumSet.noneOf(ModelCapabilityService.Modality.class));
MessageEntity msg = userMessage("看看这段视频");
when(agent.conversationService.parseMessageParts(msg))
.thenReturn(List.of(MessageContentPart.video("media-1", "demo.mp4")));
UserMessage result = agent.callBuildUserMessage(msg, "看看这段视频");
assertNotNull(result);
String text = result.getText();
assertTrue(text.contains("[系统提示]"),
"skipped video must surface a system notice in the prompt text — issue #44");
assertTrue(text.contains("demo.mp4"),
"notice must name the skipped file so the agent can tell the user");
assertTrue(text.contains("不支持视频输入"),
"reason string must name the modality the model cannot consume");
assertTrue(text.contains("建议切换"),
"notice must tell the agent to recommend switching models when no media tool is bound");
assertFalse(text.contains("不要调用任何工具"),
"issue #87: the hard tool ban must be gone — bound media tools should still be usable");
assertTrue(result.getMedia() == null || result.getMedia().isEmpty(),
"video must NOT be injected as Media when capability is absent");
}
@Test
@DisplayName("Image attachment + model lacks VISION capability → skipped, system notice")
void imageSkipped_emitsSystemNotice() {
// Regression for the "GLM-5-Turbo + image upload" failure: when a user
// uploads an image to a text-only model, we used to pass the image through
// anyway and let the API 400. Now we skip + notify, same as the video gate.
TestAgent agent = newAgentWithCaps(EnumSet.noneOf(ModelCapabilityService.Modality.class));
MessageEntity msg = userMessage("看看这张图");
when(agent.conversationService.parseMessageParts(msg))
.thenReturn(List.of(MessageContentPart.file("media-1", "poster.png", "image/png")));
UserMessage result = agent.callBuildUserMessage(msg, "看看这张图");
String text = result.getText();
assertTrue(text.contains("poster.png"));
assertTrue(text.contains("不支持图片输入"),
"vision-skip notice must use 不支持图片输入 wording");
assertTrue(result.getMedia() == null || result.getMedia().isEmpty(),
"image must NOT be injected when model has no VISION capability");
}
@Test
@DisplayName("No attachments → no system notice, prompt text unchanged")
void noAttachments_noNoticeAdded() {
TestAgent agent = newAgentWithCaps(EnumSet.noneOf(ModelCapabilityService.Modality.class));
MessageEntity msg = userMessage("hello");
when(agent.conversationService.parseMessageParts(msg)).thenReturn(List.of());
UserMessage result = agent.callBuildUserMessage(msg, "hello");
assertFalse(result.getText().contains("[系统提示]"),
"no skipped attachments → no notice; clean prompt for normal text-only turns");
}
@Test
@DisplayName("Capable model + video → no system notice (notice only fires on actual skip)")
void videoCapable_noNotice_attemptInjection() {
// VIDEO capability present no skip-on-capability-grounds. The injection itself
// may still fail downstream (file path doesn't exist in this test) when that
// happens, the file-not-found / load-failure branch surfaces its OWN notice with
// a different reason string. We assert the capability-skip reason is absent here.
TestAgent agent = newAgentWithCaps(
EnumSet.of(ModelCapabilityService.Modality.VIDEO, ModelCapabilityService.Modality.TEXT));
MessageEntity msg = userMessage("分析视频");
when(agent.conversationService.parseMessageParts(msg))
.thenReturn(List.of(MessageContentPart.video("media-1", "ok.mp4")));
UserMessage result = agent.callBuildUserMessage(msg, "分析视频");
assertFalse(result.getText().contains("不支持视频输入"),
"capable model must not be flagged as missing video capability");
}
@Test
@DisplayName("History replay (injectMedia=false) returns text-only — no Media accumulation")
void historyReplay_dropsMedia() {
// Regression for Zhipu GLM-5V "code:1210 input videos exceeds limit": each
// historical user message previously re-injected its video Media on every
// turn, so a 2-turn conversation hit the per-request 1-video cap. The
// history path must drop Media even when the model supports video.
TestAgent agent = newAgentWithCaps(
EnumSet.of(ModelCapabilityService.Modality.VIDEO, ModelCapabilityService.Modality.TEXT));
MessageEntity msg = userMessage("上一轮的视频");
// parseMessageParts is irrelevant when injectMedia=false; verify by NOT stubbing it.
UserMessage result = agent.callBuildUserMessage(msg, "上一轮的视频", false);
assertTrue(result.getMedia() == null || result.getMedia().isEmpty(),
"history replay must NOT carry Media — even capable models cap video count per request");
assertFalse(result.getText().contains("[系统提示]"),
"history replay must NOT add the skip notice — the skip notice is a current-turn concern");
}
// ---------- Test scaffold ----------
private static MessageEntity userMessage(String content) {
MessageEntity m = new MessageEntity();
m.setRole("user");
m.setContent(content);
return m;
}
private static TestAgent newAgentWithCaps(EnumSet<ModelCapabilityService.Modality> caps) {
ConversationService conv = mock(ConversationService.class);
TestAgent agent = new TestAgent(conv);
agent.modelCapabilities = caps;
agent.modelName = "test-model";
agent.agentName = "test-agent";
return agent;
}
/**
* Minimal concrete BaseAgent for testing buildUserMessage. The abstract
* chat / chatStream / execute methods are stubbed because buildUserMessage
* does not depend on them.
*/
static class TestAgent extends BaseAgent {
TestAgent(ConversationService conv) {
super(null, conv);
}
UserMessage callBuildUserMessage(MessageEntity msg, String renderedContent) {
return buildUserMessage(msg, renderedContent);
}
UserMessage callBuildUserMessage(MessageEntity msg, String renderedContent, boolean injectMedia) {
return buildUserMessage(msg, renderedContent, injectMedia);
}
@Override
public String chat(String userMessage, String conversationId) {
throw new UnsupportedOperationException();
}
@Override
public reactor.core.publisher.Flux<String> chatStream(String userMessage, String conversationId) {
throw new UnsupportedOperationException();
}
@Override
public String execute(String goal, String conversationId) {
throw new UnsupportedOperationException();
}
}
}

View File

@ -0,0 +1,78 @@
package vip.mate.agent;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
/**
* Field-shape tests for {@link GraphEventPublisher#iterationStart} and
* {@link GraphEventPublisher#iterationEnd}. Verifies the payload contract
* that downstream SSE consumers depend on (index / scope default / optional
* subagentId / char counters).
*/
class GraphEventPublisherIterationTest {
@Test
@DisplayName("iterationStart carries index, reason, scope, timestamp")
void iterationStartShape() {
GraphEventPublisher.GraphEvent event = GraphEventPublisher.iterationStart(
3, "react_step", "parent", null);
assertEquals(GraphEventPublisher.EVENT_ITERATION_START, event.type());
Map<String, Object> data = event.data();
assertEquals(3, data.get("index"));
assertEquals("react_step", data.get("reason"));
assertEquals("parent", data.get("scope"));
assertFalse(data.containsKey("subagentId"),
"subagentId must be absent when null/empty");
assertTrue(data.containsKey("timestamp"));
}
@Test
@DisplayName("iterationStart defaults missing scope to 'parent'")
void iterationStartDefaultsScope() {
GraphEventPublisher.GraphEvent event = GraphEventPublisher.iterationStart(
0, null, null, null);
Map<String, Object> data = event.data();
assertEquals("parent", data.get("scope"));
assertEquals("", data.get("reason"),
"Missing reason should serialize as empty string, not null");
}
@Test
@DisplayName("iterationStart includes subagentId when provided")
void iterationStartIncludesSubagentId() {
GraphEventPublisher.GraphEvent event = GraphEventPublisher.iterationStart(
7, "plan_step", "subagent", "sa-42");
Map<String, Object> data = event.data();
assertEquals("subagent", data.get("scope"));
assertEquals("sa-42", data.get("subagentId"));
}
@Test
@DisplayName("iterationEnd carries char counters and scope")
void iterationEndShape() {
GraphEventPublisher.GraphEvent event = GraphEventPublisher.iterationEnd(
5, "parent", null, 1234, 56);
assertEquals(GraphEventPublisher.EVENT_ITERATION_END, event.type());
Map<String, Object> data = event.data();
assertEquals(5, data.get("index"));
assertEquals("parent", data.get("scope"));
assertEquals(1234, data.get("contentChars"));
assertEquals(56, data.get("thinkingChars"));
assertFalse(data.containsKey("subagentId"));
}
@Test
@DisplayName("iterationEnd defaults scope to 'parent' when null")
void iterationEndDefaultsScope() {
GraphEventPublisher.GraphEvent event = GraphEventPublisher.iterationEnd(
0, null, "", 0, 0);
Map<String, Object> data = event.data();
assertEquals("parent", data.get("scope"));
assertFalse(data.containsKey("subagentId"),
"Empty subagentId must be omitted");
}
}

View File

@ -0,0 +1,430 @@
package vip.mate.agent;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.ai.openai.api.OpenAiApi;
import org.springframework.ai.openai.api.OpenAiApi.ChatCompletionMessage;
import org.springframework.ai.openai.api.OpenAiApi.ChatCompletionMessage.Role;
import org.springframework.ai.openai.api.OpenAiApi.ChatCompletionMessage.ToolCall;
import org.springframework.ai.openai.api.OpenAiApi.ChatCompletionRequest;
import vip.mate.llm.model.ModelProviderEntity;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotSame;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
/**
* RFC-049 PR-2 consumer-side tests for
* {@link AgentGraphBuilder#patchReasoningContent(ChatCompletionRequest, ModelProviderEntity)}.
*
* <p>Covers four orthogonal dimensions:
* <ul>
* <li>FallbackPolicy DEEPSEEK (null + warn + patchNonToolCall) vs KIMI / OPENAI /
* DEFAULT (" " + no-warn + tool-call-only)</li>
* <li>{@code lastUserIdx} scope assistants at {@code i <= lastUserIdx} never patched;
* iterator still advances for alignment</li>
* <li>sanitizedUser restored from {@code RelayEntry.originalUser}; relay token
* never egresses</li>
* <li>relay presence iterator consumed in order; missing relay triggers policy
* fallback only for in-turn messages</li>
* </ul>
*/
class PatchReasoningContentTest {
// ---------- Fixtures ----------
private static ModelProviderEntity provider(String id) {
ModelProviderEntity p = new ModelProviderEntity();
p.setProviderId(id);
return p;
}
private static ChatCompletionMessage user(String text) {
return new ChatCompletionMessage(text, Role.USER);
}
private static ChatCompletionMessage system(String text) {
return new ChatCompletionMessage(text, Role.SYSTEM);
}
/** Plain assistant message — no tool calls, no reasoning_content. */
private static ChatCompletionMessage assistantPlain(String text) {
return new ChatCompletionMessage(text, Role.ASSISTANT, null, null, null, null, null, null, null);
}
/** Assistant tool_call message with optional pre-existing reasoning_content. */
private static ChatCompletionMessage assistantToolCall(String text, String reasoningContent) {
ToolCall tc = new ToolCall("call_1", "function", null);
return new ChatCompletionMessage(text, Role.ASSISTANT, null, null, List.of(tc), null, null, null, reasoningContent);
}
/** Build a ChatCompletionRequest with the given messages + user field; all other fields null. */
private static ChatCompletionRequest request(List<ChatCompletionMessage> messages, String user) {
return new ChatCompletionRequest(
messages, // messages
"test-model", // model
null, null, null, null, null, null, null, null, null, null, null,
null, null, null, null, null, null, null, null, null, null,
null, null, // toolChoice, parallelToolCalls
user, // user
null, // reasoningEffort
null, null, null, null, null
);
}
@BeforeEach
void clearRelay() {
AssistantThinkingRelay.clearAll();
}
@AfterEach
void clearRelayAfter() {
AssistantThinkingRelay.clearAll();
}
// ---------- No-relay, no-thinking-mode path ----------
@Test
@DisplayName("No relay token + no thinking signals → request passes through unchanged")
void noop_whenNoRelayAndNoThinkingMode() {
ChatCompletionRequest req = request(List.of(
system("sys"),
user("q1"),
assistantToolCall("", null) // no reasoning_content anywhere not thinking mode
), "caller-user-1");
// model is "test-model" which maps to STANDARD family requiresReasoningContentPatch returns false
ChatCompletionRequest out = AgentGraphBuilder.patchReasoningContent(req, provider("deepseek"));
assertSame(req, out, "no thinking signal → no rebuild");
assertEquals("caller-user-1", out.user(), "user field untouched");
}
@Test
@DisplayName("Leaked relay token (no entry in map) + no thinking signals → strips token, rebuilds user")
void stripsLeakedToken_whenNoEntryNoThinking() {
// Prefix-shaped but not actually stashed simulates consumer running after
// producer's finally already discarded. take() returns null; isToken() still true.
String fakeToken = AssistantThinkingRelay.TOKEN_PREFIX + "orphan-uuid";
ChatCompletionRequest req = request(List.of(
user("q1"),
assistantPlain("hi")
), fakeToken);
ChatCompletionRequest out = AgentGraphBuilder.patchReasoningContent(req, provider("openai"));
assertNotSame(req, out, "rebuild expected to strip leaked token");
assertNull(out.user(), "leaked token must be sanitized to null");
}
// ---------- sanitizedUser restoration from RelayEntry ----------
@Test
@DisplayName("sanitizedUser is restored from RelayEntry.originalUser")
void sanitizedUser_restoredFromRelayEntry() {
List<String> thinkings = List.of("", "in-turn-think");
String token = AssistantThinkingRelay.stash(thinkings, "original-caller-42");
ChatCompletionRequest req = request(List.of(
assistantPlain("prior-assistant"), // i=0, position 0 in thinkings ""
user("q1"),
assistantToolCall("a1", null) // i=2, position 1 in thinkings "in-turn-think"
), token);
ChatCompletionRequest out = AgentGraphBuilder.patchReasoningContent(req, provider("deepseek"));
assertEquals("original-caller-42", out.user(), "sanitizedUser must equal entry.originalUser()");
assertEquals("in-turn-think", out.messages().get(2).reasoningContent(),
"in-turn assistant (i=2 > lastUserIdx=1) should receive the real thinking");
}
// ---------- lastUserIdx scope ----------
@Test
@DisplayName("DEEPSEEK patchCrossTurn=true: prior-turn assistants get ' ' fallback so multi-turn doesn't 400")
void crossTurnAssistants_patchedWithSpace_deepseek() {
// [sys, U1, A1(tool_call, no-rc), U2, A2(tool_call, no-rc)]
// lastUserIdx = 3 (U2)
// Relay thinkings: [null for A1, "real-a2" for A2]
//
// DeepSeek (since 2026-04) requires reasoning_content on EVERY assistant
// in the request prior-turn included. Without patchCrossTurn, A1 stays
// null and DeepSeek 400s on every multi-turn conversation. With it, A1
// gets the same " " fallback in-turn assistants get.
List<String> thinkings = Arrays.asList("", "real-a2");
String token = AssistantThinkingRelay.stash(thinkings, null);
ChatCompletionRequest req = request(List.of(
system("sys"),
user("q1"),
assistantToolCall("a1", null), // i=2, cross-turn (2 <= 3)
user("q2"),
assistantToolCall("a2", null) // i=4, in-turn (4 > 3)
), token);
ChatCompletionRequest out = AgentGraphBuilder.patchReasoningContent(req, provider("deepseek"));
assertEquals(" ", out.messages().get(2).reasoningContent(),
"cross-turn A1 gets ' ' fallback so DeepSeek thinking-mode validation passes");
assertEquals("real-a2", out.messages().get(4).reasoningContent(),
"in-turn A2 (i=4 > lastUserIdx=3) receives the real relay value");
}
@Test
@DisplayName("Iterator stays aligned: cross-turn consumes '' positions so in-turn gets correct thinking")
void iteratorAlignment_acrossCrossTurnAndInTurn() {
// [U1, A1(no-rc), A2(no-rc), U2, A3(no-rc), A4(no-rc)]
// lastUserIdx = 3 (U2). Producer extraction order = A1,A2,A3,A4.
// Relay: ["","" (cross-turn, stripped already), "real-a3", "real-a4"]
List<String> thinkings = List.of("", "", "real-a3", "real-a4");
String token = AssistantThinkingRelay.stash(thinkings, null);
ChatCompletionRequest req = request(List.of(
user("q1"),
assistantToolCall("a1", null), // i=1 cross-turn
assistantToolCall("a2", null), // i=2 cross-turn
user("q2"),
assistantToolCall("a3", null), // i=4 in-turn
assistantToolCall("a4", null) // i=5 in-turn
), token);
ChatCompletionRequest out = AgentGraphBuilder.patchReasoningContent(req, provider("deepseek"));
// DEEPSEEK patchCrossTurn=true: cross-turn now also gets ' ' fallback.
// Iterator alignment is preserved: A1/A2 consume the empty entries '',
// A3/A4 consume their real values in correct positions.
assertEquals(" ", out.messages().get(1).reasoningContent(), "A1 cross-turn ' ' fallback");
assertEquals(" ", out.messages().get(2).reasoningContent(), "A2 cross-turn ' ' fallback");
assertEquals("real-a3", out.messages().get(4).reasoningContent(), "A3 in-turn gets real-a3 (not real-a4)");
assertEquals("real-a4", out.messages().get(5).reasoningContent(), "A4 in-turn gets real-a4");
}
// ---------- FallbackPolicy × emptyFallback ----------
@Test
@DisplayName("DEEPSEEK policy: relay empty for in-turn tool_call → reasoning_content gets ' ' fallback")
void deepseek_relayEmpty_fallsBackToSpace() {
// 72bd33dc switched DEEPSEEK from emptyFallback=null (force explicit 400)
// to " " null kept self-replicating 400s every multi-tool turn that
// crossed a summarizing boundary. Aligning with KIMI/OPENAI tolerance.
List<String> thinkings = List.of(""); // one assistant, no real thinking
String token = AssistantThinkingRelay.stash(thinkings, null);
ChatCompletionRequest req = request(List.of(
user("q1"),
assistantToolCall("a1", null) // in-turn
), token);
ChatCompletionRequest out = AgentGraphBuilder.patchReasoningContent(req, provider("deepseek"));
assertEquals(" ", out.messages().get(1).reasoningContent(),
"DeepSeek: ' ' fallback restores forward progress when relay has no real value");
}
@Test
@DisplayName("KIMI policy: relay empty for in-turn tool_call → ' ' injected (legacy tolerance)")
void kimi_relayEmpty_injectsSpace() {
// Kimi path is triggered by the model-family check; use model name that maps to KIMI_THINKING.
List<String> thinkings = List.of("");
String token = AssistantThinkingRelay.stash(thinkings, null);
List<ChatCompletionMessage> msgs = List.of(
user("q1"),
assistantToolCall("a1", null)
);
ChatCompletionRequest req = new ChatCompletionRequest(
msgs, "kimi-k2.5", null, null, null, null, null, null, null, null, null, null, null,
null, null, null, null, null, null, null, null, null, null, null, null, token,
null, null, null, null, null, null
);
ChatCompletionRequest out = AgentGraphBuilder.patchReasoningContent(req, provider("kimi-cn"));
assertEquals(" ", out.messages().get(1).reasoningContent(),
"Kimi tolerates ' ' — preserve legacy behavior");
}
@Test
@DisplayName("Unknown provider uses DEFAULT policy: ' ' injected (legacy tolerance, not noop)")
void defaultPolicy_unknownProvider_injectsSpace() {
List<String> thinkings = List.of("");
String token = AssistantThinkingRelay.stash(thinkings, null);
// Use a model that triggers requiresReasoningContentPatch so thinking mode is active
List<ChatCompletionMessage> msgs = List.of(
user("q1"),
assistantToolCall("a1", null)
);
ChatCompletionRequest req = new ChatCompletionRequest(
msgs, "deepseek-reasoner", null, null, null, null, null, null, null, null, null, null, null,
null, null, null, null, null, null, null, null, null, null, null, null, token,
null, null, null, null, null, null
);
ChatCompletionRequest out = AgentGraphBuilder.patchReasoningContent(req, provider("custom-gateway"));
assertEquals(" ", out.messages().get(1).reasoningContent(),
"DEFAULT keeps legacy ' ' for unrecognized providers — avoid regressing self-hosted backends");
}
// ---------- FallbackPolicy × patchNonToolCall ----------
@Test
@DisplayName("DEEPSEEK policy patches non-tool_call in-turn assistants too (patchNonToolCall=true)")
void deepseek_patchesNonToolCallAssistant() {
List<String> thinkings = List.of("thinking-for-plain");
String token = AssistantThinkingRelay.stash(thinkings, null);
ChatCompletionRequest req = request(List.of(
user("q1"),
assistantPlain("plain answer") // no tool_calls
), token);
ChatCompletionRequest out = AgentGraphBuilder.patchReasoningContent(req, provider("deepseek"));
assertEquals("thinking-for-plain", out.messages().get(1).reasoningContent(),
"DeepSeek contract requires reasoning_content even on non-tool_call assistants when in thinking mode");
}
@Test
@DisplayName("KIMI policy leaves non-tool_call assistants alone (patchNonToolCall=false)")
void kimi_skipsNonToolCallAssistant() {
List<String> thinkings = List.of("would-not-be-used");
String token = AssistantThinkingRelay.stash(thinkings, null);
List<ChatCompletionMessage> msgs = List.of(
user("q1"),
assistantPlain("plain answer")
);
ChatCompletionRequest req = new ChatCompletionRequest(
msgs, "kimi-k2.5", null, null, null, null, null, null, null, null, null, null, null,
null, null, null, null, null, null, null, null, null, null, null, null, token,
null, null, null, null, null, null
);
ChatCompletionRequest out = AgentGraphBuilder.patchReasoningContent(req, provider("kimi-cn"));
assertNull(out.messages().get(1).reasoningContent(),
"Kimi only patches tool_call assistants; plain assistants are untouched");
}
// ---------- Preserve pre-existing real values ----------
@Test
@DisplayName("Assistant that already has real reasoning_content is left alone")
void existingRealValue_preserved() {
List<String> thinkings = List.of("would-overwrite");
String token = AssistantThinkingRelay.stash(thinkings, null);
ChatCompletionRequest req = request(List.of(
user("q1"),
assistantToolCall("a1", "pre-existing-real-thinking") // already has a value
), token);
ChatCompletionRequest out = AgentGraphBuilder.patchReasoningContent(req, provider("deepseek"));
assertEquals("pre-existing-real-thinking", out.messages().get(1).reasoningContent(),
"non-blank existing reasoning_content must not be overwritten by relay");
}
// ---------- Edge: empty messages ----------
@Test
@DisplayName("Empty messages list: no-op, returns same instance")
void emptyMessages_noop() {
ChatCompletionRequest req = request(List.of(), null);
assertSame(req, AgentGraphBuilder.patchReasoningContent(req, provider("deepseek")));
}
@Test
@DisplayName("Null messages: no-op, returns same instance")
void nullMessages_noop() {
ChatCompletionRequest req = new ChatCompletionRequest(
null, "m", null, null, null, null, null, null, null, null, null, null, null,
null, null, null, null, null, null, null, null, null, null, null, null, null,
null, null, null, null, null, null
);
assertSame(req, AgentGraphBuilder.patchReasoningContent(req, provider("deepseek")));
}
// ---------- Fewer relay entries than assistants: defensive policy fallback ----------
@Test
@DisplayName("Relay shorter than assistant count: extra in-turn assistants fall back to policy")
void relayShorterThanAssistants_fallsBack() {
// Producer extracted 1 entry but there are 2 in-turn tool_call assistants
// (e.g. one was added after relay stash shouldn't happen but be defensive).
List<String> thinkings = List.of("real-1");
String token = AssistantThinkingRelay.stash(thinkings, null);
ChatCompletionRequest req = request(new ArrayList<>(List.of(
user("q1"),
assistantToolCall("a1", null),
assistantToolCall("a2", null)
)), token);
ChatCompletionRequest out = AgentGraphBuilder.patchReasoningContent(req, provider("deepseek"));
assertEquals("real-1", out.messages().get(1).reasoningContent());
assertEquals(" ", out.messages().get(2).reasoningContent(),
"DEEPSEEK with emptyFallback=' ' (post-72bd33dc): missing real values get the same tolerant fallback");
}
// ---------- patchCrossTurn policy (2026-04-29) ----------
@Test
@DisplayName("KIMI / OPENAI / DEFAULT do NOT patch cross-turn — only DEEPSEEK does")
void crossTurnPatching_isDeepseekOnly() {
// Same shape as crossTurnAssistants_patchedWithSpace_deepseek but with
// KIMI provider KIMI's contract resets thinking across user turns,
// so prior-turn assistants must remain null. Pinning this here protects
// against accidentally flipping patchCrossTurn=true for all providers.
List<String> thinkings = Arrays.asList("", "real-a2");
String token = AssistantThinkingRelay.stash(thinkings, null);
ChatCompletionRequest req = request(List.of(
user("q1"),
assistantToolCall("a1", null), // i=1, cross-turn (1 <= 2)
user("q2"),
assistantToolCall("a2", null) // i=3, in-turn (3 > 2)
), token);
ChatCompletionRequest out = AgentGraphBuilder.patchReasoningContent(req, provider("kimi-cn"));
assertNull(out.messages().get(1).reasoningContent(),
"KIMI does not patch cross-turn — thinking resets across user turns");
assertEquals("real-a2", out.messages().get(3).reasoningContent(),
"KIMI in-turn assistants still receive their relay value");
}
@Test
@DisplayName("DEEPSEEK cross-turn assistant without tool_calls also patched (patchNonToolCall=true)")
void crossTurnPlainAssistant_patchedForDeepseek() {
// Plain prior-turn text assistant (no tool_calls): without the
// patchNonToolCall guard, this would still be skipped. DEEPSEEK has
// both patchNonToolCall=true AND patchCrossTurn=true, so it should
// get the ' ' fallback. This is the most common production case
// since plain assistants dominate IM channel history.
List<String> thinkings = Arrays.asList("", "");
String token = AssistantThinkingRelay.stash(thinkings, null);
ChatCompletionRequest req = request(List.of(
user("q1"),
new ChatCompletionMessage("plain a1", Role.ASSISTANT), // no tool_calls
user("q2"),
new ChatCompletionMessage("plain a2", Role.ASSISTANT)
), token);
ChatCompletionRequest out = AgentGraphBuilder.patchReasoningContent(req, provider("deepseek"));
assertEquals(" ", out.messages().get(1).reasoningContent(),
"DEEPSEEK plain prior-turn assistant gets ' ' so request validates");
assertEquals(" ", out.messages().get(3).reasoningContent(),
"DEEPSEEK plain in-turn assistant gets ' ' as before");
}
}

View File

@ -0,0 +1,212 @@
package vip.mate.agent;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.ai.openai.api.OpenAiApi;
import vip.mate.llm.model.ModelProviderEntity;
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.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* RFC-049 PR-1.3 verification covers §5.2 Case E3.1 / E3.2 / E3.3 plus the
* whitelist positive path.
*
* <p>The sanitizer is provider-first with default-deny: only providerId in
* {@code {openai, azure-openai}} is allowed to carry {@code reasoning_effort}.
* All other providers (including unknown ones) must strip regardless of what
* {@code request.model()} says, because the model name may have leaked from a
* failover primary.
*/
class ReasoningEffortSanitizerTest {
private static ModelProviderEntity provider(String id) {
ModelProviderEntity p = new ModelProviderEntity();
p.setProviderId(id);
return p;
}
/**
* Construct a minimal {@link OpenAiApi.ChatCompletionRequest} via its record canonical
* constructor with only {@code messages}, {@code model}, and {@code reasoningEffort} set
* everything else is null. {@code ChatCompletionRequest} in Spring AI 1.1.4 has no
* public builder.
*/
private static OpenAiApi.ChatCompletionRequest request(String model, String reasoningEffort) {
return new OpenAiApi.ChatCompletionRequest(
List.of(), // messages
model, // model
null, // store
null, // metadata
null, // frequencyPenalty
null, // logitBias
null, // logprobs
null, // topLogprobs
null, // maxTokens
null, // maxCompletionTokens
null, // n
null, // outputModalities
null, // audioParameters
null, // presencePenalty
null, // responseFormat
null, // seed
null, // serviceTier
null, // stop
null, // stream
null, // streamOptions
null, // temperature
null, // topP
null, // tools
null, // toolChoice
null, // parallelToolCalls
null, // user
reasoningEffort, // reasoningEffort
null, // webSearchOptions
null, // verbosity
null, // promptCacheKey
null, // safetyIdentifier
null // extraBody
);
}
@Test
@DisplayName("Whitelist: openai is allowed")
void whitelist_openai() {
assertTrue(AgentGraphBuilder.isReasoningEffortWhitelistedProvider(provider("openai")));
}
@Test
@DisplayName("Whitelist: azure-openai is allowed")
void whitelist_azureOpenai() {
assertTrue(AgentGraphBuilder.isReasoningEffortWhitelistedProvider(provider("azure-openai")));
}
@Test
@DisplayName("Whitelist: case-insensitive (Azure-OpenAI)")
void whitelist_caseInsensitive() {
assertTrue(AgentGraphBuilder.isReasoningEffortWhitelistedProvider(provider("Azure-OpenAI")));
}
@Test
@DisplayName("Whitelist: deepseek is denied")
void denylist_deepseek() {
assertFalse(AgentGraphBuilder.isReasoningEffortWhitelistedProvider(provider("deepseek")));
}
@Test
@DisplayName("Whitelist: kimi family denied")
void denylist_kimi() {
assertFalse(AgentGraphBuilder.isReasoningEffortWhitelistedProvider(provider("kimi-cn")));
assertFalse(AgentGraphBuilder.isReasoningEffortWhitelistedProvider(provider("kimi-intl")));
assertFalse(AgentGraphBuilder.isReasoningEffortWhitelistedProvider(provider("kimi-code")));
}
@Test
@DisplayName("Whitelist: dashscope / ollama / anthropic denied")
void denylist_misc() {
assertFalse(AgentGraphBuilder.isReasoningEffortWhitelistedProvider(provider("dashscope")));
assertFalse(AgentGraphBuilder.isReasoningEffortWhitelistedProvider(provider("ollama")));
assertFalse(AgentGraphBuilder.isReasoningEffortWhitelistedProvider(provider("anthropic")));
}
@Test
@DisplayName("Whitelist: unknown providerId denied (default-deny — §5.2 Case E3.3)")
void denylist_unknownProvider() {
// This is the critical regression guard: if anyone re-adds a default-allow
// branch to isReasoningEffortWhitelistedProvider, this case fails first.
assertFalse(AgentGraphBuilder.isReasoningEffortWhitelistedProvider(
provider("my-custom-openai-compat-gateway")));
assertFalse(AgentGraphBuilder.isReasoningEffortWhitelistedProvider(
provider("openrouter")));
assertFalse(AgentGraphBuilder.isReasoningEffortWhitelistedProvider(
provider("together")));
}
@Test
@DisplayName("Whitelist: null provider / null providerId denied")
void denylist_nulls() {
assertFalse(AgentGraphBuilder.isReasoningEffortWhitelistedProvider(null));
assertFalse(AgentGraphBuilder.isReasoningEffortWhitelistedProvider(new ModelProviderEntity()));
}
// ---------- sanitizeReasoningEffortForProvider ----------
@Test
@DisplayName("Sanitize no-op: request has no reasoning_effort")
void sanitize_noop_noReasoningEffort() {
OpenAiApi.ChatCompletionRequest req = request("gpt-5", null);
OpenAiApi.ChatCompletionRequest out = AgentGraphBuilder.sanitizeReasoningEffortForProvider(req, provider("deepseek"));
assertSame(req, out, "should return same instance when reasoning_effort is already null");
}
@Test
@DisplayName("§5.2 Case E3.1: primary=gpt-5 → fallback=deepseek strips reasoning_effort")
void sanitize_failover_deepseek_strips() {
// Simulate failover: OpenAiChatOptions.model still leaked as "gpt-5" on the deepseek request.
OpenAiApi.ChatCompletionRequest req = request("gpt-5", "high");
OpenAiApi.ChatCompletionRequest out = AgentGraphBuilder.sanitizeReasoningEffortForProvider(req, provider("deepseek"));
assertNull(out.reasoningEffort(), "deepseek is not on the whitelist — strip regardless of model name");
// Other fields preserved
assertEquals("gpt-5", out.model());
}
@Test
@DisplayName("§5.2 Case E3.2: kimi / dashscope / ollama also strip")
void sanitize_failover_otherDenied_strips() {
for (String pid : List.of("kimi-cn", "kimi-intl", "kimi-code", "dashscope", "ollama", "anthropic")) {
OpenAiApi.ChatCompletionRequest req = request("gpt-5", "medium");
OpenAiApi.ChatCompletionRequest out = AgentGraphBuilder.sanitizeReasoningEffortForProvider(req, provider(pid));
assertNull(out.reasoningEffort(), "provider=" + pid + " must strip");
}
}
@Test
@DisplayName("§5.2 Case E3.3: unknown provider strips (default-deny regression guard)")
void sanitize_unknownProvider_strips() {
OpenAiApi.ChatCompletionRequest req = request("gpt-5", "high");
OpenAiApi.ChatCompletionRequest out = AgentGraphBuilder.sanitizeReasoningEffortForProvider(
req, provider("my-custom-openai-compat-gateway"));
assertNull(out.reasoningEffort(),
"unknown provider must strip (default-deny) — if this fails, someone re-added default-allow");
}
@Test
@DisplayName("Whitelist + supporting model: keep reasoning_effort (gpt-5 on openai)")
void sanitize_whitelisted_supportingModel_keeps() {
OpenAiApi.ChatCompletionRequest req = request("gpt-5", "high");
OpenAiApi.ChatCompletionRequest out = AgentGraphBuilder.sanitizeReasoningEffortForProvider(req, provider("openai"));
assertSame(req, out, "gpt-5 on openai should pass through unchanged");
assertEquals("high", out.reasoningEffort());
}
@Test
@DisplayName("Whitelist + non-supporting model: strip (gpt-4 on openai)")
void sanitize_whitelisted_nonSupportingModel_strips() {
// gpt-4 is NOT OPENAI_REASONING family reasoning_effort is not applicable there.
OpenAiApi.ChatCompletionRequest req = request("gpt-4", "medium");
OpenAiApi.ChatCompletionRequest out = AgentGraphBuilder.sanitizeReasoningEffortForProvider(req, provider("openai"));
assertNull(out.reasoningEffort(),
"gpt-4 is whitelisted-provider but non-supporting-family — family gate should strip");
}
@Test
@DisplayName("Azure OpenAI with supporting model: keep reasoning_effort")
void sanitize_azureOpenai_supporting_keeps() {
OpenAiApi.ChatCompletionRequest req = request("gpt-5", "low");
OpenAiApi.ChatCompletionRequest out = AgentGraphBuilder.sanitizeReasoningEffortForProvider(req, provider("azure-openai"));
assertEquals("low", out.reasoningEffort());
}
@Test
@DisplayName("Null provider: strip (defensive)")
void sanitize_nullProvider_strips() {
OpenAiApi.ChatCompletionRequest req = request("gpt-5", "high");
OpenAiApi.ChatCompletionRequest out = AgentGraphBuilder.sanitizeReasoningEffortForProvider(req, null);
assertNull(out.reasoningEffort());
}
}

View File

@ -11,6 +11,7 @@ import org.springframework.test.context.TestPropertySource;
import vip.mate.MateClawApplication;
import vip.mate.agent.binding.model.AgentToolBinding;
import vip.mate.agent.binding.service.AgentBindingService;
import vip.mate.exception.MateClawException;
import java.util.List;
import java.util.Set;
@ -47,8 +48,54 @@ class AgentBindingServiceTest {
@BeforeEach
void setUp() {
// 每个用例用独立 agent_id互不干扰
// Each test gets its own agent id so concurrent runs don't clash.
agentId = AGENT_ID_SEQ.getAndIncrement();
// Seed a real mate_agent row so AgentBindingService.requireSameWorkspace
// can resolve the agent's workspace during bindSkill/setSkillBindings.
// Tool-binding tests don't strictly need it but seeding is cheap and
// keeps every code path realistic.
seedAgent(agentId);
}
private void seedAgent(long id) {
jdbcTemplate.update(
"MERGE INTO mate_agent (id, name, agent_type, system_prompt, max_iterations, enabled, " +
"workspace_id, create_time, update_time, deleted) " +
"KEY(id) VALUES (?, ?, 'react', '', 10, TRUE, 1, " +
"CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)",
id, "binding-test-agent-" + id);
}
private void seedSkill(long id) {
seedSkill(id, 1L);
}
/**
* Skill seeder with explicit workspace_id so the cross-workspace
* rejection path can be exercised.
*/
private void seedSkill(long id, long workspaceId) {
jdbcTemplate.update(
"MERGE INTO mate_skill (id, name, skill_type, version, enabled, builtin, " +
"workspace_id, create_time, update_time, deleted) " +
"KEY(id) VALUES (?, ?, 'dynamic', '1.0.0', TRUE, FALSE, ?, " +
"CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)",
id, "binding-test-skill-" + id, workspaceId);
}
/**
* ACP endpoint seeder. The bridge synthesizes a virtual skill with id
* {@code AcpSkillBridge.VIRTUAL_ID_BASE + endpointId} and inherits
* {@code workspaceId} from the row, so this is the lever for testing
* the bridge-backed workspace check in {@code requireSameWorkspace}.
*/
private void seedAcpEndpoint(long endpointId, long workspaceId) {
jdbcTemplate.update(
"MERGE INTO mate_acp_endpoint (id, name, command, builtin, trusted, enabled, " +
"workspace_id, create_time, update_time, deleted) " +
"KEY(id) VALUES (?, ?, 'echo', FALSE, TRUE, TRUE, ?, " +
"CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)",
endpointId, "binding-test-acp-" + endpointId, workspaceId);
}
@Test
@ -67,6 +114,7 @@ class AgentBindingServiceTest {
@DisplayName("bindSkill → unbindSkill → bindSkill 同一 (agent, skill) 不抛异常")
void rebindSkillAfterUnbind() {
long skillId = 7_777_001L;
seedSkill(skillId);
bindingService.bindSkill(agentId, skillId);
bindingService.unbindSkill(agentId, skillId);
assertDoesNotThrow(() -> bindingService.bindSkill(agentId, skillId));
@ -79,6 +127,13 @@ class AgentBindingServiceTest {
@Test
@DisplayName("setToolBindings 连续调用两次相同列表不抛异常,状态收敛")
void setToolBindingsIsIdempotent() {
// setToolBindings now refuses unknown tool names (so an API caller
// can't write a binding the runtime won't be able to resolve).
// Seed two real rows in mate_tool first so the validator considers
// the names bindable; the test's intent idempotent persistence
// is unchanged.
seedBuiltinTool("tool_a");
seedBuiltinTool("tool_b");
List<String> desired = List.of("tool_a", "tool_b");
bindingService.setToolBindings(agentId, desired);
assertDoesNotThrow(() -> bindingService.setToolBindings(agentId, desired));
@ -89,10 +144,18 @@ class AgentBindingServiceTest {
assertTrue(names.containsAll(desired));
}
private void seedBuiltinTool(String name) {
jdbcTemplate.update(
"MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) " +
"KEY(name) VALUES (?, ?, ?, ?, 'builtin', ?, '🔧', TRUE, TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)",
System.nanoTime(), name, name, "test fixture", name);
}
@Test
@DisplayName("setSkillBindings 连续调用两次相同列表不抛异常,状态收敛")
void setSkillBindingsIsIdempotent() {
List<Long> desired = List.of(7_777_101L, 7_777_102L);
desired.forEach(this::seedSkill);
bindingService.setSkillBindings(agentId, desired);
assertDoesNotThrow(() -> bindingService.setSkillBindings(agentId, desired));
@ -122,6 +185,7 @@ class AgentBindingServiceTest {
@DisplayName("唯一性回归:同一 (agent, skill) 直接 INSERT 第二行仍被唯一索引拦截")
void uniqueIndexStillEnforcedForSkill() {
long skillId = 7_777_201L;
seedSkill(skillId);
bindingService.bindSkill(agentId, skillId);
assertThrows(DuplicateKeyException.class, () ->
@ -134,6 +198,94 @@ class AgentBindingServiceTest {
);
}
@Test
@DisplayName("bindSkill 拒绝跨 workspace 的真实 skill防止 tenancy 越界)")
void bindSkillRejectsCrossWorkspaceRealSkill() {
// Agent lives in workspace 1 (set up in @BeforeEach). Seed a skill
// in workspace 2 the new requireSameWorkspace check should refuse.
long otherWorkspaceSkillId = 7_777_301L;
seedSkill(otherWorkspaceSkillId, 2L);
MateClawException ex = assertThrows(MateClawException.class,
() -> bindingService.bindSkill(agentId, otherWorkspaceSkillId));
assertEquals(403, ex.getCode(), "应返回 403 业务码(跨 workspace 越界)");
assertEquals("err.skill.cross_workspace_binding", ex.getMsgKey(),
"应使用专用 i18n key前端可精确分支");
// No row should have been written before the check failed.
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM mate_agent_skill WHERE agent_id = ? AND skill_id = ?",
Integer.class, agentId, otherWorkspaceSkillId);
assertNotNull(count);
assertEquals(0, count, "拒绝时不能写入绑定行");
}
@Test
@DisplayName("bindSkill 允许 MCP 虚拟 skillMcpServerEntity 无 workspace全局共享")
void bindSkillAllowsVirtualMcpSkill() {
// Virtual MCP id range starts at McpSkillBridge.VIRTUAL_ID_BASE (9e18).
// No mate_skill or mate_mcp_server seeding needed the bridge is
// bypassed entirely for MCP because there's no workspace to compare.
long virtualMcpId = vip.mate.skill.mcp.McpSkillBridge.VIRTUAL_ID_BASE + 42L;
assertDoesNotThrow(() -> bindingService.bindSkill(agentId, virtualMcpId));
Set<Long> ids = bindingService.getBoundSkillIds(agentId);
assertNotNull(ids);
assertTrue(ids.contains(virtualMcpId), "MCP virtual binding 应当落到 mate_agent_skill");
}
@Test
@DisplayName("bindSkill 允许同 workspace 的 ACP 虚拟 skill走 AcpSkillBridge 解析 workspace")
void bindSkillAllowsVirtualAcpSkillSameWorkspace() {
long endpointId = 4_242_001L;
seedAcpEndpoint(endpointId, 1L); // matches the agent's workspace
long virtualAcpId = vip.mate.skill.acp.AcpSkillBridge.VIRTUAL_ID_BASE + endpointId;
assertDoesNotThrow(() -> bindingService.bindSkill(agentId, virtualAcpId));
Set<Long> ids = bindingService.getBoundSkillIds(agentId);
assertNotNull(ids);
assertTrue(ids.contains(virtualAcpId), "ACP virtual binding 应当落到 mate_agent_skill");
}
@Test
@DisplayName("bindSkill 拒绝跨 workspace 的 ACP 虚拟 skillendpoint 的 workspace 与 agent 不一致)")
void bindSkillRejectsVirtualAcpSkillCrossWorkspace() {
long endpointId = 4_242_002L;
seedAcpEndpoint(endpointId, 2L); // different workspace from the agent (=1)
long virtualAcpId = vip.mate.skill.acp.AcpSkillBridge.VIRTUAL_ID_BASE + endpointId;
MateClawException ex = assertThrows(MateClawException.class,
() -> bindingService.bindSkill(agentId, virtualAcpId));
assertEquals(403, ex.getCode());
assertEquals("err.skill.cross_workspace_binding", ex.getMsgKey());
}
@Test
@DisplayName("setSkillBindings 在批量中先做完所有校验,再删旧绑定(半成品保护)")
void setSkillBindingsValidatesBeforeMutating() {
// Seed one good skill (ws=1, same as agent) so getBoundSkillIds
// is non-empty before the failing batch. Then call setSkillBindings
// with one good + one cross-workspace id the whole batch must be
// refused and the original binding must survive untouched.
long goodSkill = 7_777_401L;
seedSkill(goodSkill, 1L);
bindingService.bindSkill(agentId, goodSkill);
long badSkill = 7_777_402L;
seedSkill(badSkill, 2L);
assertThrows(MateClawException.class,
() -> bindingService.setSkillBindings(agentId, List.of(goodSkill, badSkill)));
// The pre-existing binding to goodSkill must still be there
// validation should have failed before the DELETE ran.
Set<Long> remaining = bindingService.getBoundSkillIds(agentId);
assertNotNull(remaining);
assertTrue(remaining.contains(goodSkill),
"validation 必须在 delete 旧绑定之前完成,否则会留下空绑定状态");
}
@Test
@DisplayName("unbindTool 后 DB 里真的没行(物理 delete不是软删留 deleted=1")
void unbindPhysicallyRemovesRow() {

View File

@ -0,0 +1,186 @@
package vip.mate.agent.binding;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import vip.mate.agent.binding.model.AgentToolBinding;
import vip.mate.agent.binding.repository.AgentProviderPreferenceMapper;
import vip.mate.agent.binding.repository.AgentSkillBindingMapper;
import vip.mate.agent.binding.repository.AgentToolBindingMapper;
import vip.mate.agent.binding.service.AgentBindingService;
import vip.mate.agent.repository.AgentMapper;
import vip.mate.exception.MateClawException;
import vip.mate.skill.acp.AcpSkillBridge;
import vip.mate.skill.repository.SkillMapper;
import vip.mate.skill.runtime.SkillRuntimeService;
import vip.mate.tool.model.AvailableToolDTO;
import vip.mate.tool.service.AvailableToolService;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* Unit-level coverage for {@code setToolBindings}'s validation gate
* proves that hand-crafted API requests can't write a tool name the
* runtime won't be able to resolve.
*/
class AgentBindingServiceValidationTest {
private AgentToolBindingMapper toolBindingMapper;
private AvailableToolService availableToolService;
private AgentBindingService service;
@BeforeEach
void setUp() {
AgentSkillBindingMapper skillBindingMapper = mock(AgentSkillBindingMapper.class);
toolBindingMapper = mock(AgentToolBindingMapper.class);
AgentProviderPreferenceMapper providerPreferenceMapper = mock(AgentProviderPreferenceMapper.class);
SkillRuntimeService skillRuntimeService = mock(SkillRuntimeService.class);
availableToolService = mock(AvailableToolService.class);
// Tool-binding tests don't exercise the agent/skill workspace lookup,
// so empty mocks are enough the wired-in fields just need to be
// non-null for construction.
AgentMapper agentMapper = mock(AgentMapper.class);
SkillMapper skillMapper = mock(SkillMapper.class);
AcpSkillBridge acpSkillBridge = mock(AcpSkillBridge.class);
service = new AgentBindingService(
skillBindingMapper,
toolBindingMapper,
providerPreferenceMapper,
skillRuntimeService,
availableToolService,
agentMapper,
skillMapper,
acpSkillBridge);
// No existing binding by default each test overrides as needed.
when(toolBindingMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of());
}
@Test
@DisplayName("a known available tool name persists")
void availableNameIsAccepted() {
when(availableToolService.listAvailable()).thenReturn(List.of(
bindable("web_search"),
bindable("mcp_42_search_aaaaaa")));
service.setToolBindings(99L, List.of("mcp_42_search_aaaaaa"));
ArgumentCaptor<AgentToolBinding> captor = ArgumentCaptor.forClass(AgentToolBinding.class);
verify(toolBindingMapper, times(1)).insert(captor.capture());
assertEquals("mcp_42_search_aaaaaa", captor.getValue().getToolName());
}
@Test
@DisplayName("an unknown name (typo / legacy unprefixed) is refused")
void unknownNameIsRejected() {
when(availableToolService.listAvailable()).thenReturn(List.of(
bindable("mcp_42_search_aaaaaa")));
MateClawException ex = assertThrows(MateClawException.class,
() -> service.setToolBindings(99L, List.of("search_typo")));
assertTrue(ex.getMessage().contains("search_typo"),
"error should name the rejected tool, got: " + ex.getMessage());
// Nothing should have been persisted validation runs before delete.
verify(toolBindingMapper, never()).delete(any());
verify(toolBindingMapper, never()).insert(any(AgentToolBinding.class));
}
@Test
@DisplayName("a name marked available=false (e.g. hash collision) is refused")
void unavailableNameIsRejected() {
AvailableToolDTO collided = AvailableToolDTO.builder()
.name("mcp_42_search_aaaaaa")
.available(false)
.unavailableReason("HASH_COLLISION:other")
.build();
when(availableToolService.listAvailable()).thenReturn(List.of(collided));
assertThrows(MateClawException.class,
() -> service.setToolBindings(99L, List.of("mcp_42_search_aaaaaa")));
verify(toolBindingMapper, never()).delete(any());
}
@Test
@DisplayName("a stale/unavailable name already in the existing binding can be removed (not blocked)")
void existingUnbindableCanBeRemoved() {
// Existing binding holds a name that has since become unavailable.
// The user removes it passing an empty incoming list. Validation
// must NOT block this because the new name set introduces nothing
// new to validate.
AgentToolBinding existing = new AgentToolBinding();
existing.setAgentId(99L);
existing.setToolName("mcp_42_search_aaaaaa");
existing.setEnabled(true);
when(toolBindingMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(existing));
when(availableToolService.listAvailable()).thenReturn(List.of()); // tool no longer available
service.setToolBindings(99L, List.of());
verify(toolBindingMapper, times(1)).delete(any());
verify(toolBindingMapper, never()).insert(any(AgentToolBinding.class));
}
@Test
@DisplayName("keeping an existing-but-now-stale binding is allowed; adding a NEW unknown is still refused")
void mixedKeepAndUnknownAdd() {
// Existing has one binding; user tries to keep it AND add a typo.
AgentToolBinding existing = new AgentToolBinding();
existing.setAgentId(99L);
existing.setToolName("mcp_42_search_aaaaaa");
existing.setEnabled(true);
when(toolBindingMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(existing));
// Only a different name is currently available.
when(availableToolService.listAvailable()).thenReturn(List.of(
bindable("web_search")));
assertThrows(MateClawException.class,
() -> service.setToolBindings(99L, List.of("mcp_42_search_aaaaaa", "typo")));
verify(toolBindingMapper, never()).delete(any());
}
@Test
@DisplayName("blank or null entries in incoming list are rejected")
void blankEntriesAreRejected() {
when(availableToolService.listAvailable()).thenReturn(List.of(bindable("web_search")));
assertThrows(MateClawException.class,
() -> service.setToolBindings(99L, java.util.Arrays.asList("web_search", "")));
assertThrows(MateClawException.class,
() -> service.setToolBindings(99L, java.util.Arrays.asList("web_search", (String) null)));
}
@Test
@DisplayName("AvailableToolService failure: validation refuses any new name (conservative)")
void availableServiceFailureIsConservative() {
when(availableToolService.listAvailable()).thenThrow(new RuntimeException("picker down"));
// Existing-only saves still succeed.
AgentToolBinding existing = new AgentToolBinding();
existing.setAgentId(99L);
existing.setToolName("web_search");
when(toolBindingMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(existing));
service.setToolBindings(99L, List.of("web_search"));
verify(toolBindingMapper, times(1)).delete(any());
// Adding a new one fails fast.
assertThrows(MateClawException.class,
() -> service.setToolBindings(99L, List.of("web_search", "another")));
}
private static AvailableToolDTO bindable(String name) {
return AvailableToolDTO.builder()
.name(name)
.available(true)
.build();
}
}

View File

@ -0,0 +1,71 @@
package vip.mate.agent.chatmodel;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
/**
* RFC-001 (Claude 4.7 contract): {@link AgentAnthropicChatModelBuilder#isClaude47}
* must correctly classify the model variants we'll see in production.
*
* <p>Reference: hermes-agent {@code anthropic_adapter._NO_SAMPLING_PARAMS_SUBSTRINGS}.
* Claude 4.7 forbids temperature / top_p / top_k entirely the builder relies
* on this detector to skip those fields rather than letting Anthropic 400.
*/
class AgentAnthropicChatModelBuilderClaude47Test {
@Test
@DisplayName("isClaude47 detects hyphenated direct-API model names")
void detect_hyphenated() {
assertTrue(AgentAnthropicChatModelBuilder.isClaude47("claude-opus-4-7"));
assertTrue(AgentAnthropicChatModelBuilder.isClaude47("claude-sonnet-4-7"));
assertTrue(AgentAnthropicChatModelBuilder.isClaude47("claude-haiku-4-7"));
}
@Test
@DisplayName("isClaude47 detects dotted variants (e.g. OpenRouter / mixed dialects)")
void detect_dotted() {
assertTrue(AgentAnthropicChatModelBuilder.isClaude47("claude-opus-4.7"));
assertTrue(AgentAnthropicChatModelBuilder.isClaude47("claude.sonnet.4.7"));
}
@Test
@DisplayName("isClaude47 detects OpenRouter-style prefixed model ids")
void detect_openrouterPrefix() {
assertTrue(AgentAnthropicChatModelBuilder.isClaude47("anthropic/claude-opus-4-7"));
assertTrue(AgentAnthropicChatModelBuilder.isClaude47("anthropic/claude-sonnet-4-7"));
assertTrue(AgentAnthropicChatModelBuilder.isClaude47("anthropic/claude-opus-4.7"));
}
@Test
@DisplayName("isClaude47 ignores 4.5 / 4.6 / 4.0 / 3.x and unrelated names")
void detect_negatives() {
assertFalse(AgentAnthropicChatModelBuilder.isClaude47("claude-opus-4-6"));
assertFalse(AgentAnthropicChatModelBuilder.isClaude47("claude-sonnet-4-5"));
assertFalse(AgentAnthropicChatModelBuilder.isClaude47("claude-3-7-sonnet"),
"3.7 must not match 4.7");
assertFalse(AgentAnthropicChatModelBuilder.isClaude47("claude-3-5-sonnet"));
// The "claude" prefix guard prevents non-Anthropic models from spuriously
// matching even if they contain "4-7" / "4.7" substrings.
assertFalse(AgentAnthropicChatModelBuilder.isClaude47("gpt-4-7"),
"Non-Claude models must NOT match — claude prefix guard active");
assertFalse(AgentAnthropicChatModelBuilder.isClaude47("nemotron-4-7-instruct"));
}
@Test
@DisplayName("isClaude47 null-safe")
void detect_nullSafe() {
assertFalse(AgentAnthropicChatModelBuilder.isClaude47(null));
assertFalse(AgentAnthropicChatModelBuilder.isClaude47(""));
}
@Test
@DisplayName("Note: claude-3-7-sonnet correctly distinguished from claude-4-7-*")
void detect_3_7_vs_4_7() {
// Both contain "-7" but only the second contains "4-7" as a substring.
assertFalse(AgentAnthropicChatModelBuilder.isClaude47("claude-3-7-sonnet-20250219"));
assertTrue(AgentAnthropicChatModelBuilder.isClaude47("claude-opus-4-7-20260415"),
"Date-stamped 4-7 variants must still match");
}
}

View File

@ -0,0 +1,138 @@
package vip.mate.agent.chatmodel;
import io.micrometer.observation.ObservationRegistry;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.ai.anthropic.api.AnthropicApi;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.retry.support.RetryTemplate;
import org.springframework.web.client.RestClient;
import org.springframework.web.reactive.function.client.WebClient;
import vip.mate.exception.MateClawException;
import vip.mate.llm.anthropic.oauth.ClaudeCodeApiHeaders;
import vip.mate.llm.anthropic.oauth.ClaudeCodeOAuthService;
import vip.mate.llm.anthropic.oauth.ClaudeCodeVersionDetector;
import vip.mate.llm.model.ModelProtocol;
import java.util.function.Supplier;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* Header-construction + token-fetch coverage for the Claude Code OAuth chat
* model builder. Building a real {@link AnthropicApi} doesn't make a network
* call (Spring AI defers all I/O to {@code chatCompletionEntity}), so these
* tests can exercise the full assembly path without mocking the API client.
*/
@ExtendWith(MockitoExtension.class)
class AgentClaudeCodeChatModelBuilderTest {
@Mock
private AgentAnthropicChatModelBuilder anthropicBuilder;
@Mock
private ClaudeCodeOAuthService oauthService;
private ClaudeCodeApiHeaders apiHeaders;
private AgentClaudeCodeChatModelBuilder builder;
@BeforeEach
void setUp() {
// Real ApiHeaders with a stub version detector the version string
// shows up verbatim in User-Agent assertions.
ClaudeCodeVersionDetector detector = new ClaudeCodeVersionDetector() {
@Override
public String get() { return "2.1.114"; }
};
apiHeaders = new ClaudeCodeApiHeaders(detector);
builder = new AgentClaudeCodeChatModelBuilder(
anthropicBuilder,
oauthService,
apiHeaders,
providerOf(RestClient::builder),
providerOf(WebClient::builder),
providerOf(() -> ObservationRegistry.NOOP),
new com.fasterxml.jackson.databind.ObjectMapper());
}
@Test
@DisplayName("supportedProtocol returns ANTHROPIC_CLAUDE_CODE")
void supportedProtocol() {
assertEquals(ModelProtocol.ANTHROPIC_CLAUDE_CODE, builder.supportedProtocol());
}
@Test
@DisplayName("buildOauthAnthropicApi accepts a token and produces a non-null AnthropicApi")
void buildOauthAnthropicApi_returnsClient() {
// Sanity check: the NoopApiKey path passes Spring AI's notNull assertion
// and the OAuth headers attach without throwing. If this test ever
// fails, the most likely cause is a Spring AI upgrade tightening the
// ApiKey contract see AgentClaudeCodeChatModelBuilder javadoc.
AnthropicApi api = builder.buildOauthAnthropicApi("sk-ant-oat01-test-token");
assertNotNull(api);
}
@Test
@DisplayName("build delegates to oauthService and reuses anthropicBuilder.buildAnthropicOptions")
void build_invokesOauthAndReusesOptions() {
when(oauthService.getValidToken()).thenReturn("tok-123");
// anthropicBuilder.buildAnthropicOptions returns a real options object
// we don't need a strict comparison, just that it gets invoked once and
// its result is fed through.
when(anthropicBuilder.buildAnthropicOptions(any()))
.thenReturn(org.springframework.ai.anthropic.AnthropicChatOptions.builder().build());
var result = builder.build(new vip.mate.llm.model.ModelConfigEntity(), null,
RetryTemplate.defaultInstance());
assertNotNull(result);
verify(oauthService, times(1)).getValidToken();
verify(anthropicBuilder, times(1)).buildAnthropicOptions(any());
}
@Test
@DisplayName("build propagates OAuth errors without calling buildAnthropicOptions")
void build_propagatesOauthErrors() {
// Simulates "no Claude Code on disk" caller surface is the same
// MateClawException so the global handler can format the i18n message.
when(oauthService.getValidToken()).thenThrow(new MateClawException(
"err.anthropic.no_claude_code", "no creds"));
assertThrows(MateClawException.class,
() -> builder.build(new vip.mate.llm.model.ModelConfigEntity(), null, null));
// anthropicBuilder shouldn't have been touched short-circuit before
// it would have wasted a buildAnthropicOptions call.
verify(anthropicBuilder, never()).buildAnthropicOptions(any());
}
/* ----- ObjectProvider test helper ----- */
/** Minimal {@link ObjectProvider} that defers to a {@link Supplier} for {@code getIfAvailable}. */
@SuppressWarnings("unchecked")
private static <T> ObjectProvider<T> providerOf(Supplier<T> supplier) {
ObjectProvider<T> mock = mock(ObjectProvider.class);
// Use lenient not every test triggers a getIfAvailable call (e.g.
// the supportedProtocol test takes a short path), and the strict
// default would fail with UnnecessaryStubbingException.
lenient().when(mock.getIfAvailable(any(Supplier.class))).thenAnswer(inv -> {
Supplier<T> fallback = inv.getArgument(0);
T v = supplier.get();
return v != null ? v : fallback.get();
});
return mock;
}
}

View File

@ -0,0 +1,359 @@
package vip.mate.agent.chatmodel;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.ai.anthropic.AnthropicChatOptions;
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;
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.tool.ToolCallback;
import org.springframework.ai.chat.model.ToolContext;
import org.springframework.ai.tool.definition.DefaultToolDefinition;
import org.springframework.ai.tool.definition.ToolDefinition;
import reactor.core.publisher.Flux;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Verifies the OAuth-mode prompt rewriting that prevents Anthropic's edge
* from rate-limiting MateClaw traffic. Each test corresponds to one of the
* transforms hermes-agent applies on {@code is_oauth=True} requests.
*/
class ClaudeCodeIdentityChatModelDecoratorTest {
@Test
@DisplayName("transform prepends Claude Code identity as its own SystemMessage before the original")
void transform_prependsToExistingSystem() {
ClaudeCodeIdentityChatModelDecorator d = new ClaudeCodeIdentityChatModelDecorator(noopDelegate());
Prompt input = new Prompt(List.of(
new SystemMessage("You are a helpful coding assistant."),
new UserMessage("hi")));
Prompt result = d.transform(input);
// RFC-062: identity must be its OWN system block (not merged into one string)
// Anthropic's OAuth anti-abuse gate 429s the merged form, accepts the array form.
SystemMessage identity = (SystemMessage) result.getInstructions().get(0);
assertEquals(ClaudeCodeIdentityChatModelDecorator.CLAUDE_CODE_SYSTEM_PREFIX, identity.getText());
SystemMessage body = (SystemMessage) result.getInstructions().get(1);
assertTrue(body.getText().contains("helpful coding assistant"));
}
@Test
@DisplayName("transform inserts a system message when none was present")
void transform_insertsSystemWhenAbsent() {
ClaudeCodeIdentityChatModelDecorator d = new ClaudeCodeIdentityChatModelDecorator(noopDelegate());
Prompt input = new Prompt(List.of(new UserMessage("hello")));
Prompt result = d.transform(input);
// First message must be a system message with just the identity prefix
// hermes-agent does the same: system = [cc_block] when none was supplied.
Message first = result.getInstructions().get(0);
assertTrue(first instanceof SystemMessage);
assertEquals(ClaudeCodeIdentityChatModelDecorator.CLAUDE_CODE_SYSTEM_PREFIX,
((SystemMessage) first).getText());
// User message is preserved at index 1.
assertTrue(result.getInstructions().get(1) instanceof UserMessage);
}
@Test
@DisplayName("transform is idempotent — second pass doesn't double-prefix")
void transform_idempotent() {
// Defends against accidental double-wrapping (e.g. nested decorators or
// a re-issue of the same Prompt). hermes-agent doesn't have this concern
// because its rewrite happens in one place; we keep this guard so the
// identity prefix doesn't compound to "You are Claude Code...You are Claude Code...".
ClaudeCodeIdentityChatModelDecorator d = new ClaudeCodeIdentityChatModelDecorator(noopDelegate());
Prompt original = new Prompt(List.of(new SystemMessage("Body"), new UserMessage("hi")));
Prompt once = d.transform(original);
Prompt twice = d.transform(once);
SystemMessage sys = (SystemMessage) twice.getInstructions().get(0);
// Identity should appear exactly once.
int firstIdx = sys.getText().indexOf(ClaudeCodeIdentityChatModelDecorator.CLAUDE_CODE_SYSTEM_PREFIX);
int secondIdx = sys.getText().indexOf(
ClaudeCodeIdentityChatModelDecorator.CLAUDE_CODE_SYSTEM_PREFIX, firstIdx + 1);
assertTrue(firstIdx >= 0 && secondIdx == -1,
"Identity prefix must appear exactly once even after multiple transform passes");
}
@Test
@DisplayName("sanitizeBranding replaces MateClaw references")
void sanitizeBranding_replacesProductNames() {
// Anthropic's content filter flags self-contradicting identity claims
// a "You are Claude Code" prefix followed by a body that says "You are
// MateClaw" trips the filter. Strip the conflicting brand.
String sanitized = ClaudeCodeIdentityChatModelDecorator.sanitizeBranding(
"You are MateClaw, built on mateclaw");
assertEquals("You are Claude Code, built on claude-code", sanitized);
}
@Test
@DisplayName("sanitizeBranding tolerates empty / null input")
void sanitizeBranding_nullSafe() {
// Defensive a prompt with no system text shouldn't NPE here.
assertEquals("", ClaudeCodeIdentityChatModelDecorator.sanitizeBranding(""));
assertEquals(null, ClaudeCodeIdentityChatModelDecorator.sanitizeBranding(null));
}
@Test
@DisplayName("transform preserves chat options (temperature, model, etc.)")
void transform_preservesOptions() {
// Spring AI's AnthropicChatOptions carry critical per-request state
// (max_tokens, thinking budget, cache_control). Losing them on rewrite
// would silently break Claude 4.7 thinking mode.
var options = org.springframework.ai.anthropic.AnthropicChatOptions.builder()
.model("claude-opus-4-7").maxTokens(1234).build();
ClaudeCodeIdentityChatModelDecorator d = new ClaudeCodeIdentityChatModelDecorator(noopDelegate());
Prompt input = new Prompt(List.of(new UserMessage("hi")), options);
Prompt result = d.transform(input);
var resultOpts = (org.springframework.ai.anthropic.AnthropicChatOptions) result.getOptions();
assertEquals("claude-opus-4-7", resultOpts.getModel());
assertEquals(1234, resultOpts.getMaxTokens());
}
@Test
@DisplayName("call delegates the rewritten prompt downstream")
void call_delegatesRewritten() {
// Sanity: the prompt that reaches the underlying ChatModel must be the
// rewritten one, not the original otherwise the decorator is dead code.
AtomicReference<Prompt> captured = new AtomicReference<>();
ChatModel capturing = new TestDelegate() {
@Override
public ChatResponse call(Prompt prompt) {
captured.set(prompt);
return null;
}
};
ClaudeCodeIdentityChatModelDecorator d = new ClaudeCodeIdentityChatModelDecorator(capturing);
d.call(new Prompt(List.of(new UserMessage("hi"))));
assertNotNull(captured.get());
Message first = captured.get().getInstructions().get(0);
assertTrue(first instanceof SystemMessage);
assertEquals(ClaudeCodeIdentityChatModelDecorator.CLAUDE_CODE_SYSTEM_PREFIX,
((SystemMessage) first).getText());
}
@Test
@DisplayName("transform leaves non-system messages untouched")
void transform_preservesUserAndAssistantMessages() {
ClaudeCodeIdentityChatModelDecorator d = new ClaudeCodeIdentityChatModelDecorator(noopDelegate());
Prompt input = new Prompt(List.of(
new SystemMessage("be helpful"),
new UserMessage("question 1"),
new AssistantMessage("answer 1"),
new UserMessage("question 2")));
Prompt result = d.transform(input);
// RFC-062: system splits into [identity, sanitized body] so user/assistant
// shift to indices 2, 3, 4. Their content is the original instance a copy
// here would force Spring AI to re-encode multimodal content (images,
// tool_results) for no benefit.
assertTrue(result.getInstructions().get(2) instanceof UserMessage);
assertEquals("question 1", ((UserMessage) result.getInstructions().get(2)).getText());
assertEquals("answer 1", ((AssistantMessage) result.getInstructions().get(3)).getText());
assertEquals("question 2", ((UserMessage) result.getInstructions().get(4)).getText());
}
@Test
@DisplayName("transform wraps tool callbacks so getToolDefinition().name() returns mcp_<orig>")
void transform_prefixesOutgoingToolNames() {
// Anthropic's anti-abuse path inspects tool definitions; tools without
// the mcp_ prefix on a request claiming Claude Code identity get the
// request rate-limited (429 with body "Error"). Ensure we wrap.
ToolCallback search = stubToolCallback("search", "Search the web");
ToolCallback createFile = stubToolCallback("createFile", "Create a file");
AnthropicChatOptions opts = AnthropicChatOptions.builder()
.toolCallbacks(List.of(search, createFile))
.build();
ClaudeCodeIdentityChatModelDecorator d = new ClaudeCodeIdentityChatModelDecorator(noopDelegate());
Prompt result = d.transform(new Prompt(List.of(new UserMessage("hi")), opts));
AnthropicChatOptions resOpts = (AnthropicChatOptions) result.getOptions();
List<ToolCallback> wrapped = resOpts.getToolCallbacks();
assertEquals(2, wrapped.size());
assertEquals("mcp_search", wrapped.get(0).getToolDefinition().name());
assertEquals("mcp_createFile", wrapped.get(1).getToolDefinition().name());
}
@Test
@DisplayName("PrefixedToolCallback forwards call() to the underlying tool unchanged")
void prefixedToolCallback_forwardsCall() {
// Critical contract: prefixing happens on the wire, but MateClaw's tool
// implementation must still receive the original argument string and
// return the original output verbatim. If this fails, every tool
// execution under OAuth would silently mis-route.
AtomicReference<String> capturedInput = new AtomicReference<>();
ToolCallback underlying = new ToolCallback() {
@Override
public ToolDefinition getToolDefinition() {
return DefaultToolDefinition.builder().name("search").description("d").inputSchema("{}").build();
}
@Override
public String call(String input) {
capturedInput.set(input);
return "search-output";
}
};
var wrapped = new ClaudeCodeIdentityChatModelDecorator.PrefixedToolCallback(underlying);
String out = wrapped.call("{\"q\":\"test\"}");
assertEquals("search-output", out);
assertEquals("{\"q\":\"test\"}", capturedInput.get());
assertEquals("mcp_search", wrapped.getToolDefinition().name());
}
@Test
@DisplayName("PrefixedToolCallback is idempotent — double-wrap doesn't double-prefix")
void prefixedToolCallback_idempotent() {
// Defends against accidental nested decoration. A wrapped wrapper
// should still expose mcp_search, not mcp_mcp_search.
ToolCallback underlying = stubToolCallback("search", "d");
var once = new ClaudeCodeIdentityChatModelDecorator.PrefixedToolCallback(underlying);
var twice = new ClaudeCodeIdentityChatModelDecorator.PrefixedToolCallback(once);
assertEquals("mcp_search", once.getToolDefinition().name());
assertEquals("mcp_search", twice.getToolDefinition().name());
}
@Test
@DisplayName("stripToolPrefixes removes mcp_ from response tool_use names")
void stripToolPrefixes_responseSide() {
// Claude returns tool_use with name="mcp_search" (because we prefixed
// the definition); MateClaw's tool registry only knows "search" so
// the prefix must come off before the response leaves the decorator.
AssistantMessage am = AssistantMessage.builder()
.content("calling search")
.toolCalls(List.of(
new AssistantMessage.ToolCall("call_1", "function", "mcp_search",
"{\"q\":\"foo\"}"),
new AssistantMessage.ToolCall("call_2", "function", "mcp_createFile",
"{\"path\":\"x\"}")))
.build();
ChatResponse response = new ChatResponse(List.of(new Generation(am)));
ClaudeCodeIdentityChatModelDecorator d = new ClaudeCodeIdentityChatModelDecorator(noopDelegate());
ChatResponse stripped = d.stripToolPrefixes(response);
AssistantMessage out = stripped.getResult().getOutput();
assertEquals("search", out.getToolCalls().get(0).name());
assertEquals("createFile", out.getToolCalls().get(1).name());
// ID + arguments must pass through untouched losing the call ID
// would break Anthropic's tool_result correlation on next turn.
assertEquals("call_1", out.getToolCalls().get(0).id());
assertEquals("{\"q\":\"foo\"}", out.getToolCalls().get(0).arguments());
}
@Test
@DisplayName("stripToolPrefixes returns input unchanged when no tool_use blocks present")
void stripToolPrefixes_noToolCalls_passthrough() {
// Optimization: don't allocate a new list/Generation when there's
// nothing to rewrite. Verify identity-equality for the trivial case.
ChatResponse response = new ChatResponse(List.of(
new Generation(new AssistantMessage("just text"))));
ClaudeCodeIdentityChatModelDecorator d = new ClaudeCodeIdentityChatModelDecorator(noopDelegate());
ChatResponse out = d.stripToolPrefixes(response);
assertTrue(out == response, "no-op rewrite should return the same instance");
}
@Test
@DisplayName("transform re-prefixes tool_use names in AssistantMessage history")
void transform_reprefixesHistoryToolUse() {
// Prior turn: Claude called mcp_search we stripped to "search" before
// storing next request must re-prepend mcp_ so Anthropic's history
// matches its own prior tool_use block. Otherwise Anthropic's
// tool_use_id correlation breaks and you get "tool_use without
// matching tool_result" 400s.
AssistantMessage history = AssistantMessage.builder()
.content("")
.toolCalls(List.of(
new AssistantMessage.ToolCall("call_1", "function", "search",
"{\"q\":\"foo\"}")))
.build();
ClaudeCodeIdentityChatModelDecorator d = new ClaudeCodeIdentityChatModelDecorator(noopDelegate());
Prompt result = d.transform(new Prompt(List.of(
new SystemMessage("be helpful"),
history,
new UserMessage("now do that"))));
// RFC-062: system splits into [identity, sanitized body] so AssistantMessage
// history shifts to index 2.
AssistantMessage rewrittenHistory = (AssistantMessage) result.getInstructions().get(2);
assertEquals("mcp_search", rewrittenHistory.getToolCalls().get(0).name());
// ID stays the same so tool_result correlation chains through.
assertEquals("call_1", rewrittenHistory.getToolCalls().get(0).id());
}
@Test
@DisplayName("call delegates rewritten prompt and strips response prefixes end-to-end")
void call_endToEnd() {
// Integration: outgoing prompt should have prefixed tool names, and
// the AssistantMessage we return should come back unprefixed. Mirrors
// what ReasoningNode would observe per turn.
ToolCallback tool = stubToolCallback("search", "search");
AnthropicChatOptions opts = AnthropicChatOptions.builder()
.toolCallbacks(List.of(tool)).build();
AtomicReference<Prompt> capturedPrompt = new AtomicReference<>();
ChatModel delegate = new TestDelegate() {
@Override
public ChatResponse call(Prompt prompt) {
capturedPrompt.set(prompt);
AssistantMessage am = AssistantMessage.builder()
.content("")
.toolCalls(List.of(new AssistantMessage.ToolCall(
"call_x", "function", "mcp_search", "{}")))
.build();
return new ChatResponse(List.of(new Generation(am)));
}
};
ClaudeCodeIdentityChatModelDecorator d = new ClaudeCodeIdentityChatModelDecorator(delegate);
ChatResponse out = d.call(new Prompt(List.of(new UserMessage("hi")), opts));
AnthropicChatOptions sentOpts = (AnthropicChatOptions) capturedPrompt.get().getOptions();
assertEquals("mcp_search", sentOpts.getToolCallbacks().get(0).getToolDefinition().name(),
"outgoing tool name must be prefixed");
assertEquals("search", out.getResult().getOutput().getToolCalls().get(0).name(),
"incoming tool name must be stripped");
// Sanity name must round-trip differently from the wire format.
assertNotEquals("mcp_search", out.getResult().getOutput().getToolCalls().get(0).name());
}
/* ----- Test helpers ----- */
private static ChatModel noopDelegate() {
return new TestDelegate();
}
private static ToolCallback stubToolCallback(String name, String description) {
return new ToolCallback() {
@Override
public ToolDefinition getToolDefinition() {
return DefaultToolDefinition.builder()
.name(name).description(description).inputSchema("{}").build();
}
@Override
public String call(String input) { return "ok"; }
};
}
/** Minimal ChatModel that returns null/empty — sufficient for transform-only tests. */
private static class TestDelegate implements ChatModel {
@Override
public ChatResponse call(Prompt prompt) { return null; }
@Override
public Flux<ChatResponse> stream(Prompt prompt) { return Flux.empty(); }
}
}

View File

@ -0,0 +1,237 @@
package vip.mate.agent.chatmodel;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
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.UserMessage;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.openai.OpenAiChatOptions;
import reactor.core.publisher.Flux;
import vip.mate.agent.ThinkingLevelHolder;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Validates the per-request payload patches DeepSeek V4 requires.
*
* <p>Two independent invariants are tested separately because they're each
* easy to silently break:
* <ul>
* <li>{@code extraBody.thinking} + {@code reasoning_effort} on the options.</li>
* <li>{@code reasoning_content} on prior assistant tool-call messages
* (ensure-when-enabled / strip-when-disabled).</li>
* </ul>
*/
class DeepSeekV4ThinkingDecoratorTest {
private DeepSeekV4ThinkingDecorator decorator;
@BeforeEach
void setUp() {
decorator = new DeepSeekV4ThinkingDecorator(new NoopChatModel());
}
@AfterEach
void clearHolder() {
ThinkingLevelHolder.clear();
}
/* =================================================================== */
/* Options patching */
/* =================================================================== */
@Test
@DisplayName("thinking=high → extraBody.thinking={type:enabled} + reasoning_effort=high")
void thinkingHigh_injectsEnabledAndHighEffort() {
ThinkingLevelHolder.set("high");
OpenAiChatOptions opts = OpenAiChatOptions.builder().model("deepseek-v4-flash").build();
Prompt result = decorator.transform(new Prompt(List.of(new UserMessage("hi")), opts));
OpenAiChatOptions out = (OpenAiChatOptions) result.getOptions();
assertEquals("high", out.getReasoningEffort());
assertNotNull(out.getExtraBody());
Object thinking = out.getExtraBody().get("thinking");
assertEquals(Map.of("type", "enabled"), thinking,
"thinking field must be the exact {type: enabled} shape DeepSeek expects");
}
@Test
@DisplayName("thinking=off → extraBody.thinking={type:disabled} + reasoning_effort cleared")
void thinkingOff_clearsEffortAndDisablesThinking() {
// Critical: when thinking is disabled, BOTH fields must change. Leaving
// a stale reasoning_effort while flipping thinking off causes DeepSeek
// to 400 with "thinking and reasoning_effort cannot coexist when disabled".
ThinkingLevelHolder.set("off");
OpenAiChatOptions opts = OpenAiChatOptions.builder()
.model("deepseek-v4-pro")
.reasoningEffort("medium") // pre-set, must be cleared
.build();
Prompt result = decorator.transform(new Prompt(List.of(new UserMessage("hi")), opts));
OpenAiChatOptions out = (OpenAiChatOptions) result.getOptions();
assertNull(out.getReasoningEffort(), "reasoning_effort must be cleared when thinking is off");
assertEquals(Map.of("type", "disabled"), out.getExtraBody().get(DeepSeekV4ThinkingDecorator.THINKING_FIELD));
}
@Test
@DisplayName("Existing extraBody entries are preserved when patching")
void extraBody_preservesExistingEntries() {
// Defends against a copy-and-replace bug where the patch overwrites the
// whole map. Other extra-body fields (e.g. provider-specific knobs) must
// survive losing them silently would break unrelated features.
ThinkingLevelHolder.set("low");
Map<String, Object> seed = new HashMap<>();
seed.put("custom_knob", 42);
OpenAiChatOptions opts = OpenAiChatOptions.builder()
.model("deepseek-v4-flash").build();
opts.setExtraBody(seed);
Prompt result = decorator.transform(new Prompt(List.of(new UserMessage("hi")), opts));
OpenAiChatOptions out = (OpenAiChatOptions) result.getOptions();
assertEquals(42, out.getExtraBody().get("custom_knob"));
assertNotNull(out.getExtraBody().get(DeepSeekV4ThinkingDecorator.THINKING_FIELD));
}
@Test
@DisplayName("mapEffort: low/medium/high passthrough; max collapses to high; unknown → medium")
void mapEffort_levels() {
// openclaw resolveDeepSeekV4ReasoningEffort folds "max" into "high"
// because DeepSeek doesn't expose a max tier. Pin both ends of the rule.
assertEquals("low", DeepSeekV4ThinkingDecorator.mapEffort("low"));
assertEquals("medium", DeepSeekV4ThinkingDecorator.mapEffort("medium"));
assertEquals("high", DeepSeekV4ThinkingDecorator.mapEffort("high"));
assertEquals("high", DeepSeekV4ThinkingDecorator.mapEffort("max"));
assertEquals("medium", DeepSeekV4ThinkingDecorator.mapEffort("xhigh"));
assertEquals("medium", DeepSeekV4ThinkingDecorator.mapEffort(null));
}
/* =================================================================== */
/* Message patching */
/* =================================================================== */
@Test
@DisplayName("enabled + tool-call history → reasoning_content key ensured (empty string)")
void messages_enabled_ensuresReasoningContent() {
// V4 replay contract: every prior assistant tool-call message must have
// a reasoning_content (empty allowed). Missing it returns an obscure 400
// about "reasoning_content required for thinking-enabled tool replay".
AssistantMessage am = AssistantMessage.builder()
.content("")
.toolCalls(List.of(
new AssistantMessage.ToolCall("call_1", "function", "search", "{}")))
.build();
List<Message> patched = DeepSeekV4ThinkingDecorator.patchMessages(List.of(am), true);
AssistantMessage out = (AssistantMessage) patched.get(0);
assertTrue(out.getMetadata().containsKey(DeepSeekV4ThinkingDecorator.REASONING_CONTENT_KEY));
assertEquals("", out.getMetadata().get(DeepSeekV4ThinkingDecorator.REASONING_CONTENT_KEY));
// Tool calls must pass through unchanged losing the call ID would
// break the next turn's tool_result correlation.
assertEquals("call_1", out.getToolCalls().get(0).id());
}
@Test
@DisplayName("enabled + already-has reasoning_content → no rewrite (fast path)")
void messages_enabled_noRewriteWhenAlreadyPresent() {
Map<String, Object> meta = new HashMap<>();
meta.put(DeepSeekV4ThinkingDecorator.REASONING_CONTENT_KEY, "prev thinking");
AssistantMessage am = AssistantMessage.builder()
.content("answer")
.properties(meta)
.toolCalls(List.of(
new AssistantMessage.ToolCall("call_2", "function", "search", "{}")))
.build();
List<Message> patched = DeepSeekV4ThinkingDecorator.patchMessages(List.of(am), true);
// Identity equality fast path returns the same instance to avoid pointless allocation.
assertTrue(patched.get(0) == am, "no-op rewrite should return the same instance");
}
@Test
@DisplayName("disabled → reasoning_content stripped from prior messages")
void messages_disabled_stripsReasoningContent() {
// DeepSeek echoes prior reasoning_content back into the response when
// thinking is disabled, polluting the user-visible answer. Stripping is
// not optional.
Map<String, Object> meta = new HashMap<>();
meta.put(DeepSeekV4ThinkingDecorator.REASONING_CONTENT_KEY, "old thinking");
meta.put("other_meta", "preserved");
AssistantMessage am = AssistantMessage.builder()
.content("answer")
.properties(meta)
.build();
List<Message> patched = DeepSeekV4ThinkingDecorator.patchMessages(List.of(am), false);
AssistantMessage out = (AssistantMessage) patched.get(0);
assertFalse(out.getMetadata().containsKey(DeepSeekV4ThinkingDecorator.REASONING_CONTENT_KEY),
"reasoning_content must be removed");
assertEquals("preserved", out.getMetadata().get("other_meta"),
"Other metadata keys must survive the strip");
}
@Test
@DisplayName("disabled + no reasoning_content → no-op pass-through")
void messages_disabled_noOpWhenAbsent() {
AssistantMessage am = new AssistantMessage("plain answer");
List<Message> patched = DeepSeekV4ThinkingDecorator.patchMessages(List.of(am), false);
assertTrue(patched.get(0) == am, "no-op rewrite should return the same instance");
}
@Test
@DisplayName("Non-assistant messages pass through untouched")
void messages_userPassesThrough() {
// patchMessages must only touch AssistantMessage. UserMessage / ToolMessage
// / SystemMessage carry meaning the decorator has no business modifying.
UserMessage user = new UserMessage("question");
List<Message> patched = DeepSeekV4ThinkingDecorator.patchMessages(List.of(user), true);
assertTrue(patched.get(0) == user);
}
/* =================================================================== */
/* End-to-end delegate */
/* =================================================================== */
@Test
@DisplayName("call() delegates the patched prompt to the underlying ChatModel")
void call_delegatesPatched() {
// Sanity: the prompt that reaches the underlying model carries the
// patched options/messages, not the originals.
AtomicReference<Prompt> captured = new AtomicReference<>();
DeepSeekV4ThinkingDecorator d = new DeepSeekV4ThinkingDecorator(new NoopChatModel() {
@Override public ChatResponse call(Prompt prompt) {
captured.set(prompt);
return null;
}
});
ThinkingLevelHolder.set("medium");
OpenAiChatOptions opts = OpenAiChatOptions.builder().model("deepseek-v4-flash").build();
d.call(new Prompt(List.of(new UserMessage("hi")), opts));
assertNotNull(captured.get());
OpenAiChatOptions sentOpts = (OpenAiChatOptions) captured.get().getOptions();
assertEquals("medium", sentOpts.getReasoningEffort());
assertEquals(Map.of("type", "enabled"),
sentOpts.getExtraBody().get(DeepSeekV4ThinkingDecorator.THINKING_FIELD));
}
/* ---------- Test double ---------- */
private static class NoopChatModel implements ChatModel {
@Override public ChatResponse call(Prompt prompt) { return null; }
@Override public Flux<ChatResponse> stream(Prompt prompt) { return Flux.empty(); }
}
}

View File

@ -0,0 +1,76 @@
package vip.mate.agent.context;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.model.ToolContext;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
/**
* RFC-063r §2.1: ChatOrigin value-object invariants.
*/
class ChatOriginTest {
@Test
void from_nullToolContext_returnsEmpty() {
assertSame(ChatOrigin.EMPTY, ChatOrigin.from(null));
}
@Test
void from_toolContextWithoutOrigin_returnsEmpty() {
ToolContext ctx = new ToolContext(Map.of("unrelated.key", "x"));
assertSame(ChatOrigin.EMPTY, ChatOrigin.from(ctx));
}
@Test
void roundTripThroughToolContext_preservesAllFields() {
ChannelTarget target = new ChannelTarget("user-42", "thread-abc", "bot-001");
ChatOrigin original = new ChatOrigin(7L, "wechat:42", "u123", 5L,
"/data/ws/5", 9L, target);
ToolContext ctx = original.toToolContext();
ChatOrigin restored = ChatOrigin.from(ctx);
assertEquals(original, restored);
}
@Test
void wither_doesNotMutateOriginal() {
ChatOrigin base = ChatOrigin.cron("cron_1", 5L, "/data/ws/5", 9L,
new ChannelTarget("group-a", null, null));
ChatOrigin enriched = base.withAgent(42L);
assertNull(base.agentId(), "withAgent must not mutate the original");
assertEquals(42L, enriched.agentId());
assertEquals(base.channelId(), enriched.channelId(), "channelId must be preserved");
assertEquals(base.channelTarget(), enriched.channelTarget(),
"channelTarget must be preserved");
}
@Test
void cronFactory_setsRequesterToSystem() {
ChatOrigin origin = ChatOrigin.cron("cron_7", 1L, null, 3L, null);
assertEquals("system", origin.requesterId());
assertNull(origin.agentId(), "agentId is enriched later by BaseAgent");
}
@Test
void jsonSerialization_isStableAndForwardCompatible() throws Exception {
ObjectMapper om = new ObjectMapper();
ChatOrigin origin = new ChatOrigin(7L, "wechat:42", "u123", 5L,
"/data/ws/5", 9L, new ChannelTarget("user-42", "thread-abc", "bot-001"));
String json = om.writeValueAsString(origin);
ChatOrigin restored = om.readValue(json, ChatOrigin.class);
assertEquals(origin, restored);
// RFC-063r §2.1 forward compatibility: future-added unknown fields
// must not break deserialization (covers approval rows surviving upgrades).
String jsonWithExtraField = json.replaceFirst("\\}$", ",\"futureField\":\"x\"}");
ChatOrigin tolerated = om.readValue(jsonWithExtraField, ChatOrigin.class);
assertEquals(origin, tolerated);
}
}

View File

@ -0,0 +1,63 @@
package vip.mate.agent.context;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.ToolResponseMessage;
import org.springframework.ai.chat.messages.UserMessage;
import vip.mate.config.ConversationWindowProperties;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class ConversationWindowManagerToolPruningTest {
@Test
void prunesOlderToolResultsAndKeepsLatestFullResult() {
ConversationWindowManager manager = new ConversationWindowManager(
new ConversationWindowProperties(), null, null);
String oldLarge = "old-result\n".repeat(700);
String latestLarge = "latest-result\n".repeat(700);
List<Message> messages = List.of(
new UserMessage("read earlier file"),
toolMessage("old-1", "read_file", oldLarge),
new UserMessage("read latest file"),
toolMessage("new-1", "read_file", latestLarge)
);
List<Message> pruned = manager.pruneOldToolResultsForModelInput(messages);
ToolResponseMessage oldToolMessage = (ToolResponseMessage) pruned.get(1);
ToolResponseMessage latestToolMessage = (ToolResponseMessage) pruned.get(3);
String oldData = oldToolMessage.getResponses().getFirst().responseData();
String latestData = latestToolMessage.getResponses().getFirst().responseData();
assertTrue(oldData.contains("previous tool output summarized"));
assertTrue(oldData.length() < 300);
assertEquals(latestLarge, latestData);
}
@Test
void olderDuplicateToolResultUsesDuplicatePlaceholder() {
ConversationWindowManager manager = new ConversationWindowManager(
new ConversationWindowProperties(), null, null);
String repeated = "same-output\n".repeat(700);
List<Message> messages = List.of(
toolMessage("old-1", "read_file", repeated),
toolMessage("new-1", "read_file", repeated)
);
List<Message> pruned = manager.pruneOldToolResultsForModelInput(messages);
ToolResponseMessage oldToolMessage = (ToolResponseMessage) pruned.getFirst();
String oldData = oldToolMessage.getResponses().getFirst().responseData();
assertTrue(oldData.contains("duplicate tool output omitted"));
}
private static ToolResponseMessage toolMessage(String id, String name, String data) {
return ToolResponseMessage.builder()
.responses(List.of(new ToolResponseMessage.ToolResponse(id, name, data)))
.build();
}
}

View File

@ -0,0 +1,89 @@
package vip.mate.agent.context;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.definition.ToolDefinition;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class TokenEstimatorToolsTest {
private ToolCallback callback(String name, String description, String inputSchema) {
ToolCallback cb = mock(ToolCallback.class);
ToolDefinition def = mock(ToolDefinition.class);
when(def.name()).thenReturn(name);
when(def.description()).thenReturn(description);
when(def.inputSchema()).thenReturn(inputSchema);
when(cb.getToolDefinition()).thenReturn(def);
return cb;
}
@Test
@DisplayName("null / empty collection returns 0")
void emptyZero() {
assertEquals(0, TokenEstimator.estimateToolsTokens(null));
assertEquals(0, TokenEstimator.estimateToolsTokens(List.of()));
}
@Test
@DisplayName("single tool: name + description + schema + per-tool overhead all included")
void singleTool() {
ToolCallback cb = callback("web_search",
"Search the web for recent information",
"{\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\"}}}");
int tokens = TokenEstimator.estimateToolsTokens(List.of(cb));
// > the per-tool overhead alone (proves description + schema were summed in)
assertTrue(tokens > TokenEstimator.PER_TOOL_OVERHEAD,
"Should include description and schema, got " + tokens);
// sanity bound: this small tool shouldn't blow past 100 tokens
assertTrue(tokens < 100, "Bound check, got " + tokens);
}
@Test
@DisplayName("many tools accumulate — N tools cost ~N x single-tool cost")
void manyToolsAccumulate() {
ToolCallback cb = callback("read_file",
"Read a file from the workspace",
"{\"type\":\"object\",\"properties\":{\"path\":{\"type\":\"string\"}}}");
int one = TokenEstimator.estimateToolsTokens(List.of(cb));
int five = TokenEstimator.estimateToolsTokens(List.of(cb, cb, cb, cb, cb));
assertEquals(one * 5, five, "Five identical tools should cost five times one");
}
@Test
@DisplayName("MCP-sized tool with verbose schema costs hundreds of tokens — proves the gap is real")
void mcpSizedTool() {
// Realistic MCP tool: long description + nested schema with many properties
String bigDescription = "Execute a SQL query against the connected PostgreSQL database. " +
"Returns rows as a JSON array. Supports SELECT, INSERT, UPDATE, DELETE statements. " +
"Bound parameters must be passed as a separate array; do not concatenate user input.";
String bigSchema = "{\"type\":\"object\",\"properties\":{" +
"\"sql\":{\"type\":\"string\",\"description\":\"The SQL statement to execute\"}," +
"\"params\":{\"type\":\"array\",\"items\":{\"type\":\"string\"},\"description\":\"Bound parameters\"}," +
"\"timeout_ms\":{\"type\":\"integer\",\"description\":\"Statement timeout in ms\",\"minimum\":0,\"maximum\":60000}," +
"\"read_only\":{\"type\":\"boolean\",\"description\":\"Reject statements that modify data\"}" +
"},\"required\":[\"sql\"]}";
ToolCallback cb = callback("postgres_query", bigDescription, bigSchema);
int tokens = TokenEstimator.estimateToolsTokens(List.of(cb));
assertTrue(tokens > 100,
"A real MCP tool's schema cost should clearly exceed 100 tokens, got " + tokens);
}
@Test
@DisplayName("callbacks that throw on getToolDefinition() are skipped, not propagated")
void brokenCallbackSwallowed() {
ToolCallback bad = mock(ToolCallback.class);
when(bad.getToolDefinition()).thenThrow(new RuntimeException("provider error"));
ToolCallback good = callback("ok", "ok", "{}");
int tokens = TokenEstimator.estimateToolsTokens(List.of(bad, good));
// good tool still contributes; bad one contributes 0
assertTrue(tokens > 0,
"Broken callback should be skipped, good one should still count, got " + tokens);
}
}

View File

@ -0,0 +1,186 @@
package vip.mate.agent.delegation;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.security.core.Authentication;
import vip.mate.audit.service.AuditEventService;
import vip.mate.common.result.R;
import vip.mate.exception.MateClawException;
import vip.mate.workspace.conversation.ConversationService;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class SubagentControllerTest {
private SubagentRegistry registry;
private ConversationService conversationService;
private AuditEventService auditEventService;
private SubagentController controller;
private Authentication ownerAuth;
private Authentication outsiderAuth;
@BeforeEach
void setUp() {
registry = new SubagentRegistry();
conversationService = mock(ConversationService.class);
auditEventService = mock(AuditEventService.class);
ObjectMapper mapper = new ObjectMapper();
controller = new SubagentController(registry, conversationService, auditEventService, mapper);
ownerAuth = mock(Authentication.class);
when(ownerAuth.getName()).thenReturn("alice");
outsiderAuth = mock(Authentication.class);
when(outsiderAuth.getName()).thenReturn("mallory");
// Default: alice owns parent-1, mallory does not.
when(conversationService.isConversationOwner(eq("parent-1"), eq("alice"))).thenReturn(true);
when(conversationService.isConversationOwner(eq("parent-1"), eq("mallory"))).thenReturn(false);
}
@Test
@DisplayName("interrupt — owner gets 200 with interrupted=true and an audit row")
void interruptOwner() {
String sid = registry.register("parent-1", "child-1", 7L, "do thing", null);
R<Map<String, Object>> response = controller.interrupt(sid, ownerAuth);
assertThat(response.getCode()).isEqualTo(200); // ResultCode.SUCCESS
assertThat(response.getData()).containsEntry("interrupted", true);
assertThat(registry.get(sid).orElseThrow().status().get()).isEqualTo("interrupted");
verify(auditEventService).record(eq("subagent.interrupt"), eq("subagent"),
eq(sid), anyString(), anyString());
// Denial audit must NOT have fired on the owner path.
verify(auditEventService, never()).record(eq("subagent.interrupt.denied"),
anyString(), anyString(), anyString(), anyString());
}
@Test
@DisplayName("interrupt — non-owner is denied (403) and a denial audit is written")
void interruptDeniedForNonOwner() {
String sid = registry.register("parent-1", "child-1", 7L, "do thing", null);
assertThatThrownBy(() -> controller.interrupt(sid, outsiderAuth))
.isInstanceOf(MateClawException.class)
.matches(t -> ((MateClawException) t).getCode() == 403);
verify(auditEventService).record(eq("subagent.interrupt.denied"), eq("subagent"),
eq(sid), anyString(), anyString());
// Status must remain unchanged for the non-owner path.
assertThat(registry.get(sid).orElseThrow().status().get()).isEqualTo("running");
}
@Test
@DisplayName("interrupt — missing subagent throws 404")
void interruptNotFound() {
assertThatThrownBy(() -> controller.interrupt("sa-does-not-exist", ownerAuth))
.isInstanceOf(MateClawException.class)
.matches(t -> ((MateClawException) t).getCode() == 404);
verify(auditEventService, never()).record(eq("subagent.interrupt"),
anyString(), anyString(), anyString(), anyString());
}
@Test
@DisplayName("spawn-pause — missing parentConversationId throws 400")
void spawnPauseMissingParent() {
Map<String, Object> body = new HashMap<>();
body.put("paused", true);
assertThatThrownBy(() -> controller.setPaused(body, ownerAuth))
.isInstanceOf(MateClawException.class)
.matches(t -> ((MateClawException) t).getCode() == 400);
// Empty body also fails the same way.
assertThatThrownBy(() -> controller.setPaused(new HashMap<>(), ownerAuth))
.isInstanceOf(MateClawException.class)
.matches(t -> ((MateClawException) t).getCode() == 400);
}
@Test
@DisplayName("spawn-pause — owner toggles flag and audit captures decision")
void spawnPauseOwnerToggle() {
Map<String, Object> body = new HashMap<>();
body.put("parentConversationId", "parent-1");
body.put("paused", true);
R<Map<String, Object>> resp = controller.setPaused(body, ownerAuth);
assertThat(resp.getData()).containsEntry("paused", true);
assertThat(registry.isSpawnPaused("parent-1")).isTrue();
verify(auditEventService).record(eq("subagent.spawn-pause"), eq("conversation"),
eq("parent-1"), eq("parent-1"), anyString());
body.put("paused", false);
controller.setPaused(body, ownerAuth);
assertThat(registry.isSpawnPaused("parent-1")).isFalse();
}
@Test
@DisplayName("spawn-pause — non-owner gets 403, flag is not changed")
void spawnPauseNonOwnerForbidden() {
Map<String, Object> body = new HashMap<>();
body.put("parentConversationId", "parent-1");
body.put("paused", true);
assertThatThrownBy(() -> controller.setPaused(body, outsiderAuth))
.isInstanceOf(MateClawException.class)
.matches(t -> ((MateClawException) t).getCode() == 403);
assertThat(registry.isSpawnPaused("parent-1")).isFalse();
}
@Test
@DisplayName("listActive — missing parentConversationId throws 400")
void listActiveMissingParent() {
assertThatThrownBy(() -> controller.listActive(null, ownerAuth))
.isInstanceOf(MateClawException.class)
.matches(t -> ((MateClawException) t).getCode() == 400);
assertThatThrownBy(() -> controller.listActive("", ownerAuth))
.isInstanceOf(MateClawException.class)
.matches(t -> ((MateClawException) t).getCode() == 400);
}
@Test
@DisplayName("listActive — owner sees only their own subagents in the response")
void listActiveOwnerScoped() {
registry.register("parent-1", "child-1", 7L, "g", null);
registry.register("parent-1", "child-2", 7L, "g2", null);
registry.register("other-parent", "child-x", 8L, "g3", null);
R<Map<String, Object>> resp = controller.listActive("parent-1", ownerAuth);
@SuppressWarnings("unchecked")
List<Map<String, Object>> subagents = (List<Map<String, Object>>) resp.getData().get("subagents");
assertThat(subagents).hasSize(2);
assertThat(subagents).allSatisfy(dto -> {
assertThat(dto.get("parentConversationId")).isEqualTo("parent-1");
// Disposable + raw atomic refs must not leak into the wire DTO.
assertThat(dto).doesNotContainKey("disposable");
});
}
@Test
@DisplayName("listActive — non-owner is denied 403")
void listActiveNonOwnerForbidden() {
registry.register("parent-1", "child-1", 7L, "g", null);
assertThatThrownBy(() -> controller.listActive("parent-1", outsiderAuth))
.isInstanceOf(MateClawException.class)
.matches(t -> ((MateClawException) t).getCode() == 403);
}
}

View File

@ -0,0 +1,145 @@
package vip.mate.agent.delegation;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import vip.mate.channel.web.ChatStreamTracker;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class SubagentHeartbeatTest {
private SubagentRegistry registry;
private SubagentHeartbeatConfig cfg;
private ChatStreamTracker streamTracker;
private SubagentHeartbeat heartbeat;
@BeforeEach
void setUp() {
registry = new SubagentRegistry();
cfg = new SubagentHeartbeatConfig();
// Tight thresholds keep tests fast.
cfg.setIntervalSec(30);
cfg.setStaleCyclesIdle(3);
cfg.setStaleCyclesInTool(5);
streamTracker = mock(ChatStreamTracker.class);
heartbeat = new SubagentHeartbeat(registry, cfg, streamTracker);
}
@Test
@DisplayName("idle child flips to stale exactly at the configured idle threshold")
void idleChildBecomesStale() {
String id = registry.register("parent-1", "child-1", 1L, "g", null);
// No tool, no phase change across cycles idle path.
when(streamTracker.getRunningToolName("child-1")).thenReturn(null);
when(streamTracker.getCurrentPhase("child-1")).thenReturn("thinking");
var rec = registry.get(id).orElseThrow();
// Cycle 1: first observation seeds lastSeen, no stale increment.
heartbeat.evaluate(rec);
assertThat(rec.staleCount().get()).isEqualTo(0);
assertThat(rec.status().get()).isEqualTo("running");
// Cycles 2 and 3: no change counter increments to 1, then 2.
heartbeat.evaluate(rec);
heartbeat.evaluate(rec);
assertThat(rec.staleCount().get()).isEqualTo(2);
assertThat(rec.status().get()).isEqualTo("running");
verify(streamTracker, never()).broadcastObject(anyString(), eq("subagent_stale"), any());
// Cycle 4: counter hits 3 stale and event broadcast.
heartbeat.evaluate(rec);
assertThat(rec.status().get()).isEqualTo("stale");
verify(streamTracker, times(1)).broadcastObject(eq("parent-1"), eq("subagent_stale"), any());
}
@Test
@DisplayName("in-tool child uses the longer in-tool threshold before stale fires")
void inToolChildUsesLongerThreshold() {
String id = registry.register("parent-2", "child-2", 1L, "g", null);
when(streamTracker.getRunningToolName("child-2")).thenReturn("read_file");
when(streamTracker.getCurrentPhase("child-2")).thenReturn("action");
var rec = registry.get(id).orElseThrow();
// Cycle 1 seeds lastSeen (no increment). Each subsequent no-change
// tick increments staleCount by 1; staleCyclesInTool=5 fires when
// the counter HITS 5. So we need 1 seed + 5 increment ticks.
heartbeat.evaluate(rec); // seed
for (int i = 0; i < 5; i++) {
heartbeat.evaluate(rec);
}
assertThat(rec.status().get()).isEqualTo("stale");
verify(streamTracker, times(1)).broadcastObject(eq("parent-2"), eq("subagent_stale"), any());
}
@Test
@DisplayName("phase or tool change resets stale counter")
void progressResetsCounter() {
String id = registry.register("parent-3", "child-3", 1L, "g", null);
var rec = registry.get(id).orElseThrow();
when(streamTracker.getRunningToolName("child-3")).thenReturn(null);
when(streamTracker.getCurrentPhase("child-3")).thenReturn("thinking");
heartbeat.evaluate(rec); // seed
heartbeat.evaluate(rec); // +1
heartbeat.evaluate(rec); // +2
assertThat(rec.staleCount().get()).isEqualTo(2);
// Phase change counter resets.
when(streamTracker.getCurrentPhase("child-3")).thenReturn("action");
heartbeat.evaluate(rec);
assertThat(rec.staleCount().get()).isEqualTo(0);
// Tool change while staying in same phase also resets.
when(streamTracker.getRunningToolName("child-3")).thenReturn("read_file");
heartbeat.evaluate(rec); // (tool changed) reset
assertThat(rec.staleCount().get()).isEqualTo(0);
}
@Test
@DisplayName("heartbeat skips non-running records")
void skipsNonRunning() {
String id = registry.register("parent-4", "child-4", 1L, "g", null);
registry.get(id).orElseThrow().status().set("interrupted");
heartbeat.check();
verify(streamTracker, never()).getRunningToolName(anyString());
verify(streamTracker, never()).broadcastObject(anyString(), anyString(), any());
}
@Test
@DisplayName("subagent_stale payload carries id, cycles, lastTool, elapsedMs")
void stalePayloadShape() {
cfg.setStaleCyclesIdle(2);
String id = registry.register("parent-5", "child-5", 1L, "g", null);
when(streamTracker.getRunningToolName("child-5")).thenReturn(null);
when(streamTracker.getCurrentPhase("child-5")).thenReturn("thinking");
var rec = registry.get(id).orElseThrow();
heartbeat.evaluate(rec); // seed
heartbeat.evaluate(rec); // +1
heartbeat.evaluate(rec); // +2 stale
ArgumentCaptor<Object> captor = ArgumentCaptor.forClass(Object.class);
verify(streamTracker).broadcastObject(eq("parent-5"), eq("subagent_stale"), captor.capture());
@SuppressWarnings("unchecked")
Map<String, Object> payload = (Map<String, Object>) captor.getValue();
assertThat(payload).containsKeys("subagentId", "cycles", "lastTool", "elapsedMs");
assertThat(payload.get("subagentId")).isEqualTo(id);
}
}

View File

@ -0,0 +1,159 @@
package vip.mate.agent.delegation;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import reactor.core.Disposable;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.regex.Pattern;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class SubagentRegistryTest {
/** ID format: sa-<epoch_ms>-<8 lowercase hex> */
private static final Pattern ID_PATTERN = Pattern.compile("^sa-\\d+-[0-9a-f]{8}$");
private SubagentRegistry registry;
@BeforeEach
void setUp() {
registry = new SubagentRegistry();
}
@Test
@DisplayName("register assigns matching ID, snapshot finds it, unregister drops it")
void registerSnapshotUnregister() {
Disposable d = mock(Disposable.class);
String id = registry.register("parent-1", "child-1", 7L, "do thing", d);
assertThat(id).matches(ID_PATTERN);
assertThat(registry.get(id)).isPresent();
assertThat(registry.snapshot("parent-1")).hasSize(1);
assertThat(registry.snapshot("parent-1").get(0).childConversationId()).isEqualTo("child-1");
assertThat(registry.allActive()).hasSize(1);
registry.unregister(id);
assertThat(registry.get(id)).isEmpty();
assertThat(registry.snapshot("parent-1")).isEmpty();
}
@Test
@DisplayName("snapshot filters by parent — siblings under other parents are not visible")
void snapshotFiltersByParent() {
registry.register("parent-A", "ca-1", 1L, "task", null);
registry.register("parent-A", "ca-2", 1L, "task", null);
registry.register("parent-B", "cb-1", 1L, "task", null);
assertThat(registry.snapshot("parent-A")).hasSize(2);
assertThat(registry.snapshot("parent-B")).hasSize(1);
assertThat(registry.snapshot("parent-C")).isEmpty();
assertThat(registry.snapshot(null)).isEmpty();
}
@Test
@DisplayName("interrupt flips status, disposes subscription, returns false for missing/null")
void interruptBehaviour() {
Disposable disposable = mock(Disposable.class);
when(disposable.isDisposed()).thenReturn(false);
String id = registry.register("p", "c", 1L, "g", disposable);
assertThat(registry.interrupt(id)).isTrue();
assertThat(registry.get(id)).isPresent();
assertThat(registry.get(id).get().status().get()).isEqualTo("interrupted");
verify(disposable).dispose();
// Already-disposed subscription is not disposed again.
when(disposable.isDisposed()).thenReturn(true);
registry.interrupt(id);
verify(disposable).dispose(); // still only the first call
assertThat(registry.interrupt("does-not-exist")).isFalse();
assertThat(registry.interrupt(null)).isFalse();
}
@Test
@DisplayName("interrupt with null disposable does not throw")
void interruptNullDisposable() {
String id = registry.register("p", "c", 1L, "g", null);
assertThat(registry.interrupt(id)).isTrue();
assertThat(registry.get(id).get().status().get()).isEqualTo("interrupted");
}
@Test
@DisplayName("setSpawnPaused is scoped per parent — pausing A does not pause B")
void spawnPauseIsParentScoped() {
registry.setSpawnPaused("parent-A", true);
assertThat(registry.isSpawnPaused("parent-A")).isTrue();
assertThat(registry.isSpawnPaused("parent-B")).isFalse();
registry.setSpawnPaused("parent-A", false);
assertThat(registry.isSpawnPaused("parent-A")).isFalse();
// Null inputs are tolerated and never report paused.
assertThat(registry.isSpawnPaused(null)).isFalse();
assertThat(registry.setSpawnPaused(null, true)).isFalse();
}
@Test
@DisplayName("concurrent register from many threads produces unique IDs and no record loss")
void concurrentRegister() throws Exception {
int threads = 16;
int perThread = 50;
ExecutorService pool = Executors.newFixedThreadPool(threads);
CountDownLatch start = new CountDownLatch(1);
Set<String> ids = java.util.Collections.synchronizedSet(new HashSet<>());
for (int t = 0; t < threads; t++) {
final int tid = t;
pool.submit(() -> {
try { start.await(); } catch (InterruptedException e) { return; }
for (int i = 0; i < perThread; i++) {
String id = registry.register("parent-" + tid, "child-" + tid + "-" + i,
(long) i, "g", null);
ids.add(id);
}
});
}
start.countDown();
pool.shutdown();
assertThat(pool.awaitTermination(10, TimeUnit.SECONDS)).isTrue();
assertThat(ids).hasSize(threads * perThread);
assertThat(registry.allActive()).hasSize(threads * perThread);
// Each parent owns exactly perThread children.
for (int t = 0; t < threads; t++) {
assertThat(registry.snapshot("parent-" + t)).hasSize(perThread);
}
}
@Test
@DisplayName("get on null / missing returns empty Optional")
void getNullSafe() {
assertThat(registry.get(null)).isEmpty();
assertThat(registry.get("nope")).isEmpty();
}
@Test
@DisplayName("unregister on null / missing is a no-op")
void unregisterNullSafe() {
registry.register("p", "c", 1L, "g", null);
registry.unregister(null);
registry.unregister("does-not-exist");
assertThat(registry.allActive()).hasSize(1);
}
}

View File

@ -0,0 +1,219 @@
package vip.mate.agent.graph;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
/**
* Pin {@link NodeStreamingChatHelper#hasRepeatingSuffix} the cheap
* loop detector that catches reasoning-mode models (qwen3.6, deepseek-r1)
* stuck emitting the same final-answer paragraph over and over.
*
* <p>Real failure pattern from production: model alternates English
* "Wait, I should X. Done. I will write the response." with the same
* Chinese answer, dozens of times, until {@code max_tokens} runs out.
* Without this guard the user waits for a wall of duplicated text;
* with it, the stream stops at the third or fourth copy and the
* already-accumulated content gets returned as a partial answer.
*
* <p>The detector probes periods from 24 chars (anything shorter would
* false-positive on natural phrases) up to 240 chars; 4 verbatim
* consecutive copies is the threshold (3-times structured outputs like
* "TL;DR / body / TL;DR again" should pass through).
*/
class ContentRepetitionGuardTest {
private static final int MIN_PERIOD = 24;
private static final int MAX_PERIOD = 240;
private static final int MIN_OCCURRENCES = 4;
@Test
@DisplayName("non-cyclic prose with varied sentences does NOT trip")
void naturalProseDoesNotTrip() {
// Real writing: each sentence is unique, no consecutive paragraph
// repeats anywhere in the buffer.
String prose = "MateClaw 是一个企业级 AI 助手。它支持多种渠道接入,包括企业微信、"
+ "飞书、钉钉。Agent 通过 StateGraph 编排,可以调用工具、生成图片、查询知识库。"
+ "用户可以在 Web 控制台、桌面 App 或群聊里发起对话。系统记忆采用三档分层:"
+ "PROFILE.md 记录用户画像、MEMORY.md 沉淀稳定事实、memory/YYYY-MM-DD.md "
+ "保存当日上下文。审批流程基于 Spring AI Alibaba Graph工具调用前会被守卫拦截"
+ "高风险操作必须由用户显式批准才能执行。会话与频道之间是多对多关系。";
assertFalse(NodeStreamingChatHelper.hasRepeatingSuffix(
prose, MIN_PERIOD, MAX_PERIOD, MIN_OCCURRENCES));
}
@Test
@DisplayName("verbatim short paragraph repeated 4× → trips")
void verbatimQuadrupleRepeatTrips() {
// The exact production failure mode: 50-char Chinese answer repeated.
String paragraph = "收到语音啦!想查昨天的天气没问题,告诉我城市名我马上帮你查!\n";
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 5; i++) sb.append(paragraph);
assertTrue(NodeStreamingChatHelper.hasRepeatingSuffix(
sb, MIN_PERIOD, MAX_PERIOD, MIN_OCCURRENCES),
"5 verbatim ~30-char paragraphs in a row should trip");
}
@Test
@DisplayName("3 verbatim repeats stay UNDER threshold (legitimate triple-mention pattern)")
void threeRepeatsBelowThreshold() {
// Some legitimate outputs repeat structured summaries 2-3 times
// (e.g. "TL;DR" + body + "TL;DR" again). The threshold of 4
// gives breathing room so these don't false-positive.
String paragraph = "请告诉我您所在的城市,例如北京、上海或深圳,我可以为您查询天气。\n";
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 3; i++) sb.append(paragraph);
assertFalse(NodeStreamingChatHelper.hasRepeatingSuffix(
sb, MIN_PERIOD, MAX_PERIOD, MIN_OCCURRENCES),
"3 verbatim paragraphs must NOT trip — preserves triple-mention outputs");
}
@Test
@DisplayName("interleaved English thinking + Chinese answer pattern still trips")
void interleavedRepetitionTrips() {
// Mirrors the production trace exactly: English thinking
// alternating with the same Chinese answer. The combined
// "thinking + answer" unit is the actual repeating period.
String unit = "Wait, I should write.\nOkay.\n收到语音啦告诉我城市名我马上帮你查\n";
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 5; i++) sb.append(unit);
assertTrue(NodeStreamingChatHelper.hasRepeatingSuffix(
sb, MIN_PERIOD, MAX_PERIOD, MIN_OCCURRENCES),
"5 verbatim 'thinking + answer' cycles should trip");
}
@Test
@DisplayName("empty / short / null content returns false (fast path)")
void shortContentDoesNotTrip() {
assertFalse(NodeStreamingChatHelper.hasRepeatingSuffix(
null, MIN_PERIOD, MAX_PERIOD, MIN_OCCURRENCES));
assertFalse(NodeStreamingChatHelper.hasRepeatingSuffix(
"", MIN_PERIOD, MAX_PERIOD, MIN_OCCURRENCES));
assertFalse(NodeStreamingChatHelper.hasRepeatingSuffix(
"hi there", MIN_PERIOD, MAX_PERIOD, MIN_OCCURRENCES));
// Just under MIN_PERIOD × MIN_OCCURRENCES can't possibly match.
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 80; i++) sb.append('x');
assertFalse(NodeStreamingChatHelper.hasRepeatingSuffix(
sb, MIN_PERIOD, MAX_PERIOD, MIN_OCCURRENCES));
}
@Test
@DisplayName("trailing repeat after long preamble: detects only the looping suffix")
void detectsLoopAfterPreamble() {
// Realistic: model produces a long valid answer, then enters a
// loop appending the same trailer. The detector must catch the
// loop even though the buffer prefix has perfectly varied text.
StringBuilder sb = new StringBuilder();
sb.append("好的,我已经为您完成了任务,下面是详细的执行结果:\n");
sb.append("第一步,我读取了配置文件并解析了内容。\n");
sb.append("第二步,我调用了天气查询接口拿到了原始数据。\n");
sb.append("第三步,我将结果格式化为人类可读的中文文本。\n");
// Now the model gets stuck repeating a closing phrase.
String trailer = "如有其他问题,请随时告诉我,我会尽快为您解答和处理。\n";
for (int i = 0; i < 5; i++) sb.append(trailer);
assertTrue(NodeStreamingChatHelper.hasRepeatingSuffix(
sb, MIN_PERIOD, MAX_PERIOD, MIN_OCCURRENCES),
"trailing 5×-repeated trailer must trip even after long preamble");
}
@Test
@DisplayName("single-char fill (200x 'a') does NOT trip — too short to be a real period")
void singleCharFillDoesNotTrip() {
// 'aaaa...' could be parsed as period=1 with 200 occurrences,
// but our floor is MIN_PERIOD=24, so a literal 24-char run of
// 'a' would need to repeat 4× which is just one continuous
// run of 96 'a' chars. That's a degenerate case; mark as
// not-tripping-via-this-detector since it's not the "self-
// arguing loop" failure mode (a model emitting 'aaaaaaa...'
// would hit max_tokens harmlessly without any degradation
// worth user attention).
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 200; i++) sb.append('a');
// 200 'a' chars: period=24 unit is "aaaa...a" (24 of them).
// The prior 24-char block is also "aaa...a" (24 of them).
// So they DO match. This trips. Document the behavior it's
// mostly harmless because models don't actually loop on single
// chars.
assertTrue(NodeStreamingChatHelper.hasRepeatingSuffix(
sb, MIN_PERIOD, MAX_PERIOD, MIN_OCCURRENCES),
"documented behavior: pure single-char fills DO trip; not a real failure mode in practice");
}
@Test
@DisplayName("invalid args return false defensively")
void invalidArgsReturnFalse() {
assertFalse(NodeStreamingChatHelper.hasRepeatingSuffix("text", 0, 100, 4));
assertFalse(NodeStreamingChatHelper.hasRepeatingSuffix("text", 24, 240, 1));
// maxPeriod < minPeriod
assertFalse(NodeStreamingChatHelper.hasRepeatingSuffix("text", 100, 50, 4));
}
// ===== dedupTrailingRepeats =====
//
// Once the loop guard fires, the streamed text has already gone out
// (SSE chunks can't be unsent), but the DB-persisted final answer +
// IM channel reply should show ONE clean copy of the looping unit
// instead of the wall the user just watched scroll by.
@Test
@DisplayName("dedup: 5 verbatim copies → 1 copy")
void dedupCollapsesRepeats() {
String unit = "收到语音啦!想查昨天的天气没问题,告诉我城市名我马上帮你查!\n";
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 5; i++) sb.append(unit);
String result = NodeStreamingChatHelper.dedupTrailingRepeats(sb.toString(), MIN_PERIOD, MAX_PERIOD);
assertEquals(unit, result, "5 copies should collapse to exactly 1");
}
@Test
@DisplayName("dedup: prefix + repeated trailer → prefix + 1 copy of trailer")
void dedupPreservesPrefixCollapseTrailer() {
String prefix = "好的,下面是详细回答:第一步完成了。第二步也完成了。下面是结论。\n";
String trailer = "如有其他问题请随时告诉我,我会尽快为您解答处理。\n";
StringBuilder sb = new StringBuilder(prefix);
for (int i = 0; i < 5; i++) sb.append(trailer);
String result = NodeStreamingChatHelper.dedupTrailingRepeats(sb.toString(), MIN_PERIOD, MAX_PERIOD);
assertEquals(prefix + trailer, result,
"prefix preserved verbatim; trailer collapses 5×→1×");
}
@Test
@DisplayName("dedup: no trailing repeats → buffer unchanged")
void dedupNoRepeatsUnchanged() {
String prose = "这是一段没有任何尾部重复的正常回答,包含多个不同的句子和话题。"
+ "我们讨论了天气、新闻、技术,每段内容都不同。";
assertEquals(prose,
NodeStreamingChatHelper.dedupTrailingRepeats(prose, MIN_PERIOD, MAX_PERIOD));
}
@Test
@DisplayName("dedup: only 1 copy at end (no actual repetition) → unchanged")
void dedupSingleCopyUnchanged() {
String unit = "请告诉我您所在的城市,我帮您查询。";
// Just one copy at the tail nothing to collapse.
assertEquals(unit,
NodeStreamingChatHelper.dedupTrailingRepeats(unit, MIN_PERIOD, MAX_PERIOD));
}
@Test
@DisplayName("dedup: empty / null inputs return as-is")
void dedupEmptyOrNull() {
assertNull(NodeStreamingChatHelper.dedupTrailingRepeats(null, MIN_PERIOD, MAX_PERIOD));
assertEquals("", NodeStreamingChatHelper.dedupTrailingRepeats("", MIN_PERIOD, MAX_PERIOD));
}
@Test
@DisplayName("dedup: 2 copies (the minimum trip threshold) → 1 copy")
void dedupTwoCopiesCollapse() {
// dedup uses 2+ copies as its trigger (vs. hasRepeatingSuffix's 4×
// detection threshold). Once the guard has decided the buffer is
// looping, even a 2× tail should be collapsed since we know
// structurally the model is mid-loop.
String unit = "如果您还有任何其他疑问,欢迎随时联系我,我会尽快回复。";
String input = unit + unit;
assertEquals(unit,
NodeStreamingChatHelper.dedupTrailingRepeats(input, MIN_PERIOD, MAX_PERIOD));
}
}

View File

@ -108,4 +108,86 @@ class ErrorClassificationTest {
assertEquals(NodeStreamingChatHelper.ErrorType.CLIENT_ERROR,
classify(new RuntimeException("400 Bad Request: malformed JSON")));
}
// ===== Transient TLS / IO errors SERVER_ERROR (retryable) =====
//
// Without these, a single TLS handshake hiccup or socket reset mid-stream
// surfaces to the user as "LLM 调用失败" with zero retries the existing
// exponential-backoff loop only triggers on RATE_LIMIT / SERVER_ERROR.
// Routing them through SERVER_ERROR gives them ~3s/6s/12s retry budget,
// which is enough to absorb transient network glitches without user impact.
@Test
@DisplayName("SSL bad_record_mac (RFC 5246 fatal alert 20) → SERVER_ERROR")
void sslBadRecordMacIsServerError() throws Exception {
// Real-world chain: WebClientRequestException SSLException("Received
// fatal alert: bad_record_mac"). The leaf message contains
// bad_record_mac, the wrapper contributes SSLException class name.
javax.net.ssl.SSLException sslEx = new javax.net.ssl.SSLException(
"Received fatal alert: bad_record_mac");
assertEquals(NodeStreamingChatHelper.ErrorType.SERVER_ERROR,
classify(new RuntimeException("(bad_record_mac) Received fatal alert", sslEx)));
}
@Test
@DisplayName("plain SSLException class in chain → SERVER_ERROR")
void sslExceptionClassIsServerError() throws Exception {
// extractFullErrorChain appends getClass().getSimpleName(), so even
// an SSLException without a recognizable message text gets matched
// via the class name token.
assertEquals(NodeStreamingChatHelper.ErrorType.SERVER_ERROR,
classify(new javax.net.ssl.SSLException("handshake aborted")));
}
@Test
@DisplayName("SSLHandshakeException → SERVER_ERROR")
void sslHandshakeExceptionIsServerError() throws Exception {
assertEquals(NodeStreamingChatHelper.ErrorType.SERVER_ERROR,
classify(new javax.net.ssl.SSLHandshakeException("Remote host closed connection during handshake")));
}
@Test
@DisplayName("SocketException (peer reset mid-stream) → SERVER_ERROR")
void socketExceptionIsServerError() throws Exception {
assertEquals(NodeStreamingChatHelper.ErrorType.SERVER_ERROR,
classify(new java.net.SocketException("Connection reset by peer")));
}
@Test
@DisplayName("Reactor Netty 'Connection prematurely closed' → SERVER_ERROR")
void prematureCloseIsServerError() throws Exception {
assertEquals(NodeStreamingChatHelper.ErrorType.SERVER_ERROR,
classify(new RuntimeException("Connection prematurely closed BEFORE response")));
}
@Test
@DisplayName("Broken pipe (server cut TCP write half) → SERVER_ERROR")
void brokenPipeIsServerError() throws Exception {
assertEquals(NodeStreamingChatHelper.ErrorType.SERVER_ERROR,
classify(new java.io.IOException("Broken pipe")));
}
@Test
@DisplayName("WebClientRequestException with SSL cause → SERVER_ERROR (not UNKNOWN)")
void webClientRequestSslIsServerError() throws Exception {
// The exact production failure pattern: Reactor wraps the SSL leaf in
// WebClientRequestException. The chain walker sees both the wrapper
// class name AND the leaf SSLException class name, and the message
// string carries bad_record_mac.
Throwable cause = new javax.net.ssl.SSLException("Received fatal alert: bad_record_mac");
Throwable wrapped = new RuntimeException(
"WebClientRequestException: bad_record_mac; nested exception", cause);
assertEquals(NodeStreamingChatHelper.ErrorType.SERVER_ERROR, classify(wrapped));
}
@Test
@DisplayName("AUTH still wins over TLS chain (real auth failure not masked)")
void authStillWinsOverTlsChain() throws Exception {
// A 401 response wrapped by Reactor still carries WebClientResponseException
// in the chain the classifier must not see "WebClient*Exception" and
// demote it to SERVER_ERROR. AUTH_ERROR is checked before SERVER_ERROR
// in classifyError(), so 401 keywords win.
assertEquals(NodeStreamingChatHelper.ErrorType.AUTH_ERROR,
classify(new RuntimeException("401 Unauthorized: Invalid API Key (WebClientResponseException)")));
}
}

View File

@ -0,0 +1,246 @@
package vip.mate.agent.graph;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.metadata.ChatGenerationMetadata;
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;
import vip.mate.channel.web.ChatStreamTracker;
import java.util.List;
import java.util.concurrent.CancellationException;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
/**
* Regression tests for Lane D performance fixes (RFC 06-lane-d-performance-fixes).
*
* <ul>
* <li>D-1: Backoff sleep responds to Stop signal within 100ms</li>
* <li>D-2: RATE_LIMIT/SERVER_ERROR retries capped at 2 (was 5)</li>
* </ul>
*/
class LaneDPerformanceFixesTest {
private ChatStreamTracker streamTracker;
@BeforeEach
void setUp() {
streamTracker = mock(ChatStreamTracker.class);
when(streamTracker.isStopRequested(any())).thenReturn(false);
}
private NodeStreamingChatHelper helper(ChatModel primary) {
return new NodeStreamingChatHelper(streamTracker, List.of(), null);
}
private static Prompt smallPrompt() {
return new Prompt(List.of(new UserMessage("hi")));
}
private static ChatModel successModel(String text) {
ChatModel m = mock(ChatModel.class);
Generation gen = new Generation(new AssistantMessage(text), ChatGenerationMetadata.NULL);
ChatResponse resp = mock(ChatResponse.class);
when(resp.getResults()).thenReturn(List.of(gen));
when(resp.getResult()).thenReturn(gen);
when(resp.getMetadata()).thenReturn(null);
when(m.stream(any(Prompt.class))).thenReturn(Flux.just(resp));
return m;
}
private static ChatModel rateLimitModel() {
ChatModel m = mock(ChatModel.class);
when(m.stream(any(Prompt.class))).thenReturn(
Flux.error(new RuntimeException("429 Too Many Requests: rate limit exceeded")));
return m;
}
// ============================================================
// D-1: Backoff sleep responds to Stop signal
// ============================================================
@Nested
@DisplayName("D-1: Backoff sleep responds to Stop signal")
class BackoffStopSignalTests {
@Test
@DisplayName("Stop requested during backoff aborts retry quickly with CancellationException")
void stopDuringBackoffAbortsRetry() {
// Arrange: model always returns rate-limit error to trigger backoff
AtomicInteger callCount = new AtomicInteger(0);
ChatModel model = mock(ChatModel.class);
when(model.stream(any(Prompt.class))).thenAnswer(inv -> {
callCount.incrementAndGet();
return Flux.error(new RuntimeException("429 Too Many Requests"));
});
// Stop is requested after first call during backoff sleep.
// First poll returns false (initial check before sleep loop starts),
// then true on subsequent checks to simulate user clicking stop.
AtomicInteger stopCheckCount = new AtomicInteger(0);
when(streamTracker.isStopRequested("conv-d1")).thenAnswer(inv ->
stopCheckCount.incrementAndGet() > 2);
var helper = helper(model);
long startMs = System.currentTimeMillis();
// The stop-during-backoff path throws CancellationException
assertThrows(CancellationException.class, () ->
helper.streamCall(model, smallPrompt(), "conv-d1", "reasoning"));
long elapsedMs = System.currentTimeMillis() - startMs;
// The backoff for attempt 1 is 3000ms base. With stop polling at 100ms intervals,
// it should abort well before the full 3000ms backoff completes.
assertTrue(elapsedMs < 2000,
"Stop should abort backoff quickly, but took " + elapsedMs + "ms");
// The model should only have been called once (first attempt fails, backoff
// for second attempt is interrupted by stop)
assertEquals(1, callCount.get(),
"Model should only be called once before stop aborts the backoff");
}
@Test
@DisplayName("Normal flow without stop completes backoff normally")
void normalFlowWithoutStopCompletesBackoff() {
// First call: rate limit; second call: success
AtomicInteger callCount = new AtomicInteger(0);
ChatModel model = mock(ChatModel.class);
when(model.stream(any(Prompt.class))).thenAnswer(inv -> {
if (callCount.incrementAndGet() == 1) {
return Flux.error(new RuntimeException("429 Too Many Requests"));
}
Generation gen = new Generation(new AssistantMessage("ok"), ChatGenerationMetadata.NULL);
ChatResponse resp = mock(ChatResponse.class);
when(resp.getResults()).thenReturn(List.of(gen));
when(resp.getResult()).thenReturn(gen);
when(resp.getMetadata()).thenReturn(null);
return Flux.just(resp);
});
// Stop never requested
when(streamTracker.isStopRequested(any())).thenReturn(false);
var helper = helper(model);
var result = helper.streamCall(model, smallPrompt(), "conv-d1b", "reasoning");
assertEquals("ok", result.text(), "Second attempt should succeed");
assertEquals(2, callCount.get(), "Model should be called twice (fail + succeed)");
}
}
// ============================================================
// D-2: RATE_LIMIT retries capped at 2
// ============================================================
@Nested
@DisplayName("D-2: RATE_LIMIT/SERVER_ERROR retries capped at 2")
class RateLimitRetryCapTests {
@Test
@DisplayName("RATE_LIMIT error retries at most 2 times before giving up")
void rateLimitMaxTwoRetries() {
AtomicInteger callCount = new AtomicInteger(0);
ChatModel model = mock(ChatModel.class);
when(model.stream(any(Prompt.class))).thenAnswer(inv -> {
callCount.incrementAndGet();
return Flux.error(new RuntimeException("429 Too Many Requests: rate limit"));
});
var helper = helper(model);
var result = helper.streamCall(model, smallPrompt(), "conv-d2a", "reasoning");
// With MAX_RETRIES_RATE_LIMIT=2, attempts are: 0, 1, 2 = 3 total calls
assertTrue(callCount.get() <= 3,
"RATE_LIMIT should retry at most 2 times (3 total calls), but got " + callCount.get());
assertNotEquals(NodeStreamingChatHelper.ErrorType.NONE, result.errorType(),
"Result should be an error after exhausting retries");
}
@Test
@DisplayName("SERVER_ERROR keeps full MAX_RETRIES=5 (not capped like RATE_LIMIT)")
void serverErrorKeepsFullRetries() {
AtomicInteger callCount = new AtomicInteger(0);
ChatModel model = mock(ChatModel.class);
when(model.stream(any(Prompt.class))).thenAnswer(inv -> {
callCount.incrementAndGet();
return Flux.error(new RuntimeException("500 Internal Server Error"));
});
var helper = helper(model);
var result = helper.streamCall(model, smallPrompt(), "conv-d2b", "reasoning");
// SERVER_ERROR should use the full MAX_RETRIES=5 (6 total calls: attempt 0-5),
// NOT the reduced MAX_RETRIES_RATE_LIMIT=2.
assertTrue(callCount.get() > 3,
"SERVER_ERROR should retry more than RATE_LIMIT (>3 calls), but got " + callCount.get());
assertEquals(6, callCount.get(),
"SERVER_ERROR should try 6 times total (attempt 0 through 5)");
}
@Test
@DisplayName("AUTH_ERROR is not retried (unchanged behavior)")
void authErrorNotRetried() {
AtomicInteger callCount = new AtomicInteger(0);
ChatModel model = mock(ChatModel.class);
when(model.stream(any(Prompt.class))).thenAnswer(inv -> {
callCount.incrementAndGet();
return Flux.error(new RuntimeException("401 Unauthorized"));
});
var helper = helper(model);
var result = helper.streamCall(model, smallPrompt(), "conv-d2c", "reasoning");
assertEquals(1, callCount.get(),
"AUTH_ERROR should not be retried at all");
assertEquals(NodeStreamingChatHelper.ErrorType.AUTH_ERROR, result.errorType());
}
}
// ============================================================
// D-3: broadcastProgress method exists and works
// ============================================================
@Nested
@DisplayName("D-3: broadcastProgress method")
class BroadcastProgressTests {
@Test
@DisplayName("broadcastProgress sends progress event via streamTracker")
void broadcastProgressSendsEvent() {
var helper = new NodeStreamingChatHelper(streamTracker);
helper.broadcastProgress("conv-d3", "分析中...");
verify(streamTracker, times(1)).broadcastObject(
eq("conv-d3"), eq("progress"), any());
}
@Test
@DisplayName("broadcastProgress is safe with null streamTracker")
void broadcastProgressNullTrackerNoOp() {
var helper = new NodeStreamingChatHelper(null);
// Should not throw
assertDoesNotThrow(() -> helper.broadcastProgress("conv-d3b", "分析中..."));
}
@Test
@DisplayName("broadcastProgress is safe with null conversationId")
void broadcastProgressNullConvIdNoOp() {
var helper = new NodeStreamingChatHelper(streamTracker);
assertDoesNotThrow(() -> helper.broadcastProgress(null, "分析中..."));
// Should not invoke streamTracker when conversationId is null
verify(streamTracker, never()).broadcastObject(any(), any(), any());
}
}
}

View File

@ -0,0 +1,115 @@
package vip.mate.agent.graph;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.metadata.ChatGenerationMetadata;
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;
import vip.mate.channel.web.ChatStreamTracker;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* Regression coverage for the thinking-only soft cap added in P0-2.
*
* <p>The cap disposes the upstream stream when the model has emitted
* {@code >= THINKING_ONLY_HARD_CAP_CHARS} of {@code reasoning_content}
* with zero visible content and zero tool calls. The risk noted during
* review (P1-A): some providers (Anthropic / DeepSeek-thinking variants)
* pack {@code reasoning_content} and a {@code tool_call} into the same
* SSE chunk. If the cap check sits inside the thinking-delta block (i.e.
* before the chunk's tool_call is accumulated) it would dispose just
* before observing the tool turning a request that was about to dispatch
* a tool into a spurious "INCOMPLETE: thinking-only" outcome.
*/
class NodeStreamingChatHelperThinkingCapTest {
private ChatStreamTracker streamTracker;
@BeforeEach
void setUp() {
streamTracker = mock(ChatStreamTracker.class);
when(streamTracker.isStopRequested(any())).thenReturn(false);
}
private static Prompt smallPrompt() {
return new Prompt(List.of(new UserMessage("hi")));
}
private static ChatModel singleChunkModel(AssistantMessage msg) {
Generation gen = new Generation(msg, ChatGenerationMetadata.NULL);
ChatResponse resp = mock(ChatResponse.class);
when(resp.getResults()).thenReturn(List.of(gen));
when(resp.getResult()).thenReturn(gen);
when(resp.getMetadata()).thenReturn(null);
ChatModel m = mock(ChatModel.class);
when(m.stream(any(Prompt.class))).thenReturn(Flux.just(resp));
return m;
}
@Test
@DisplayName("thinking >= cap + tool_call in same chunk: cap must NOT trigger; tool_call survives")
void thinkingAndToolCallSameChunk_doesNotTripSoftCap() {
// Build a single chunk that carries 40k thinking (well above the
// 32k cap) AND a tool call. With the buggy ordering this would
// dispose before accumulateToolCalls runs and the helper would
// return a partial "thinking_only_no_content" result.
String hugeThinking = "x".repeat(40_000);
AssistantMessage.ToolCall tc = new AssistantMessage.ToolCall(
"id-1", "function", "search", "{\"q\":\"foo\"}");
AssistantMessage msg = AssistantMessage.builder()
.content("")
.toolCalls(List.of(tc))
.properties(Map.of("reasoningContent", hugeThinking))
.build();
ChatModel m = singleChunkModel(msg);
var helper = new NodeStreamingChatHelper(streamTracker);
var result = helper.streamCall(m, smallPrompt(), "conv-thinking-tc", "reasoning");
assertTrue(result.hasToolCalls(),
"Tool call accompanying huge thinking in the same chunk must survive");
assertEquals(1, result.toolCalls().size());
assertEquals("search", result.toolCalls().get(0).name());
assertFalse(result.partial(),
"Result must not be marked partial when a tool_call was observed in the same chunk");
assertNotEquals("thinking_only_no_content", result.errorMessage(),
"Soft cap must not fire when the chunk carrying huge thinking also carried a tool call");
}
@Test
@DisplayName("thinking >= cap with NO tool_call and NO content: cap fires, result is partial+thinking_only_no_content")
void thinkingOnlyNoContent_capFires() {
// Symmetric positive case: confirms the cap still triggers in the
// genuine "深度思考 ... never finishes" scenario the cap was added for.
String hugeThinking = "y".repeat(40_000);
AssistantMessage msg = AssistantMessage.builder()
.content("")
.properties(Map.of("reasoningContent", hugeThinking))
.build();
ChatModel m = singleChunkModel(msg);
var helper = new NodeStreamingChatHelper(streamTracker);
var result = helper.streamCall(m, smallPrompt(), "conv-thinking-only", "reasoning");
assertFalse(result.hasToolCalls());
assertTrue(result.partial(), "Cap should mark the result as partial");
assertEquals("thinking_only_no_content", result.errorMessage());
assertEquals(hugeThinking, result.thinking(),
"Thinking transcript is preserved so the UI can show it in a collapse panel");
}
}

View File

@ -0,0 +1,122 @@
package vip.mate.agent.graph;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.metadata.ChatGenerationMetadata;
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;
import vip.mate.channel.web.ChatStreamTracker;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* Regression coverage for tool-call arguments sanitization.
*
* <p>Some OpenAI-compatible providers (aliyun-codingplan, others using the
* "coding" DashScope endpoint) reject the follow-up chat-completions request
* with HTTP 400 when the assistant message in history carries a tool call
* whose {@code function.arguments} is not parseable JSON. The streaming
* accumulator can produce empty or truncated argument strings, so the helper
* normalizes the final value to {@code "{}"} when it is missing or invalid.
*/
class NodeStreamingChatHelperToolCallArgsTest {
private ChatStreamTracker streamTracker;
@BeforeEach
void setUp() {
streamTracker = mock(ChatStreamTracker.class);
when(streamTracker.isStopRequested(any())).thenReturn(false);
}
private static Prompt smallPrompt() {
return new Prompt(List.of(new UserMessage("hi")));
}
private static ChatModel singleChunkModel(AssistantMessage msg) {
Generation gen = new Generation(msg, ChatGenerationMetadata.NULL);
ChatResponse resp = mock(ChatResponse.class);
when(resp.getResults()).thenReturn(List.of(gen));
when(resp.getResult()).thenReturn(gen);
when(resp.getMetadata()).thenReturn(null);
ChatModel m = mock(ChatModel.class);
when(m.stream(any(Prompt.class))).thenReturn(Flux.just(resp));
return m;
}
@Test
@DisplayName("Empty tool-call arguments normalized to '{}'")
void emptyArguments_replacedWithEmptyJsonObject() {
AssistantMessage.ToolCall tc = new AssistantMessage.ToolCall(
"id-empty", "function", "list_skills", "");
AssistantMessage msg = AssistantMessage.builder()
.content("")
.toolCalls(List.of(tc))
.build();
var helper = new NodeStreamingChatHelper(streamTracker);
var result = helper.streamCall(singleChunkModel(msg), smallPrompt(),
"conv-empty-args", "reasoning");
assertTrue(result.hasToolCalls(), "tool call must survive");
assertEquals(1, result.toolCalls().size());
assertEquals("{}", result.toolCalls().get(0).arguments(),
"empty arguments must be replaced with '{}' so strict providers "
+ "(aliyun-codingplan, ...) accept the follow-up request");
}
@Test
@DisplayName("Truncated/invalid JSON arguments normalized to '{}'")
void truncatedJsonArguments_replacedWithEmptyJsonObject() {
// Simulates a stream cut mid-token: model emitted '{"q":"hel' and stopped.
AssistantMessage.ToolCall tc = new AssistantMessage.ToolCall(
"id-truncated", "function", "search", "{\"q\":\"hel");
AssistantMessage msg = AssistantMessage.builder()
.content("")
.toolCalls(List.of(tc))
.build();
var helper = new NodeStreamingChatHelper(streamTracker);
var result = helper.streamCall(singleChunkModel(msg), smallPrompt(),
"conv-truncated-args", "reasoning");
assertTrue(result.hasToolCalls(), "tool call must survive");
assertEquals(1, result.toolCalls().size());
assertEquals("{}", result.toolCalls().get(0).arguments(),
"invalid JSON arguments must be replaced with '{}' so the follow-up "
+ "request stays well-formed");
}
@Test
@DisplayName("Valid JSON arguments preserved verbatim")
void validJsonArguments_preservedAsIs() {
String validArgs = "{\"query\":\"foo\",\"limit\":5}";
AssistantMessage.ToolCall tc = new AssistantMessage.ToolCall(
"id-valid", "function", "search", validArgs);
AssistantMessage msg = AssistantMessage.builder()
.content("")
.toolCalls(List.of(tc))
.build();
var helper = new NodeStreamingChatHelper(streamTracker);
var result = helper.streamCall(singleChunkModel(msg), smallPrompt(),
"conv-valid-args", "reasoning");
assertTrue(result.hasToolCalls());
assertEquals(1, result.toolCalls().size());
assertEquals(validArgs, result.toolCalls().get(0).arguments(),
"valid JSON arguments must not be rewritten");
}
}

View File

@ -1,114 +0,0 @@
package vip.mate.agent.graph;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
/**
* RepetitionDetector 单元测试
*/
class RepetitionDetectorTest {
private RepetitionDetector detector;
@BeforeEach
void setUp() {
detector = new RepetitionDetector();
}
@Test
@DisplayName("正常文本不触发重复检测")
void shouldNotTriggerForNormalText() {
assertFalse(detector.appendAndCheck("Hello, world! This is a normal response. "));
assertFalse(detector.appendAndCheck("It contains various sentences and ideas. "));
assertFalse(detector.appendAndCheck("No repetition should be detected here. "));
assertFalse(detector.appendAndCheck("The detector only flags degenerate patterns. "));
assertFalse(detector.isRepetitionDetected());
}
@Test
@DisplayName("短文本不触发检测(低于最小内容长度)")
void shouldNotTriggerForShortText() {
assertFalse(detector.appendAndCheck(""));
assertFalse(detector.appendAndCheck(""));
assertFalse(detector.appendAndCheck(""));
assertFalse(detector.isRepetitionDetected());
}
@Test
@DisplayName("连续重复相同片段触发检测")
void shouldTriggerForRepeatedPattern() {
// 构造足够长的前缀以超过最小检测长度
StringBuilder sb = new StringBuilder();
sb.append("这是一段正常的开头文本。".repeat(5));
detector.appendAndCheck(sb.toString());
// 现在重复同一模式多次
String pattern = "不吃香菜,喝冰美式。";
boolean triggered = false;
for (int i = 0; i < 20; i++) {
if (detector.appendAndCheck(pattern)) {
triggered = true;
break;
}
}
assertTrue(triggered, "Should detect repetition after many identical appends");
assertTrue(detector.isRepetitionDetected());
}
@Test
@DisplayName("检测到重复后持续返回 true")
void shouldKeepReturningTrueAfterDetection() {
// 直接构造重复内容
String pattern = "重复片段测试内容。";
StringBuilder bulk = new StringBuilder();
bulk.append("正常的前缀内容,长度足够。".repeat(5));
for (int i = 0; i < 20; i++) {
bulk.append(pattern);
}
detector.appendAndCheck(bulk.toString());
// 后续调用应该继续返回 true
assertTrue(detector.appendAndCheck("任何新内容"));
assertTrue(detector.isRepetitionDetected());
}
@Test
@DisplayName("reset 后重新检测")
void shouldResetState() {
// 先触发检测
String pattern = "重复片段测试。";
StringBuilder bulk = new StringBuilder("前缀".repeat(50));
for (int i = 0; i < 20; i++) {
bulk.append(pattern);
}
detector.appendAndCheck(bulk.toString());
// reset
detector.reset();
assertFalse(detector.isRepetitionDetected());
assertFalse(detector.appendAndCheck("正常的新内容"));
}
@Test
@DisplayName("null 和空字符串不触发也不异常")
void shouldHandleNullAndEmpty() {
assertFalse(detector.appendAndCheck(null));
assertFalse(detector.appendAndCheck(""));
assertFalse(detector.isRepetitionDetected());
}
@Test
@DisplayName("Unicode 中文重复模式正确检测")
void shouldDetectChineseRepetition() {
StringBuilder sb = new StringBuilder("初始化内容填充。".repeat(10));
String pattern = "已记住。以后涉及点餐时我会提醒你:";
for (int i = 0; i < 20; i++) {
sb.append(pattern);
}
boolean triggered = detector.appendAndCheck(sb.toString());
assertTrue(triggered, "Should detect Chinese character repetition");
}
}

View File

@ -0,0 +1,240 @@
package vip.mate.agent.graph;
import com.alibaba.cloud.ai.graph.OverAllState;
import org.junit.jupiter.api.DisplayName;
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.ToolResponseMessage;
import org.springframework.ai.chat.model.ToolContext;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.definition.ToolDefinition;
import org.springframework.ai.tool.metadata.ToolMetadata;
import vip.mate.agent.AgentToolSet;
import vip.mate.agent.graph.edge.ObservationDispatcher;
import vip.mate.agent.graph.executor.ToolExecutionExecutor;
import vip.mate.agent.graph.node.ActionNode;
import vip.mate.agent.graph.node.FinalAnswerNode;
import vip.mate.agent.graph.state.DirectToolOutput;
import vip.mate.tool.guard.ToolGuard;
import vip.mate.tool.guard.ToolGuardResult;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
import static vip.mate.agent.graph.state.MateClawStateKeys.*;
/**
* RFC-052 end-to-end chain test exercises the full graph traversal across
* three real components without mocking:
*
* <pre>
* ToolExecutionExecutor (executes returnDirect tool)
* writes ToolResponseMessage + events + directOutputs
* ActionNode (interprets ToolExecutionResult, sets RETURN_DIRECT_TRIGGERED)
* state mutation
* ObservationDispatcher (routes to FinalAnswerNode)
* edge decision
* FinalAnswerNode (assembles final answer from DIRECT_TOOL_OUTPUTS)
* produces FINAL_ANSWER + finishReason=RETURN_DIRECT
* </pre>
*
* <p>This complements the per-component unit tests by verifying the
* <em>composition</em> works: invariants flow correctly between nodes via
* {@link OverAllState}, no integration glue is missing, no state key is
* misnamed across boundaries.
*
* <p>What this does NOT test (still requires manual / SpringBootTest):
* <ul>
* <li>{@code StateGraphReActAgent} stream emission of {@code FINAL_ANSWER}
* as {@code content_delta}</li>
* <li>{@code StreamAccumulator} capturing {@code tool_direct_result} into
* {@code metadata.directToolNames} (covered by the manual demo)</li>
* <li>{@code BaseAgent.toSpringMessage} scrubbing on the next user turn
* (covered by {@code BaseAgentDirectToolHistoryScrubTest})</li>
* </ul>
*/
class ReturnDirectEndToEndTest {
private static final String SECRET =
"EMPLOYEE-SALARY-RECORD\n" +
"Name: Alice\n" +
"Base: 12345\n" +
"Bonus: 67890\n" +
"SSN: 999-88-7777";
@Test
@DisplayName("RFC-052 end-to-end: secret reaches FINAL_ANSWER verbatim, never enters LLM-bound messages")
void fullChain_directToolFlowsAcrossNodes() throws Exception {
// ===== Setup: real executor + real ActionNode + real Dispatcher + real FinalAnswerNode =====
ToolCallback directTool = stubCallback("query_employee_salary", true, args -> SECRET);
AgentToolSet toolSet = AgentToolSet.fromCallbacks(List.of(), List.of(directTool));
ToolGuard alwaysAllow = (n, a) -> ToolGuardResult.allow();
ToolExecutionExecutor executor = new ToolExecutionExecutor(toolSet, alwaysAllow, null, null);
ActionNode actionNode = new ActionNode(executor);
ObservationDispatcher dispatcher = new ObservationDispatcher();
FinalAnswerNode finalAnswerNode = new FinalAnswerNode();
// ===== Step 1: simulate ReasoningNode having decided to call the direct tool =====
AssistantMessage.ToolCall toolCall = new AssistantMessage.ToolCall(
"call_x", "function", "query_employee_salary", "{}");
Map<String, Object> initialState = new HashMap<>();
initialState.put(TOOL_CALLS, List.of(toolCall));
initialState.put(CONVERSATION_ID, "conv_e2e");
initialState.put(AGENT_ID, "agent_e2e");
OverAllState state1 = new OverAllState(initialState);
// ===== Step 2: ActionNode runs the executor =====
Map<String, Object> actionOut = actionNode.apply(state1);
// Verify ActionNode set the trigger flags
assertEquals(Boolean.TRUE, actionOut.get(RETURN_DIRECT_TRIGGERED),
"ActionNode must set RETURN_DIRECT_TRIGGERED when executor produced direct outputs");
@SuppressWarnings("unchecked")
List<DirectToolOutput> outputs = (List<DirectToolOutput>) actionOut.get(DIRECT_TOOL_OUTPUTS);
assertNotNull(outputs);
assertEquals(1, outputs.size());
assertEquals(SECRET, outputs.get(0).fullResult(),
"Full secret must reach DIRECT_TOOL_OUTPUTS verbatim");
// Critical: the ToolResponseMessage stored in MESSAGES must NOT contain the secret
// it must contain the placeholder, since this is what would be re-fed to the LLM
// if the graph weren't short-circuiting.
@SuppressWarnings("unchecked")
List<Message> messages = (List<Message>) actionOut.get(MESSAGES);
assertNotNull(messages);
assertEquals(1, messages.size());
ToolResponseMessage tr = (ToolResponseMessage) messages.get(0);
assertEquals(1, tr.getResponses().size());
ToolResponseMessage.ToolResponse resp = tr.getResponses().get(0);
// RFC-052 §2.4 contract: the placeholder is a fixed, English, business-data-free
// sentence. Asserting the exact text doubles as a contract test if anyone
// changes the placeholder text this fails and the RFC needs updating too.
assertEquals(
"[Tool result returned directly to user. " +
"Content withheld from model context per tool policy.]",
resp.responseData(),
"Tool response carried in MESSAGES must be the §2.4 placeholder, not the secret");
assertFalse(resp.responseData().contains("12345"),
"Sanity: the salary number must not be on the LLM-bound path");
assertFalse(resp.responseData().contains("999-88-7777"),
"Sanity: SSN must not be on the LLM-bound path");
// ===== Step 3: ObservationDispatcher decides where to route =====
// Build a state that reflects what the graph would have AFTER ActionNode
// (we manually merge ActionNode's output for the dispatcher input the
// actual graph engine does this via state merge strategies).
Map<String, Object> stateAfterAction = new HashMap<>(initialState);
stateAfterAction.putAll(actionOut);
// Skip ObservationNode for simplicity it doesn't touch our flags. Real
// graph runs Action Observation Dispatcher; we verify the dispatcher
// contract directly.
OverAllState state2 = new OverAllState(stateAfterAction);
String route = dispatcher.apply(state2);
assertEquals(FINAL_ANSWER_NODE, route,
"Dispatcher must route RETURN_DIRECT_TRIGGERED to FinalAnswerNode, " +
"skipping the next LLM call");
// ===== Step 4: FinalAnswerNode assembles the final answer =====
Map<String, Object> finalOut = finalAnswerNode.apply(state2);
assertEquals(SECRET, finalOut.get(FINAL_ANSWER),
"FinalAnswerNode must surface the direct tool's full text verbatim as the final answer");
assertEquals("return_direct", finalOut.get(FINISH_REASON),
"finishReason must be RETURN_DIRECT");
}
@Test
@DisplayName("RFC-052 end-to-end: mixed batch — direct tool A succeeds, non-direct tool B succeeds, plan still short-circuits")
void fullChain_mixedBatch_directWins() throws Exception {
ToolCallback direct = stubCallback("read_medical_record", true, args -> "PATIENT-DATA-XYZ");
ToolCallback normal = stubCallback("get_weather", false, args -> "sunny, 22C");
AgentToolSet toolSet = AgentToolSet.fromCallbacks(List.of(), List.of(direct, normal));
ToolGuard alwaysAllow = (n, a) -> ToolGuardResult.allow();
ToolExecutionExecutor executor = new ToolExecutionExecutor(toolSet, alwaysAllow, null, null);
ActionNode actionNode = new ActionNode(executor);
ObservationDispatcher dispatcher = new ObservationDispatcher();
FinalAnswerNode finalAnswerNode = new FinalAnswerNode();
List<AssistantMessage.ToolCall> calls = List.of(
new AssistantMessage.ToolCall("c1", "function", "read_medical_record", "{}"),
new AssistantMessage.ToolCall("c2", "function", "get_weather", "{}"));
Map<String, Object> initial = new HashMap<>();
initial.put(TOOL_CALLS, calls);
initial.put(CONVERSATION_ID, "conv_mixed");
initial.put(AGENT_ID, "agent_mixed");
Map<String, Object> actionOut = actionNode.apply(new OverAllState(initial));
assertEquals(Boolean.TRUE, actionOut.get(RETURN_DIRECT_TRIGGERED));
Map<String, Object> merged = new HashMap<>(initial);
merged.putAll(actionOut);
OverAllState merged2 = new OverAllState(merged);
assertEquals(FINAL_ANSWER_NODE, dispatcher.apply(merged2),
"Even with a non-direct tool in the batch, the direct one short-circuits");
Map<String, Object> finalOut = finalAnswerNode.apply(merged2);
assertEquals("PATIENT-DATA-XYZ", finalOut.get(FINAL_ANSWER),
"Single direct output rendered verbatim (single-output path, no headings)");
assertEquals("return_direct", finalOut.get(FINISH_REASON));
}
@Test
@DisplayName("RFC-052 end-to-end: non-direct tool DOES NOT trigger short-circuit")
void fullChain_nonDirectTool_runsNormalLoop() throws Exception {
ToolCallback normal = stubCallback("get_weather", false, args -> "rainy, 12C");
AgentToolSet toolSet = AgentToolSet.fromCallbacks(List.of(), List.of(normal));
ToolGuard alwaysAllow = (n, a) -> ToolGuardResult.allow();
ToolExecutionExecutor executor = new ToolExecutionExecutor(toolSet, alwaysAllow, null, null);
ActionNode actionNode = new ActionNode(executor);
ObservationDispatcher dispatcher = new ObservationDispatcher();
AssistantMessage.ToolCall call = new AssistantMessage.ToolCall(
"call_w", "function", "get_weather", "{}");
Map<String, Object> initial = new HashMap<>();
initial.put(TOOL_CALLS, List.of(call));
initial.put(CONVERSATION_ID, "conv_w");
initial.put(AGENT_ID, "agent_w");
initial.put(CURRENT_ITERATION, 0);
initial.put(MAX_ITERATIONS, 10);
Map<String, Object> actionOut = actionNode.apply(new OverAllState(initial));
// RETURN_DIRECT_TRIGGERED must NOT be set
assertNull(actionOut.get(RETURN_DIRECT_TRIGGERED),
"Non-direct tool must not flip RETURN_DIRECT_TRIGGERED");
// Dispatcher routes to REASONING_NODE for next loop iteration
Map<String, Object> merged = new HashMap<>(initial);
merged.putAll(actionOut);
String route = dispatcher.apply(new OverAllState(merged));
assertEquals(REASONING_NODE, route,
"Without the direct flag, dispatcher must continue the ReAct loop");
}
/** Stub ToolCallback with explicit returnDirect flag (mirrors the unit-test helper). */
private static ToolCallback stubCallback(String name, boolean returnDirect,
java.util.function.Function<String, String> handler) {
ToolDefinition def = ToolDefinition.builder()
.name(name)
.description("e2e test tool " + name)
.inputSchema("{\"type\":\"object\",\"properties\":{}}")
.build();
ToolMetadata md = ToolMetadata.builder().returnDirect(returnDirect).build();
return new ToolCallback() {
@Override public ToolDefinition getToolDefinition() { return def; }
@Override public ToolMetadata getToolMetadata() { return md; }
@Override public String call(String arguments) { return handler.apply(arguments); }
@Override public String call(String arguments, ToolContext toolContext) {
return handler.apply(arguments);
}
};
}
}

View File

@ -0,0 +1,158 @@
package vip.mate.agent.graph;
import org.junit.jupiter.api.DisplayName;
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.UserMessage;
import org.springframework.ai.chat.prompt.Prompt;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* RFC-049 PR-2 §2.4.1: verify the {@code lastUserIdx} boundary semantics of
* {@link NodeStreamingChatHelper#stripThinkingFromPrompt}.
*
* <p>Prior-turn AssistantMessages ({@code i <= lastUserIdx}) must have their
* {@code reasoningContent} stripped DeepSeek's contract says "reset across
* user turns". In-turn AssistantMessages ({@code i > lastUserIdx}) must keep
* their thinking so DeepSeek's "pass back within the same turn" requirement
* holds for ReAct multi-round tool calls.
*/
class StripThinkingBoundaryTest {
private static AssistantMessage assistantWithThinking(String content, String thinking) {
AssistantMessage.Builder b = AssistantMessage.builder().content(content);
if (thinking != null) {
b.properties(Map.of("reasoningContent", thinking));
}
return b.build();
}
private static String thinkingOf(Message m) {
if (!(m instanceof AssistantMessage am)) return null;
Object rc = am.getMetadata() != null ? am.getMetadata().get("reasoningContent") : null;
return rc instanceof String s ? s : null;
}
@Test
@DisplayName("No UserMessage (edge): lastUserIdx=-1 → all assistants treated as in-turn, thinking kept")
void noUser_allKept() {
// Edge case: when the prompt contains no UserMessage at all (e.g. system-only
// setup or a freshly-built Prompt that hasn't received user input yet), there
// is no prior-turn boundary, so every assistant is considered in-turn and
// their thinking is preserved. This is the safer default we never strip
// without a clear cross-turn signal.
List<Message> msgs = List.of(
new SystemMessage("sys"),
assistantWithThinking("a1", "think-1"),
assistantWithThinking("a2", "think-2")
);
Prompt cleaned = NodeStreamingChatHelper.stripThinkingFromPrompt(new Prompt(msgs));
assertEquals("think-1", thinkingOf(cleaned.getInstructions().get(1)));
assertEquals("think-2", thinkingOf(cleaned.getInstructions().get(2)));
}
@Test
@DisplayName("Single turn: UserMessage then assistants → all in-turn assistants keep thinking")
void singleTurn_allInTurnKept() {
List<Message> msgs = List.of(
new SystemMessage("sys"),
new UserMessage("q1"),
assistantWithThinking("a1-tool", "think-a1"),
assistantWithThinking("a2-final", "think-a2")
);
Prompt cleaned = NodeStreamingChatHelper.stripThinkingFromPrompt(new Prompt(msgs));
assertEquals("think-a1", thinkingOf(cleaned.getInstructions().get(2)));
assertEquals("think-a2", thinkingOf(cleaned.getInstructions().get(3)));
}
@Test
@DisplayName("Case H: cross-turn stripped, in-turn preserved")
void crossTurn_stripped_inTurn_kept() {
// [sys, U1, A1(think1), U2, A2(think2), A3(think3)]
// lastUserIdx = 3 (U2)
// i=2 A1 prior-turn strip
// i=4 A2 in-turn keep
// i=5 A3 in-turn keep
List<Message> msgs = List.of(
new SystemMessage("sys"),
new UserMessage("u1"),
assistantWithThinking("a1", "think-1"),
new UserMessage("u2"),
assistantWithThinking("a2", "think-2"),
assistantWithThinking("a3", "think-3")
);
Prompt cleaned = NodeStreamingChatHelper.stripThinkingFromPrompt(new Prompt(msgs));
assertNull(thinkingOf(cleaned.getInstructions().get(2)),
"A1 is prior-turn (i=2 <= lastUserIdx=3) — thinking must be stripped");
assertEquals("think-2", thinkingOf(cleaned.getInstructions().get(4)),
"A2 is in-turn (i=4 > lastUserIdx=3) — thinking must be kept");
assertEquals("think-3", thinkingOf(cleaned.getInstructions().get(5)),
"A3 is in-turn (i=5 > lastUserIdx=3) — thinking must be kept");
}
@Test
@DisplayName("Options reference is preserved by the returned Prompt (producer relies on this)")
void optionsPreservedByReference() {
org.springframework.ai.openai.OpenAiChatOptions opts =
org.springframework.ai.openai.OpenAiChatOptions.builder().model("test").build();
opts.setUser("original-user");
List<Message> msgs = List.of(
new UserMessage("u1"),
assistantWithThinking("a1", "think")
);
Prompt in = new Prompt(msgs, opts);
Prompt cleaned = NodeStreamingChatHelper.stripThinkingFromPrompt(in);
// The returned Prompt's options must be the same instance, so the
// producer's subsequent setUser(relayToken) is visible through cleaned too.
assertTrue(cleaned.getOptions() == in.getOptions(),
"stripThinkingFromPrompt must preserve the options reference");
assertEquals("original-user",
((org.springframework.ai.openai.OpenAiChatOptions) cleaned.getOptions()).getUser());
}
@Test
@DisplayName("Assistant without thinking is untouched (no churn)")
void noThinkingMetadata_passthrough() {
List<Message> msgs = List.of(
new UserMessage("u1"),
new AssistantMessage("plain a")
);
Prompt cleaned = NodeStreamingChatHelper.stripThinkingFromPrompt(new Prompt(msgs));
// Should return a Prompt with the same messages (no rebuild required)
assertEquals(msgs.size(), cleaned.getInstructions().size());
assertNull(thinkingOf(cleaned.getInstructions().get(1)));
}
@Test
@DisplayName("Prior-turn assistant with non-thinking metadata: thinking stripped, other metadata preserved")
void priorTurnAssistant_otherMetadataPreserved() {
AssistantMessage priorAssistant = AssistantMessage.builder()
.content("prior")
.properties(Map.of("reasoningContent", "old-think", "custom-key", "custom-val"))
.build();
List<Message> msgs = List.of(
new UserMessage("u1"),
priorAssistant,
new UserMessage("u2"),
assistantWithThinking("current", "current-think")
);
Prompt cleaned = NodeStreamingChatHelper.stripThinkingFromPrompt(new Prompt(msgs));
Message rebuiltPrior = cleaned.getInstructions().get(1);
assertTrue(rebuiltPrior instanceof AssistantMessage);
AssistantMessage am = (AssistantMessage) rebuiltPrior;
assertNull(am.getMetadata().get("reasoningContent"), "thinking must be stripped");
assertEquals("custom-val", am.getMetadata().get("custom-key"), "other metadata must be preserved");
}
}

View File

@ -91,4 +91,44 @@ class ObservationDispatcherTest {
));
assertEquals(SUMMARIZING_NODE, dispatcher.apply(state));
}
// ========== RFC-052 returnDirect routing ==========
@Test
@DisplayName("RFC-052: RETURN_DIRECT_TRIGGERED routes straight to FinalAnswerNode")
void returnDirectTriggered_routesToFinalAnswer() throws Exception {
OverAllState state = new OverAllState(Map.of(
CURRENT_ITERATION, 1,
MAX_ITERATIONS, 10,
RETURN_DIRECT_TRIGGERED, true
));
assertEquals(FINAL_ANSWER_NODE, dispatcher.apply(state));
}
@Test
@DisplayName("RFC-052: RETURN_DIRECT outranks shouldSummarize / limit-exceeded")
void returnDirectTriggered_takesPriorityOverSummarizeAndLimit() throws Exception {
// Even when summarize and limit conditions would trigger, RETURN_DIRECT wins.
OverAllState state = new OverAllState(Map.of(
CURRENT_ITERATION, 100, // way over limit
MAX_ITERATIONS, 10,
SHOULD_SUMMARIZE, true,
RETURN_DIRECT_TRIGGERED, true
));
assertEquals(FINAL_ANSWER_NODE, dispatcher.apply(state));
}
@Test
@DisplayName("RFC-052: AWAITING_APPROVAL still wins over RETURN_DIRECT")
void awaitingApproval_winsOverReturnDirect() throws Exception {
// Approval-pending must terminate the graph regardless; user decision
// arrives later via the replay path.
OverAllState state = new OverAllState(Map.of(
CURRENT_ITERATION, 1,
MAX_ITERATIONS, 10,
AWAITING_APPROVAL, true,
RETURN_DIRECT_TRIGGERED, true
));
assertEquals(FINAL_ANSWER_NODE, dispatcher.apply(state));
}
}

View File

@ -0,0 +1,165 @@
package vip.mate.agent.graph.executor;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.ai.chat.messages.ToolResponseMessage;
import java.nio.file.Path;
import java.util.List;
import java.lang.reflect.Field;
import java.util.concurrent.ExecutorService;
import static org.junit.jupiter.api.Assertions.*;
/**
* Tests for Lane D executor and config changes:
*
* <ul>
* <li>D-4: ToolExecutionExecutor uses virtual thread executor</li>
* <li>D-5: ToolResultProperties defaults updated to 16000/32000</li>
* </ul>
*/
class LaneDExecutorAndConfigTest {
// ============================================================
// D-4: ToolExecutionExecutor uses virtual threads
// ============================================================
@Nested
@DisplayName("D-4: ToolExecutionExecutor virtual thread pool")
class VirtualThreadPoolTests {
@Test
@DisplayName("TOOL_EXECUTOR is a named virtual thread executor (not fixed thread pool)")
void toolExecutorIsVirtualThreadBased() throws Exception {
Field field = ToolExecutionExecutor.class.getDeclaredField("TOOL_EXECUTOR");
field.setAccessible(true);
ExecutorService executor = (ExecutorService) field.get(null);
assertNotNull(executor, "TOOL_EXECUTOR should not be null");
// Virtual thread executor class name contains "ThreadPerTaskExecutor"
// when created via Executors.newThreadPerTaskExecutor(factory).
String className = executor.getClass().getName();
assertTrue(className.contains("ThreadPerTaskExecutor"),
"Expected ThreadPerTaskExecutor (named virtual threads), but got: " + className);
}
@Test
@DisplayName("Virtual threads are named 'tool-executor-N' for log traceability")
void virtualThreadsAreNamed() throws Exception {
Field field = ToolExecutionExecutor.class.getDeclaredField("TOOL_EXECUTOR");
field.setAccessible(true);
ExecutorService executor = (ExecutorService) field.get(null);
// Submit a task and capture the thread name
var future = executor.submit(() -> Thread.currentThread().getName());
String threadName = future.get();
assertTrue(threadName.startsWith("tool-executor-"),
"Virtual thread should be named 'tool-executor-N', but got: " + threadName);
}
}
// ============================================================
// D-5: ToolResultProperties defaults
// ============================================================
@Nested
@DisplayName("D-5: ToolResultProperties defaults updated")
class ToolResultPropertiesDefaultsTests {
@Test
@DisplayName("perResultThresholdChars default is 16000 (was 4000)")
void perResultThresholdCharsDefault() {
ToolResultProperties props = new ToolResultProperties();
assertEquals(16000, props.getPerResultThresholdChars(),
"Default perResultThresholdChars should be 16000");
}
@Test
@DisplayName("perTurnBudgetChars default is 32000 (was 16000)")
void perTurnBudgetCharsDefault() {
ToolResultProperties props = new ToolResultProperties();
assertEquals(32000, props.getPerTurnBudgetChars(),
"Default perTurnBudgetChars should be 32000");
}
@Test
@DisplayName("Other defaults remain unchanged")
void otherDefaultsUnchanged() {
ToolResultProperties props = new ToolResultProperties();
assertTrue(props.isEnabled(), "enabled should default to true");
assertEquals(800, props.getPreviewHeadChars(),
"previewHeadChars should still default to 800");
assertEquals(2500, props.getExcludedToolInlineChars(),
"excludedToolInlineChars should default to 2500");
assertEquals("", props.getStorageBaseDir(),
"storageBaseDir should still default to empty string");
}
@Test
@DisplayName("Large results above 16000 still trigger spill (threshold boundary)")
void thresholdBoundary() {
ToolResultProperties props = new ToolResultProperties();
// Results <= 16000 should NOT spill
assertTrue(15000 <= props.getPerResultThresholdChars(),
"A 15000-char result should be within threshold");
// Results > 16000 should spill
assertTrue(17000 > props.getPerResultThresholdChars(),
"A 17000-char result should exceed threshold");
}
}
@Nested
@DisplayName("Tool result aggregate budget")
class ToolResultAggregateBudgetTests {
@TempDir
Path tempDir;
@Test
@DisplayName("excluded retrieval tools are compacted when aggregate budget is exceeded")
void excludedToolResultsCompactWhenTurnBudgetIsExceeded() {
ToolResultProperties props = new ToolResultProperties();
props.setStorageBaseDir(tempDir.toString());
props.setPerTurnBudgetChars(5000);
props.setExcludedToolInlineChars(1200);
props.setExcludedTools(List.of("read_file"));
ToolResultStorage storage = new ToolResultStorage(props);
String largeRead = "line\n".repeat(1600);
List<ToolResponseMessage.ToolResponse> responses = List.of(
new ToolResponseMessage.ToolResponse("call-1", "read_file", largeRead),
new ToolResponseMessage.ToolResponse("call-2", "read_file", largeRead + "tail")
);
List<ToolResponseMessage.ToolResponse> compacted =
storage.enforceTurnBudget(responses, "conv-test", tempDir.toString());
assertTrue(compacted.stream().mapToInt(r -> r.responseData().length()).sum() < 5000);
assertTrue(compacted.stream().allMatch(r ->
r.responseData().contains("tool result compacted for model context")));
}
@Test
@DisplayName("large eligible tool result is spilled before entering model context")
void largeEligibleToolResultIsSpilled() {
ToolResultProperties props = new ToolResultProperties();
props.setStorageBaseDir(tempDir.toString());
props.setPerResultThresholdChars(1000);
props.setPreviewHeadChars(120);
ToolResultStorage storage = new ToolResultStorage(props);
String largeResult = "0123456789\n".repeat(500);
String contextResult = storage.persistIfOversized(
largeResult, "web_search", "call-1", "conv-test", tempDir.toString());
assertTrue(contextResult.startsWith(ToolResultStorage.SPILL_MARKER_PREFIX));
assertTrue(contextResult.length() < largeResult.length());
assertTrue(contextResult.contains("full_chars=" + largeResult.length()));
}
}
}

View File

@ -0,0 +1,157 @@
package vip.mate.agent.graph.executor;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.ToolResponseMessage;
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.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Verifies the per-response tool_calls cap that protects the executor
* against runaway batch sizes from misbehaving models
* (StreamLake / kat-coder-pro-v1 emit 50+ in one shot).
*
* <p>This is a pure unit test on the package-private static helper; no
* Spring context, no mocks. The behavior under cap matters most for two
* cases: (1) the LLM must still receive paired tool responses for every
* dropped tool_call (some providers reject otherwise), and (2) the order
* of executed calls must remain stable so the agent's logic isn't
* reshuffled by the cap.
*/
class ToolExecutionExecutorCapToolCallsTest {
private static AssistantMessage.ToolCall call(String id, String name) {
return new AssistantMessage.ToolCall(id, "function", name, "{}");
}
private static List<AssistantMessage.ToolCall> sequentialCalls(int n) {
List<AssistantMessage.ToolCall> out = new ArrayList<>(n);
for (int i = 0; i < n; i++) {
out.add(call("call_" + i, "tool_" + i));
}
return out;
}
// Pass-through cases
@Test
@DisplayName("null input returns empty list, no truncation")
void nullInputPassesThrough() {
ToolExecutionExecutor.CappedToolCalls capped =
ToolExecutionExecutor.capToolCalls(null, 16);
assertNotNull(capped);
assertNotNull(capped.effective());
assertTrue(capped.effective().isEmpty());
assertTrue(capped.truncatedResponses().isEmpty());
assertFalse(capped.wasTruncated());
}
@Test
@DisplayName("empty input is returned untouched")
void emptyInputPassesThrough() {
List<AssistantMessage.ToolCall> input = List.of();
ToolExecutionExecutor.CappedToolCalls capped =
ToolExecutionExecutor.capToolCalls(input, 16);
assertSame(input, capped.effective(), "no copy when within cap");
assertTrue(capped.truncatedResponses().isEmpty());
assertFalse(capped.wasTruncated());
}
@Test
@DisplayName("size at cap is returned untouched (boundary)")
void atCapPassesThrough() {
List<AssistantMessage.ToolCall> input = sequentialCalls(16);
ToolExecutionExecutor.CappedToolCalls capped =
ToolExecutionExecutor.capToolCalls(input, 16);
assertSame(input, capped.effective(),
"at-cap input must not be sublist'd — surprising allocation");
assertTrue(capped.truncatedResponses().isEmpty());
assertFalse(capped.wasTruncated());
}
@Test
@DisplayName("size below cap is returned untouched")
void belowCapPassesThrough() {
List<AssistantMessage.ToolCall> input = sequentialCalls(5);
ToolExecutionExecutor.CappedToolCalls capped =
ToolExecutionExecutor.capToolCalls(input, 16);
assertSame(input, capped.effective());
assertFalse(capped.wasTruncated());
}
// Truncation cases
@Test
@DisplayName("over-cap input is trimmed; first N kept in original order")
void overCapTrimmed() {
List<AssistantMessage.ToolCall> input = sequentialCalls(20);
ToolExecutionExecutor.CappedToolCalls capped =
ToolExecutionExecutor.capToolCalls(input, 16);
assertTrue(capped.wasTruncated());
assertEquals(16, capped.effective().size());
// Order preservation matters the agent's reasoning may depend on
// the LLM's chosen sequence (e.g. read-then-write); reshuffling the
// first-N is silently breaking.
for (int i = 0; i < 16; i++) {
assertEquals("call_" + i, capped.effective().get(i).id());
}
}
@Test
@DisplayName("each dropped tool_call gets a synthetic ToolResponseMessage with matching id")
void droppedCallsGetTruncatedResponses() {
List<AssistantMessage.ToolCall> input = sequentialCalls(20);
ToolExecutionExecutor.CappedToolCalls capped =
ToolExecutionExecutor.capToolCalls(input, 16);
// 4 dropped calls (indices 16..19) 4 synthetic responses.
assertEquals(4, capped.truncatedResponses().size());
for (int i = 0; i < 4; i++) {
ToolResponseMessage.ToolResponse resp = capped.truncatedResponses().get(i);
assertEquals("call_" + (16 + i), resp.id(),
"synthetic response must reuse the dropped tool_call's id "
+ "or providers will reject the next turn");
assertEquals("tool_" + (16 + i), resp.name());
assertTrue(resp.responseData().contains("[truncated]"),
"response body must signal truncation so the LLM can reissue");
}
}
@Test
@DisplayName("synthetic response body mentions both requested and cap counts")
void truncatedResponseBodyExplainsCounts() {
List<AssistantMessage.ToolCall> input = sequentialCalls(50);
ToolExecutionExecutor.CappedToolCalls capped =
ToolExecutionExecutor.capToolCalls(input, 16);
String body = capped.truncatedResponses().get(0).responseData();
assertTrue(body.contains("50"), "body should mention requested count: " + body);
assertTrue(body.contains("16"), "body should mention cap value: " + body);
}
@Test
@DisplayName("custom cap value honored — same logic at any threshold")
void customCapHonored() {
List<AssistantMessage.ToolCall> input = sequentialCalls(10);
ToolExecutionExecutor.CappedToolCalls capped =
ToolExecutionExecutor.capToolCalls(input, 3);
assertTrue(capped.wasTruncated());
assertEquals(3, capped.effective().size());
assertEquals(7, capped.truncatedResponses().size());
}
}

View File

@ -0,0 +1,142 @@
package vip.mate.agent.graph.executor;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.definition.ToolDefinition;
import vip.mate.agent.AgentToolSet;
import vip.mate.tool.guard.ToolGuard;
import vip.mate.tool.guard.ToolGuardResult;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
/**
* LLMs frequently mangle tool names: emit {@code WebSearch} or
* {@code web_search_tool} when the registry knows {@code web_search}, or
* {@code Read_File} when the registry knows {@code read_file}. Without
* normalization those calls return "Tool not found" and the agent loses a
* turn and worse, the guard's deny rules (keyed on canonical names) get
* silently bypassed because the guard never sees a matching name.
*/
class ToolExecutionExecutorNameNormalizationTest {
private ToolCallback callbackNamed(String name) {
ToolCallback cb = mock(ToolCallback.class);
ToolDefinition def = mock(ToolDefinition.class);
when(def.name()).thenReturn(name);
when(def.description()).thenReturn(name);
when(def.inputSchema()).thenReturn("{}");
when(cb.getToolDefinition()).thenReturn(def);
when(cb.call(anyString(), any())).thenReturn("ok:" + name);
when(cb.call(anyString())).thenReturn("ok:" + name);
return cb;
}
private ToolExecutionExecutor newExecutor(ToolCallback... callbacks) {
AgentToolSet toolSet = AgentToolSet.fromCallbacks(List.of(), List.of(callbacks));
ToolGuard alwaysAllow = (n, a) -> ToolGuardResult.allow();
return new ToolExecutionExecutor(toolSet, alwaysAllow, null, null);
}
@Test
@DisplayName("normalizeToolName: CamelCase → snake_case")
void normalize_camelCase() {
assertEquals("web_search", ToolExecutionExecutor.normalizeToolName("WebSearch"));
assertEquals("web_search", ToolExecutionExecutor.normalizeToolName("webSearch"));
assertEquals("read_file", ToolExecutionExecutor.normalizeToolName("ReadFile"));
assertEquals("browser_use", ToolExecutionExecutor.normalizeToolName("BrowserUse"));
}
@Test
@DisplayName("normalizeToolName: trailing _tool / Tool / _function suffix stripped")
void normalize_suffixStrip() {
assertEquals("web_search", ToolExecutionExecutor.normalizeToolName("web_search_tool"));
assertEquals("web_search", ToolExecutionExecutor.normalizeToolName("WebSearchTool"));
assertEquals("read_file", ToolExecutionExecutor.normalizeToolName("read_file_function"));
}
@Test
@DisplayName("normalizeToolName: separator collapse + lowercase")
void normalize_separators() {
assertEquals("read_file", ToolExecutionExecutor.normalizeToolName("Read_File"));
assertEquals("read_file", ToolExecutionExecutor.normalizeToolName("read-file"));
assertEquals("read_file", ToolExecutionExecutor.normalizeToolName("read.file"));
assertEquals("read_file", ToolExecutionExecutor.normalizeToolName("read file"));
assertEquals("read_file", ToolExecutionExecutor.normalizeToolName("__read__file__"));
}
@Test
@DisplayName("normalizeToolName: idempotent on already-canonical names")
void normalize_idempotent() {
assertEquals("web_search", ToolExecutionExecutor.normalizeToolName("web_search"));
assertEquals("read_file", ToolExecutionExecutor.normalizeToolName("read_file"));
}
@Test
@DisplayName("normalizeToolName: handles null/empty")
void normalize_edgeCases() {
assertEquals("", ToolExecutionExecutor.normalizeToolName(null));
assertEquals("", ToolExecutionExecutor.normalizeToolName(""));
assertEquals("", ToolExecutionExecutor.normalizeToolName(" "));
}
@Test
@DisplayName("resolveToolName: exact match returns input unchanged (hot path)")
void resolve_exactMatchUnchanged() {
ToolExecutionExecutor executor = newExecutor(callbackNamed("web_search"));
assertEquals("web_search", executor.resolveToolName("web_search"));
}
@Test
@DisplayName("resolveToolName: CamelCase emission resolves to snake_case canonical")
void resolve_camelToSnake() {
ToolExecutionExecutor executor = newExecutor(callbackNamed("web_search"));
assertEquals("web_search", executor.resolveToolName("WebSearch"));
assertEquals("web_search", executor.resolveToolName("webSearch"));
}
@Test
@DisplayName("resolveToolName: _tool / Tool suffix resolves to canonical")
void resolve_suffixStripped() {
ToolExecutionExecutor executor = newExecutor(callbackNamed("web_search"));
assertEquals("web_search", executor.resolveToolName("web_search_tool"));
assertEquals("web_search", executor.resolveToolName("WebSearchTool"));
}
@Test
@DisplayName("resolveToolName: unknown name returns input unchanged so 'tool not found' fires correctly")
void resolve_unknownReturnsInput() {
ToolExecutionExecutor executor = newExecutor(callbackNamed("web_search"));
assertEquals("totally_made_up", executor.resolveToolName("totally_made_up"));
}
@Test
@DisplayName("end-to-end: model emits 'WebSearch', registered as 'web_search', tool actually executes")
void endToEnd_camelCaseDispatch() {
ToolExecutionExecutor executor = newExecutor(callbackNamed("web_search"));
var result = executor.execute(
List.of(new AssistantMessage.ToolCall("call_1", "function", "WebSearch", "{}")),
"conv", "agent", false, "user", null);
assertEquals(1, result.responses().size());
assertEquals("ok:web_search", result.responses().get(0).responseData(),
"Mangled name should resolve and dispatch to the registered tool");
}
@Test
@DisplayName("end-to-end: '_tool' suffix is stripped and the call dispatches")
void endToEnd_toolSuffixStripped() {
ToolExecutionExecutor executor = newExecutor(callbackNamed("read_file"));
var result = executor.execute(
List.of(new AssistantMessage.ToolCall("call_2", "function", "read_file_tool", "{}")),
"conv", "agent", false, "user", null);
assertEquals("ok:read_file", result.responses().get(0).responseData());
}
}

View File

@ -0,0 +1,226 @@
package vip.mate.agent.graph.executor;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.ToolResponseMessage;
import org.springframework.ai.chat.model.ToolContext;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.definition.ToolDefinition;
import org.springframework.ai.tool.metadata.ToolMetadata;
import vip.mate.agent.AgentToolSet;
import vip.mate.agent.GraphEventPublisher;
import vip.mate.agent.graph.state.DirectToolOutput;
import vip.mate.tool.guard.ToolGuard;
import vip.mate.tool.guard.ToolGuardResult;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
/**
* RFC-052 PR-1/PR-2 end-to-end test for {@link ToolExecutionExecutor}.
*
* <p>The contract under test:
* <ol>
* <li>A {@code returnDirect=true} tool's full result is captured in
* {@link ToolExecutionExecutor.ToolExecutionResult#directOutputs()}.</li>
* <li>The corresponding {@link ToolResponseMessage.ToolResponse} carries the
* fixed placeholder, not the sensitive content.</li>
* <li>An {@code EVENT_TOOL_DIRECT_RESULT} event is emitted with the full text
* and {@code renderAs=assistant_message}.</li>
* <li>Non-direct tools in the same batch keep their existing behavior.</li>
* </ol>
*/
class ToolExecutionExecutorReturnDirectTest {
private static final String SECRET = "EMPLOYEE-SALARY: Alice=12345, Bob=67890";
private ToolExecutionExecutor newExecutor(ToolCallback... callbacks) {
AgentToolSet toolSet = AgentToolSet.fromCallbacks(List.of(), List.of(callbacks));
ToolGuard alwaysAllow = (n, a) -> ToolGuardResult.allow();
// streamTracker=null is supported throughout executor; null approval
// service is fine when guard never returns NEEDS_APPROVAL.
return new ToolExecutionExecutor(toolSet, alwaysAllow, null, null);
}
@Test
@DisplayName("RFC-052: returnDirect tool result reaches user verbatim and stays out of LLM context")
void directTool_fullResultCapturedAndPlaceholderInResponse() {
ToolCallback direct = stubCallback("query_employee_salary", true, args -> SECRET);
ToolExecutionExecutor executor = newExecutor(direct);
AssistantMessage.ToolCall call = new AssistantMessage.ToolCall(
"call_1", "function", "query_employee_salary", "{}");
ToolExecutionExecutor.ToolExecutionResult result =
executor.execute(List.of(call), "conv_1", "agent_1", false, "user_1", null);
// (1) directOutputs aggregates the full text
assertEquals(1, result.directOutputs().size());
DirectToolOutput out = result.directOutputs().get(0);
assertEquals("query_employee_salary", out.toolName());
assertEquals(SECRET, out.fullResult(), "Full result must be preserved verbatim");
assertTrue(result.hasDirectOutputs());
// (2) ToolResponseMessage carries the placeholder (LLM-safe)
assertEquals(1, result.responses().size());
ToolResponseMessage.ToolResponse resp = result.responses().get(0);
assertEquals(ToolExecutionExecutor.DIRECT_TOOL_PLACEHOLDER, resp.responseData(),
"ToolResponseMessage must carry the placeholder, not the sensitive payload");
assertFalse(resp.responseData().contains("EMPLOYEE-SALARY"),
"Sensitive substring must not appear in tool response");
// (3) tool_direct_result event was emitted with renderAs=assistant_message + full text
var directEvents = result.events().stream()
.filter(e -> GraphEventPublisher.EVENT_TOOL_DIRECT_RESULT.equals(e.type()))
.toList();
assertEquals(1, directEvents.size(), "exactly one tool_direct_result event expected");
var data = directEvents.get(0).data();
assertEquals("call_1", data.get("toolCallId"));
assertEquals("query_employee_salary", data.get("toolName"));
assertEquals(SECRET, data.get("result"));
assertEquals("assistant_message", data.get("renderAs"));
// (4) no tool_call_completed event for the direct tool direct path replaces it
boolean hasCompleted = result.events().stream()
.anyMatch(e -> GraphEventPublisher.EVENT_TOOL_COMPLETE.equals(e.type()));
assertFalse(hasCompleted, "direct path replaces tool_call_completed; double-emit would " +
"leak the placeholder into UI as a tool result card");
}
@Test
@DisplayName("RFC-052: non-direct tool keeps existing behavior (no direct outputs)")
void nonDirectTool_keepsBaselineBehavior() {
ToolCallback normal = stubCallback("get_weather", false, args -> "sunny, 22C");
ToolExecutionExecutor executor = newExecutor(normal);
AssistantMessage.ToolCall call = new AssistantMessage.ToolCall(
"call_w", "function", "get_weather", "{}");
ToolExecutionExecutor.ToolExecutionResult result =
executor.execute(List.of(call), "conv_w", "agent_w", false, "user_w", null);
assertFalse(result.hasDirectOutputs(), "no direct tool ran; directOutputs must be empty");
assertTrue(result.directOutputs().isEmpty());
assertEquals(1, result.responses().size());
assertEquals("sunny, 22C", result.responses().get(0).responseData());
// no direct event
boolean hasDirect = result.events().stream()
.anyMatch(e -> GraphEventPublisher.EVENT_TOOL_DIRECT_RESULT.equals(e.type()));
assertFalse(hasDirect);
}
@Test
@DisplayName("RFC-052: returnDirect tool throwing yields generic message (no exception details leak)")
void directTool_throwing_genericErrorMessage() {
ToolCallback throwingDirect = stubCallback("query_employee_salary", true, args -> {
throw new RuntimeException("OracleDriver: connection refused, secret-conn-str=user/PWD123@db");
});
ToolExecutionExecutor executor = newExecutor(throwingDirect);
AssistantMessage.ToolCall call = new AssistantMessage.ToolCall(
"call_e", "function", "query_employee_salary", "{}");
ToolExecutionExecutor.ToolExecutionResult result =
executor.execute(List.of(call), "conv_e", "agent_e", false, "user_e", null);
// No directOutputs exception aborted before the direct branch
assertFalse(result.hasDirectOutputs());
assertEquals(1, result.responses().size());
String content = result.responses().get(0).responseData();
assertEquals("Tool execution failed (details withheld per returnDirect policy)", content,
"Direct-tool exception text must be replaced with a generic placeholder");
assertFalse(content.contains("PWD123"), "Sensitive substring from exception must not leak");
assertFalse(content.contains("OracleDriver"), "Stack/connection details must not leak");
}
@Test
@DisplayName("RFC-052: pre-approved direct tool replays through direct path")
void executePreApproved_directTool_takesDirectPath() {
ToolCallback direct = stubCallback("query_secret", true, args -> "SECRET-PAYLOAD-123");
ToolExecutionExecutor executor = newExecutor(direct);
AssistantMessage.ToolCall toolCall = new AssistantMessage.ToolCall(
"call_a", "function", "query_secret", "{}");
java.util.List<GraphEventPublisher.GraphEvent> events = new java.util.ArrayList<>();
java.util.List<DirectToolOutput> directOutputs = new java.util.ArrayList<>();
ToolResponseMessage.ToolResponse response = executor.executePreApproved(
toolCall, "{}", events, "conv_a", null, directOutputs);
// Without the directOutputs collector wired, executePreApproved would
// have leaked SECRET-PAYLOAD-123 into the response. With the fix:
assertEquals(ToolExecutionExecutor.DIRECT_TOOL_PLACEHOLDER, response.responseData(),
"Pre-approved direct tool must produce a placeholder response");
assertEquals(1, directOutputs.size());
assertEquals("SECRET-PAYLOAD-123", directOutputs.get(0).fullResult());
// tool_direct_result event present
assertTrue(events.stream()
.anyMatch(e -> GraphEventPublisher.EVENT_TOOL_DIRECT_RESULT.equals(e.type())));
}
@Test
@DisplayName("RFC-052: legacy executePreApproved (no collector) does NOT silently leak — placeholder still applied")
void executePreApproved_legacyOverload_stillProducesPlaceholder() {
ToolCallback direct = stubCallback("query_secret", true, args -> "SECRET-OTHER-456");
ToolExecutionExecutor executor = newExecutor(direct);
AssistantMessage.ToolCall toolCall = new AssistantMessage.ToolCall(
"call_b", "function", "query_secret", "{}");
java.util.List<GraphEventPublisher.GraphEvent> events = new java.util.ArrayList<>();
// 5-arg overload with no directOutputs collector directOutputs is
// dropped on the floor, but the placeholder still keeps the LLM safe.
ToolResponseMessage.ToolResponse response = executor.executePreApproved(
toolCall, "{}", events, "conv_b", null);
assertEquals(ToolExecutionExecutor.DIRECT_TOOL_PLACEHOLDER, response.responseData());
assertFalse(response.responseData().contains("SECRET-OTHER-456"));
}
@Test
@DisplayName("RFC-052: mixed batch — any direct tool triggers direct outputs while non-direct keeps result")
void mixedBatch_directAndNonDirect() {
ToolCallback direct = stubCallback("read_medical_record", true, args -> "PATIENT-DATA-XYZ");
ToolCallback normal = stubCallback("get_weather", false, args -> "rainy, 12C");
ToolExecutionExecutor executor = newExecutor(direct, normal);
List<AssistantMessage.ToolCall> calls = List.of(
new AssistantMessage.ToolCall("c1", "function", "read_medical_record", "{}"),
new AssistantMessage.ToolCall("c2", "function", "get_weather", "{}"));
ToolExecutionExecutor.ToolExecutionResult result =
executor.execute(calls, "conv_m", "agent_m", false, "user_m", null);
assertTrue(result.hasDirectOutputs());
assertEquals(1, result.directOutputs().size());
assertEquals("read_medical_record", result.directOutputs().get(0).toolName());
assertEquals("PATIENT-DATA-XYZ", result.directOutputs().get(0).fullResult());
// non-direct response still contains its own data; placeholder is only on direct
assertEquals(2, result.responses().size());
ToolResponseMessage.ToolResponse directResp = result.responses().get(0);
ToolResponseMessage.ToolResponse normalResp = result.responses().get(1);
assertEquals(ToolExecutionExecutor.DIRECT_TOOL_PLACEHOLDER, directResp.responseData());
assertEquals("rainy, 12C", normalResp.responseData());
}
/** Build a minimal ToolCallback stub with an explicit returnDirect flag. */
private static ToolCallback stubCallback(String name, boolean returnDirect,
java.util.function.Function<String, String> handler) {
ToolDefinition def = ToolDefinition.builder()
.name(name)
.description("test tool " + name)
.inputSchema("{\"type\":\"object\",\"properties\":{}}")
.build();
ToolMetadata md = ToolMetadata.builder().returnDirect(returnDirect).build();
return new ToolCallback() {
@Override public ToolDefinition getToolDefinition() { return def; }
@Override public ToolMetadata getToolMetadata() { return md; }
@Override public String call(String arguments) { return handler.apply(arguments); }
@Override public String call(String arguments, ToolContext toolContext) {
return handler.apply(arguments);
}
};
}
}

View File

@ -0,0 +1,185 @@
package vip.mate.agent.graph.executor;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.model.ToolContext;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.definition.ToolDefinition;
import org.springframework.ai.tool.metadata.ToolMetadata;
import vip.mate.agent.AgentToolSet;
import vip.mate.skill.runtime.SkillRuntimeService;
import vip.mate.skill.runtime.model.ResolvedSkill;
import vip.mate.tool.guard.ToolGuard;
import vip.mate.tool.guard.ToolGuardResult;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
/**
* Auto-redirect: when the LLM mistakenly calls a skill name as if it were
* a tool, the executor should transparently invoke {@code readSkillFile}
* on its behalf and return the SKILL.md content as the tool result.
*
* <p>Why: smaller models (qwen-turbo et al.) often can't act on a
* "this is a Skill, not a Tool — go read X first" textual hint. They
* generate a polite "let me get that" reply and end the turn without
* any further tool call, leaving the user stuck. With auto-redirect
* the model receives runnable instructions on its very first attempt.
*
* <p>The hint-only path is still preserved for the case where
* {@code readSkillFile} isn't bound to the agent (covered by
* {@link ToolExecutionExecutorSkillHintTest}).
*/
class ToolExecutionExecutorSkillAutoRedirectTest {
private static final String SKILL_MD =
"---\nname: tencent-meeting-mcp\n---\n\n# Quick start\nrunSkillScript scripts/setup.sh\n";
private ToolExecutionExecutor newExecutor(ToolCallback... callbacks) {
AgentToolSet toolSet = AgentToolSet.fromCallbacks(List.of(), List.of(callbacks));
ToolGuard alwaysAllow = (n, a) -> ToolGuardResult.allow();
return new ToolExecutionExecutor(toolSet, alwaysAllow, null, null);
}
private SkillRuntimeService skillRuntimeWith(String... activeNames) {
SkillRuntimeService svc = mock(SkillRuntimeService.class);
List<ResolvedSkill> skills = java.util.Arrays.stream(activeNames).map(name -> {
ResolvedSkill s = mock(ResolvedSkill.class);
when(s.getName()).thenReturn(name);
return s;
}).toList();
when(svc.getActiveSkills()).thenReturn(skills);
return svc;
}
/** Captures the args that the auto-redirected readSkillFile receives. */
private static class CapturingReadSkillFile {
final AtomicReference<String> lastArgs = new AtomicReference<>();
final ToolCallback callback;
CapturingReadSkillFile(String returnContent) {
ToolDefinition def = ToolDefinition.builder()
.name("readSkillFile")
.description("test stub")
.inputSchema("{\"type\":\"object\",\"properties\":{}}")
.build();
ToolMetadata md = ToolMetadata.builder().returnDirect(false).build();
callback = new ToolCallback() {
@Override public ToolDefinition getToolDefinition() { return def; }
@Override public ToolMetadata getToolMetadata() { return md; }
@Override public String call(String arguments) {
lastArgs.set(arguments);
return returnContent;
}
@Override public String call(String arguments, ToolContext ctx) {
return call(arguments);
}
};
}
}
@Test
@DisplayName("skill-as-tool call gets auto-redirected to readSkillFile when the tool is bound")
void skillCallAutoRedirects() {
CapturingReadSkillFile rsf = new CapturingReadSkillFile(SKILL_MD);
ToolExecutionExecutor executor = newExecutor(rsf.callback);
executor.setSkillRuntimeService(skillRuntimeWith("tencent-meeting-mcp"));
String llmArgs = "{\"action\":\"create\",\"subject\":\"AI讨论会\"}";
var result = executor.execute(
List.of(new AssistantMessage.ToolCall(
"call_1", "function", "tencent-meeting-mcp", llmArgs)),
"conv", "agent", false, "user", null);
assertEquals(1, result.responses().size());
String response = result.responses().get(0).responseData();
// (1) readSkillFile was invoked with the skill's name and SKILL.md
String forwarded = rsf.lastArgs.get();
assertNotNull(forwarded, "readSkillFile must have been invoked transparently");
assertTrue(forwarded.contains("\"skillName\":\"tencent-meeting-mcp\""), forwarded);
assertTrue(forwarded.contains("\"filePath\":\"SKILL.md\""), forwarded);
// (2) Response carries the SKILL.md content
assertTrue(response.contains("# Quick start"),
"Response should embed SKILL.md content: " + response);
assertTrue(response.contains("runSkillScript scripts/setup.sh"),
"Response should embed the runnable example from SKILL.md");
// (3) Response carries the [auto-redirect] nudge so the LLM understands
// why it didn't get a function-call result of the shape it expected
assertTrue(response.contains("[auto-redirect]"),
"Response should declare the auto-redirect: " + response);
assertTrue(response.contains("runSkillScript"),
"Response should tell the LLM what to call next");
// (4) Original payload is echoed back so the LLM doesn't have to re-derive
// args before calling runSkillScript
assertTrue(response.contains("AI讨论会"),
"Original LLM args should be echoed in the redirect: " + response);
}
@Test
@DisplayName("skill-as-tool call falls through to hint when readSkillFile is NOT bound to this agent")
void skillCallWithoutReadSkillFileFallsThroughToHint() {
// Empty tool set readSkillFile not registered for this agent
ToolExecutionExecutor executor = newExecutor();
executor.setSkillRuntimeService(skillRuntimeWith("tencent-meeting-mcp"));
var result = executor.execute(
List.of(new AssistantMessage.ToolCall(
"call_2", "function", "tencent-meeting-mcp", "{}")),
"conv", "agent", false, "user", null);
String response = result.responses().get(0).responseData();
assertTrue(response.contains("Skill, not a Tool"),
"Without readSkillFile, executor must fall back to the textual hint: " + response);
assertFalse(response.contains("[auto-redirect]"),
"No redirect should have happened: " + response);
}
@Test
@DisplayName("non-skill unknown tool name still produces the bare 'Tool not found' message")
void unknownToolKeepsBareError() {
CapturingReadSkillFile rsf = new CapturingReadSkillFile(SKILL_MD);
ToolExecutionExecutor executor = newExecutor(rsf.callback);
executor.setSkillRuntimeService(skillRuntimeWith("tencent-meeting-mcp"));
var result = executor.execute(
List.of(new AssistantMessage.ToolCall(
"call_3", "function", "made_up_tool", "{}")),
"conv", "agent", false, "user", null);
String response = result.responses().get(0).responseData();
assertEquals("Tool not found: made_up_tool", response);
assertNull(rsf.lastArgs.get(),
"readSkillFile must NOT be invoked for non-skill names");
}
@Test
@DisplayName("skill name with special chars in the LLM args is JSON-escaped before forwarding")
void specialCharsInArgsAreEscaped() {
CapturingReadSkillFile rsf = new CapturingReadSkillFile(SKILL_MD);
ToolExecutionExecutor executor = newExecutor(rsf.callback);
// Skill name with double quotes / backslash to verify the inline JSON
// we build for the readSkillFile call escapes them properly.
executor.setSkillRuntimeService(skillRuntimeWith("weird\"name\\skill"));
var result = executor.execute(
List.of(new AssistantMessage.ToolCall(
"call_4", "function", "weird\"name\\skill", "{}")),
"conv", "agent", false, "user", null);
// If escaping were broken, readSkillFile would have rejected the
// malformed JSON and returned an error. The response carrying SKILL_MD
// proves the forwarded args parsed cleanly.
assertTrue(result.responses().get(0).responseData().contains("# Quick start"));
String forwarded = rsf.lastArgs.get();
assertTrue(forwarded.contains("weird\\\"name\\\\skill"),
"Forwarded args should JSON-escape quotes and backslashes: " + forwarded);
}
}

View File

@ -0,0 +1,122 @@
package vip.mate.agent.graph.executor;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.tool.ToolCallback;
import vip.mate.agent.AgentToolSet;
import vip.mate.skill.runtime.SkillRuntimeService;
import vip.mate.skill.runtime.model.ResolvedSkill;
import vip.mate.tool.guard.ToolGuard;
import vip.mate.tool.guard.ToolGuardResult;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
/**
* Issue #46: when the LLM mis-calls a skill name as a tool, the executor
* should return a precise hint explaining that the name is a Skill (not a
* Tool) and how to invoke it via {@code readSkillFile} instead of the
* dead-end "Tool not found" string that gave the model nothing to act on.
*/
class ToolExecutionExecutorSkillHintTest {
private ToolExecutionExecutor newExecutor(ToolCallback... callbacks) {
AgentToolSet toolSet = AgentToolSet.fromCallbacks(List.of(), List.of(callbacks));
ToolGuard alwaysAllow = (n, a) -> ToolGuardResult.allow();
return new ToolExecutionExecutor(toolSet, alwaysAllow, null, null);
}
private SkillRuntimeService skillRuntimeWith(String... activeNames) {
SkillRuntimeService svc = mock(SkillRuntimeService.class);
List<ResolvedSkill> skills = java.util.Arrays.stream(activeNames).map(name -> {
ResolvedSkill s = mock(ResolvedSkill.class);
when(s.getName()).thenReturn(name);
return s;
}).toList();
when(svc.getActiveSkills()).thenReturn(skills);
return svc;
}
@Test
@DisplayName("issue#46: tool name matching an active skill yields skill-aware hint")
void unknownToolMatchingSkill_returnsHint() {
ToolExecutionExecutor executor = newExecutor(); // empty tool set
executor.setSkillRuntimeService(skillRuntimeWith("RedisOps", "browser_cdp"));
ToolExecutionExecutor.ToolExecutionResult result = executor.execute(
List.of(new AssistantMessage.ToolCall("call_1", "function", "RedisOps", "{}")),
"conv", "agent", false, "user", null);
assertEquals(1, result.responses().size());
String response = result.responses().get(0).responseData();
assertTrue(response.contains("Skill, not a Tool"),
"Response should declare the name is a Skill: " + response);
assertTrue(response.contains("readSkillFile(skillName=\"RedisOps\""),
"Response should suggest the concrete invocation: " + response);
assertFalse(response.equals("Tool not found: RedisOps"),
"Response should NOT fall back to the bare error string");
}
@Test
@DisplayName("issue#46: case-insensitive skill match — LLMs sometimes alter casing")
void unknownToolCaseInsensitiveSkillMatch_returnsHint() {
ToolExecutionExecutor executor = newExecutor();
executor.setSkillRuntimeService(skillRuntimeWith("RedisOps"));
ToolExecutionExecutor.ToolExecutionResult result = executor.execute(
List.of(new AssistantMessage.ToolCall("call_2", "function", "redisops", "{}")),
"conv", "agent", false, "user", null);
String response = result.responses().get(0).responseData();
assertTrue(response.contains("Skill, not a Tool"),
"Lowercase 'redisops' should still match active skill 'RedisOps': " + response);
}
@Test
@DisplayName("issue#46: tool name not matching any skill keeps the bare 'Tool not found' message")
void unknownToolWithNoSkillMatch_keepsBareError() {
ToolExecutionExecutor executor = newExecutor();
executor.setSkillRuntimeService(skillRuntimeWith("RedisOps", "browser_cdp"));
ToolExecutionExecutor.ToolExecutionResult result = executor.execute(
List.of(new AssistantMessage.ToolCall("call_3", "function", "totally_made_up_tool", "{}")),
"conv", "agent", false, "user", null);
String response = result.responses().get(0).responseData();
assertEquals("Tool not found: totally_made_up_tool", response,
"When the name doesn't match any skill, the executor must fall back to the bare error");
}
@Test
@DisplayName("issue#46: when skillRuntimeService is unset (legacy/test path), behavior is unchanged")
void unknownToolWithoutSkillRuntime_keepsBareError() {
ToolExecutionExecutor executor = newExecutor();
// intentionally do NOT call setSkillRuntimeService
ToolExecutionExecutor.ToolExecutionResult result = executor.execute(
List.of(new AssistantMessage.ToolCall("call_4", "function", "RedisOps", "{}")),
"conv", "agent", false, "user", null);
String response = result.responses().get(0).responseData();
assertEquals("Tool not found: RedisOps", response,
"Without a wired SkillRuntimeService, the executor must keep the legacy bare error");
}
@Test
@DisplayName("issue#46: pre-approved replay path also gets the skill-aware hint")
void preApprovedReplayUnknownTool_returnsHint() {
ToolExecutionExecutor executor = newExecutor();
executor.setSkillRuntimeService(skillRuntimeWith("RedisOps"));
java.util.List<vip.mate.agent.GraphEventPublisher.GraphEvent> events = new java.util.ArrayList<>();
var response = executor.executePreApproved(
new AssistantMessage.ToolCall("call_5", "function", "RedisOps", "{}"),
"{}", events, "conv", null);
assertTrue(response.responseData().contains("Skill, not a Tool"),
"Pre-approved replay should also produce the skill-aware hint: " + response.responseData());
}
}

View File

@ -4,7 +4,12 @@ import com.alibaba.cloud.ai.graph.OverAllState;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import vip.mate.agent.GraphEventPublisher;
import vip.mate.agent.graph.state.DirectToolOutput;
import vip.mate.agent.graph.state.SourceEvidenceLedger;
import vip.mate.tool.document.GeneratedFileCache;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
@ -90,4 +95,396 @@ class FinalAnswerNodeTest {
assertEquals("Failed to generate a response, please retry.", result.get(FINAL_ANSWER));
assertEquals("error_fallback", result.get(FINISH_REASON));
}
// ========== RFC-052 returnDirect ==========
@Test
@DisplayName("RFC-052: single direct tool output becomes the final answer verbatim")
void directSingle_verbatim() throws Exception {
DirectToolOutput out = new DirectToolOutput(
"call_1", "query_employee_salary",
"Alice's salary is 12345.", System.currentTimeMillis());
OverAllState state = new OverAllState(Map.of(
RETURN_DIRECT_TRIGGERED, true,
DIRECT_TOOL_OUTPUTS, List.of(out)
));
Map<String, Object> result = node.apply(state);
assertEquals("Alice's salary is 12345.", result.get(FINAL_ANSWER),
"Direct tool result must reach the user verbatim, no LLM rewriting");
assertEquals("return_direct", result.get(FINISH_REASON));
}
@Test
@DisplayName("RFC-052: multiple direct outputs are joined with tool-name headings")
void directMultiple_joinedWithHeadings() throws Exception {
DirectToolOutput a = new DirectToolOutput(
"call_1", "tool_a", "result_a", System.currentTimeMillis());
DirectToolOutput b = new DirectToolOutput(
"call_2", "tool_b", "result_b", System.currentTimeMillis());
OverAllState state = new OverAllState(Map.of(
RETURN_DIRECT_TRIGGERED, true,
DIRECT_TOOL_OUTPUTS, List.of(a, b)
));
Map<String, Object> result = node.apply(state);
String answer = (String) result.get(FINAL_ANSWER);
assertNotNull(answer);
assertTrue(answer.startsWith("### tool_a\nresult_a"),
"first heading + body should appear at the top");
assertTrue(answer.contains("### tool_b\nresult_b"),
"second heading + body should follow");
assertEquals("return_direct", result.get(FINISH_REASON));
}
@Test
@DisplayName("RFC-052: trigger flag without outputs falls through to default assembly")
void directTriggerEmpty_fallsThrough() throws Exception {
OverAllState state = new OverAllState(Map.of(
RETURN_DIRECT_TRIGGERED, true,
FINAL_ANSWER, "fallback content"
));
Map<String, Object> result = node.apply(state);
// Without DIRECT_TOOL_OUTPUTS we should NOT short-circuit to RETURN_DIRECT
// the existing FINAL_ANSWER path handles it as NORMAL.
assertEquals("fallback content", result.get(FINAL_ANSWER));
assertEquals("normal", result.get(FINISH_REASON));
}
@Test
@DisplayName("RFC-052: direct path takes precedence over draft / existing answer / approval")
void directBranch_highestPriority() throws Exception {
DirectToolOutput out = new DirectToolOutput(
"call_1", "tool_x", "direct text", System.currentTimeMillis());
OverAllState state = new OverAllState(Map.of(
RETURN_DIRECT_TRIGGERED, true,
DIRECT_TOOL_OUTPUTS, List.of(out),
FINAL_ANSWER, "must be ignored",
FINAL_ANSWER_DRAFT, "must also be ignored"
));
Map<String, Object> result = node.apply(state);
assertEquals("direct text", result.get(FINAL_ANSWER));
assertEquals("return_direct", result.get(FINISH_REASON));
}
@Test
@DisplayName("源码证据不足时降级 finishReason 并提示未验证引用")
void unsupportedSourceReferencesBecomeEvidenceInsufficient() throws Exception {
SourceEvidenceLedger ledger = SourceEvidenceLedger.empty()
.withSourcePath("src/main/java/vip/mate/skill/SkillController.java");
OverAllState state = new OverAllState(Map.of(
SOURCE_EVIDENCE_LEDGER, ledger,
FINAL_ANSWER, "SkillController.java 是入口SkillServiceImpl.java 负责业务逻辑。"
));
Map<String, Object> result = node.apply(state);
assertEquals("evidence_insufficient", result.get(FINISH_REASON));
assertTrue(((String) result.get(FINAL_ANSWER)).contains("SkillServiceImpl.java"));
assertTrue(((String) result.get(FINAL_ANSWER)).contains("证据不足"));
}
// ========== finish_reason GraphEvent (P1: must ride PENDING_EVENTS, not SSE bypass) ==========
/**
* Pull the {@code finish_reason} GraphEvent attached to a node output.
* Returns null when no such event was emitted.
*/
@SuppressWarnings("unchecked")
private static GraphEventPublisher.GraphEvent pickFinishReasonEvent(Map<String, Object> output) {
Object raw = output.get(PENDING_EVENTS);
if (!(raw instanceof List<?> list)) return null;
for (Object item : list) {
if (item instanceof GraphEventPublisher.GraphEvent ev
&& GraphEventPublisher.EVENT_FINISH_REASON.equals(ev.type())) {
return ev;
}
}
return null;
}
@Test
@DisplayName("normal path emits finish_reason GraphEvent on PENDING_EVENTS so the accumulator can persist it")
void normalPath_emitsFinishReasonEvent() throws Exception {
OverAllState state = new OverAllState(Map.of(
FINAL_ANSWER, "正常回答"
));
Map<String, Object> result = node.apply(state);
GraphEventPublisher.GraphEvent ev = pickFinishReasonEvent(result);
assertNotNull(ev, "FinalAnswerNode must attach a finish_reason GraphEvent (NORMAL path)");
assertEquals("normal", ev.data().get("reason"));
}
@Test
@DisplayName("incomplete path also emits finish_reason GraphEvent (regression for the SSE-bypass bug)")
void incompletePath_emitsFinishReasonEvent() throws Exception {
// Simulates ReasoningNode handing INCOMPLETE through to FinalAnswerNode
// (e.g. repetition-truncated partial). The earlier fix wired this via
// streamTracker.broadcastObject which was an SSE-only bypass the
// accumulator never saw it. Now it MUST ride PENDING_EVENTS.
OverAllState state = new OverAllState(Map.of(
FINAL_ANSWER, "已经流式输出的部分内容…",
FINISH_REASON, "incomplete"
));
Map<String, Object> result = node.apply(state);
GraphEventPublisher.GraphEvent ev = pickFinishReasonEvent(result);
assertNotNull(ev, "INCOMPLETE finish_reason must reach the channel via PENDING_EVENTS");
assertEquals("incomplete", ev.data().get("reason"));
}
@Test
@DisplayName("RFC-052 RETURN_DIRECT path emits finish_reason GraphEvent")
void returnDirectPath_emitsFinishReasonEvent() throws Exception {
DirectToolOutput out = new DirectToolOutput(
"call_1", "tool_x", "direct text", System.currentTimeMillis());
OverAllState state = new OverAllState(Map.of(
RETURN_DIRECT_TRIGGERED, true,
DIRECT_TOOL_OUTPUTS, List.of(out)
));
Map<String, Object> result = node.apply(state);
GraphEventPublisher.GraphEvent ev = pickFinishReasonEvent(result);
assertNotNull(ev, "RETURN_DIRECT path must attach a finish_reason GraphEvent");
assertEquals("return_direct", ev.data().get("reason"));
}
@Test
@DisplayName("AWAITING_APPROVAL path emits finish_reason GraphEvent (NORMAL while paused)")
void awaitingApprovalPath_emitsFinishReasonEvent() throws Exception {
OverAllState state = new OverAllState(Map.of(
AWAITING_APPROVAL, true,
STREAMED_CONTENT, "我现在要做 X 操作。"
));
Map<String, Object> result = node.apply(state);
GraphEventPublisher.GraphEvent ev = pickFinishReasonEvent(result);
assertNotNull(ev, "AWAITING_APPROVAL path must attach a finish_reason GraphEvent");
assertEquals("normal", ev.data().get("reason"),
"Approval pause is treated as a normal pause; the resolved decision will emit a fresh event on replay");
}
@Test
@DisplayName("evidence_insufficient path emits finish_reason GraphEvent with the downgraded reason")
void evidenceInsufficientPath_emitsFinishReasonEvent() throws Exception {
SourceEvidenceLedger ledger = SourceEvidenceLedger.empty()
.withSourcePath("src/main/java/vip/mate/skill/SkillController.java");
OverAllState state = new OverAllState(Map.of(
SOURCE_EVIDENCE_LEDGER, ledger,
FINAL_ANSWER, "SkillController.java 是入口SkillServiceImpl.java 负责业务逻辑。"
));
Map<String, Object> result = node.apply(state);
GraphEventPublisher.GraphEvent ev = pickFinishReasonEvent(result);
assertNotNull(ev);
assertEquals("evidence_insufficient", ev.data().get("reason"),
"Downgraded finishReason must surface in the GraphEvent, not the original NORMAL");
}
// ========== fake-URL guard ==========
//
// Without the guard, a hallucinated /api/v1/files/generated/{uuid} URL
// surfaces verbatim to every channel IM clients render a clickable
// link that 404s, and users save the 404 HTML body as a .docx which
// they then report as "corrupted file". Putting the guard at the
// FinalAnswerNode terminal means EVERY channel (Web SSE, Slack,
// DingTalk, WeCom, Telegram, ) sees the same scrubbed text.
@Test
@DisplayName("fake-URL guard: hallucinated generated-file URL → user-visible warning")
void fakeUrl_replacedWithWarning() throws Exception {
FinalAnswerNode guarded = new FinalAnswerNode(new GeneratedFileCache());
OverAllState state = new OverAllState(Map.of(
FINAL_ANSWER, "您的文档已生成: /api/v1/files/generated/a1b2c3d4-e5f6-7890-abcd-ef1234567890"
));
Map<String, Object> result = guarded.apply(state);
String answer = (String) result.get(FINAL_ANSWER);
assertFalse(answer.contains("/api/v1/files/generated/"),
"fake URL must not survive in the persisted answer; got: " + answer);
assertTrue(answer.contains(GeneratedFileCache.MISSING_REFERENCE_NOTICE),
"user-visible warning must appear in place of the fake URL; got: " + answer);
}
@Test
@DisplayName("fake-URL guard: real cached URL passes through so channel adapters can rewrite it")
void realUrl_leftIntact() throws Exception {
GeneratedFileCache cache = new GeneratedFileCache();
String id = cache.put("real-bytes".getBytes(), "report.pdf", "application/pdf");
FinalAnswerNode guarded = new FinalAnswerNode(cache);
OverAllState state = new OverAllState(Map.of(
FINAL_ANSWER, "下载: /api/v1/files/generated/" + id
));
Map<String, Object> result = guarded.apply(state);
// Cached URLs survive verbatim downstream WeCom / Slack / etc.
// adapters can still rewrite them into native attachments.
assertTrue(((String) result.get(FINAL_ANSWER))
.contains("/api/v1/files/generated/" + id),
"live cached URL must pass through for downstream native-attachment rewrite");
}
@Test
@DisplayName("fake-URL guard: also fires on RETURN_DIRECT path (tool output may also hallucinate)")
void fakeUrl_scrubbedOnDirectPath() throws Exception {
FinalAnswerNode guarded = new FinalAnswerNode(new GeneratedFileCache());
DirectToolOutput out = new DirectToolOutput(
"call_1", "tool_x",
"see /api/v1/files/generated/never-rendered-uuid",
System.currentTimeMillis());
OverAllState state = new OverAllState(Map.of(
RETURN_DIRECT_TRIGGERED, true,
DIRECT_TOOL_OUTPUTS, List.of(out)
));
Map<String, Object> result = guarded.apply(state);
String answer = (String) result.get(FINAL_ANSWER);
assertFalse(answer.contains("never-rendered-uuid"),
"RETURN_DIRECT path must also scrub; got: " + answer);
}
@Test
@DisplayName("fake-URL guard: no-cache constructor (legacy callers, narrow tests) is a no-op")
void noCache_passThrough() throws Exception {
// FinalAnswerNode without an injected cache must not throw the
// narrow unit tests that construct the node with the no-arg ctor
// still need to work. The trade-off: tests that don't exercise
// file outputs simply skip the scrub. Production wiring always
// passes a real cache from AgentGraphBuilder.
FinalAnswerNode unguarded = new FinalAnswerNode();
OverAllState state = new OverAllState(Map.of(
FINAL_ANSWER, "/api/v1/files/generated/anything"
));
Map<String, Object> result = unguarded.apply(state);
assertEquals("/api/v1/files/generated/anything", result.get(FINAL_ANSWER));
}
// ========== feedback_event recovery affordance ==========
//
// After NodeStreamingChatHelper has exhausted its TLS/IO retry budget
// and the turn ends in ERROR_FALLBACK, the user is left staring at
// red "[错误] …" text with no recovery affordance. FinalAnswerNode
// attaches a feedback_event GraphEvent so the frontend can render
// retry/regenerate/report buttons next to the failed bubble and
// the event is persisted into message metadata so a page reload
// doesn't make the affordance vanish.
/** Pull the feedback_event GraphEvent attached to a node output. */
@SuppressWarnings("unchecked")
private static GraphEventPublisher.GraphEvent pickFeedbackEvent(Map<String, Object> output) {
Object raw = output.get(PENDING_EVENTS);
if (!(raw instanceof List<?> list)) return null;
for (Object item : list) {
if (item instanceof GraphEventPublisher.GraphEvent ev
&& GraphEventPublisher.EVENT_FEEDBACK.equals(ev.type())) {
return ev;
}
}
return null;
}
@Test
@DisplayName("ERROR_FALLBACK turn emits feedback_event with retry/regenerate/report actions")
void errorFallback_emitsFeedbackEvent() throws Exception {
// Mirrors the production path: ReasoningNode hands a fatal-error
// finalAnswer + ERROR_FALLBACK finishReason to FinalAnswerNode.
OverAllState state = new OverAllState(Map.of(
FINAL_ANSWER, "[错误] LLM 调用失败: bad_record_mac",
FINISH_REASON, "error_fallback"
));
Map<String, Object> result = node.apply(state);
GraphEventPublisher.GraphEvent ev = pickFeedbackEvent(result);
assertNotNull(ev, "ERROR_FALLBACK turn must attach a feedback_event for the UI");
assertEquals("ERROR_FALLBACK", ev.data().get("errorType"));
assertEquals("[错误] LLM 调用失败: bad_record_mac", ev.data().get("errorMessage"));
Object actions = ev.data().get("actions");
assertTrue(actions instanceof List<?>);
assertEquals(List.of("retry", "regenerate", "report"), actions);
}
@Test
@DisplayName("ERROR_FALLBACK still emits the standard finish_reason event alongside feedback_event")
void errorFallback_alsoEmitsFinishReason() throws Exception {
// The two events ride the same PENDING_EVENTS list. Existing
// consumers (memory gate, channel accumulator, message metadata
// persistence) read finish_reason; the new feedback_event is
// additive losing finish_reason here would silently break
// those consumers.
OverAllState state = new OverAllState(Map.of(
FINAL_ANSWER, "[错误] 认证失败: Invalid API Key",
FINISH_REASON, "error_fallback"
));
Map<String, Object> result = node.apply(state);
GraphEventPublisher.GraphEvent fr = pickFinishReasonEvent(result);
assertNotNull(fr, "finish_reason event must remain on PENDING_EVENTS");
assertEquals("error_fallback", fr.data().get("reason"));
GraphEventPublisher.GraphEvent fb = pickFeedbackEvent(result);
assertNotNull(fb, "feedback_event must coexist with finish_reason on the same output");
}
@Test
@DisplayName("NORMAL turn does NOT emit feedback_event (no recovery affordance needed)")
void normalTurn_noFeedbackEvent() throws Exception {
OverAllState state = new OverAllState(Map.of(
FINAL_ANSWER, "正常回答"
));
Map<String, Object> result = node.apply(state);
assertNull(pickFeedbackEvent(result),
"Successful turns must not attach feedback_event — would render misleading retry buttons");
}
@Test
@DisplayName("INCOMPLETE turn does NOT emit feedback_event (handled by its own card)")
void incompleteTurn_noFeedbackEvent() throws Exception {
// INCOMPLETE has its own dedicated UI card ("regenerate" button
// wired via finishReason=incomplete). Adding feedback_event there
// would duplicate the affordance and confuse users.
OverAllState state = new OverAllState(Map.of(
FINAL_ANSWER, "已经流式输出的部分内容…",
FINISH_REASON, "incomplete"
));
Map<String, Object> result = node.apply(state);
assertNull(pickFeedbackEvent(result),
"INCOMPLETE has its own card; must not also surface feedback_event");
}
@Test
@DisplayName("STOPPED (user-initiated abort) does NOT emit feedback_event")
void stoppedTurn_noFeedbackEvent() throws Exception {
// User clicked stop. They don't need a "retry" prompt the
// partial output is the explicit signal they asked for.
OverAllState state = new OverAllState(Map.of(
FINAL_ANSWER, "我刚在生成…",
FINISH_REASON, "stopped"
));
Map<String, Object> result = node.apply(state);
assertNull(pickFeedbackEvent(result));
}
}

View File

@ -5,10 +5,14 @@ import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
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.model.ChatModel;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.tool.ToolCallback;
import vip.mate.agent.AgentToolSet;
import vip.mate.agent.graph.NodeStreamingChatHelper;
import vip.mate.agent.graph.state.SourceEvidenceLedger;
import vip.mate.channel.web.ChatStreamTracker;
import java.util.HashMap;
@ -17,6 +21,7 @@ import java.util.Map;
import java.util.concurrent.CancellationException;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentCaptor.forClass;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import static vip.mate.agent.graph.state.MateClawStateKeys.*;
@ -98,6 +103,38 @@ class ReasoningNodeOutputTest {
assertEquals("回答内容", output.get(FINAL_ANSWER));
}
@Test
@DisplayName("源码证据不足的 final answer原文进 streamedContent警告作为 finalAnswer 追加")
void evidenceInsufficientFinalAnswer_splitsPersistedContentAndWarning() throws Exception {
NodeStreamingChatHelper.StreamResult result = new NodeStreamingChatHelper.StreamResult(
"SkillController.java 是入口SkillServiceImpl.java 负责业务。", "",
new AssistantMessage("SkillController.java 是入口SkillServiceImpl.java 负责业务。"),
List.of(), false, 100, 50);
when(streamingHelper.streamCall(any(), any(), anyString(), anyString())).thenReturn(result);
Map<String, Object> stateMap = new HashMap<>();
stateMap.put(CONVERSATION_ID, "test-conv");
stateMap.put(SYSTEM_PROMPT, "you are a helper");
stateMap.put(USER_MESSAGE, "分析源码");
stateMap.put(MESSAGES, List.of());
stateMap.put(CURRENT_ITERATION, 3);
stateMap.put(MAX_ITERATIONS, 10);
stateMap.put(LLM_CALL_COUNT, 5);
stateMap.put(FORCED_TOOL_CALL, "");
stateMap.put(SOURCE_EVIDENCE_LEDGER, SourceEvidenceLedger.empty()
.withSourcePath("src/main/java/vip/mate/skill/controller/SkillController.java"));
Map<String, Object> output = createNode().apply(new OverAllState(stateMap));
assertControlFlagsCleared(output, "evidenceInsufficientFinalAnswer");
assertEquals("evidence_insufficient", output.get(FINISH_REASON));
assertEquals("SkillController.java 是入口SkillServiceImpl.java 负责业务。",
output.get(STREAMED_CONTENT));
assertTrue(((String) output.get(FINAL_ANSWER)).contains("证据不足"));
assertTrue(((String) output.get(FINAL_ANSWER)).contains("SkillServiceImpl.java"));
assertEquals(false, output.get(CONTENT_STREAMED),
"warning suffix should be broadcast and persisted as a visible final delta");
}
// ===== 工具调用 =====
@Test
@ -138,6 +175,33 @@ class ReasoningNodeOutputTest {
assertEquals("error_fallback", output.get(FINISH_REASON));
}
@Test
@DisplayName("thinking-only no-content 路径:标 INCOMPLETE 并附带可重试提示")
void thinkingOnlyCap_preservedAsIncomplete() throws Exception {
// Simulates the "深度思考 ... 5.4k chars never finishes" symptom:
// helper disposes the stream after THINKING_ONLY_HARD_CAP_CHARS of
// reasoning_content with zero visible content/tools. ReasoningNode
// surfaces a short fallback line and preserves the thinking transcript.
String thinkingTranscript = "我先读 X再读 Y再读 Z…".repeat(64);
NodeStreamingChatHelper.StreamResult result = new NodeStreamingChatHelper.StreamResult(
"", thinkingTranscript, new AssistantMessage(""),
List.of(), false, 0, 600, true, "thinking_only_no_content",
NodeStreamingChatHelper.ErrorType.UNKNOWN);
when(streamingHelper.streamCall(any(), any(), anyString(), anyString())).thenReturn(result);
Map<String, Object> output = createNode().apply(buildStaleState());
assertControlFlagsCleared(output, "thinkingOnlyCap");
assertLlmCallCountWritten(output, "thinkingOnlyCap");
assertEquals("incomplete", output.get(FINISH_REASON));
String answer = (String) output.get(FINAL_ANSWER);
assertNotNull(answer);
assertTrue(answer.contains("思考阶段"),
"Fallback line should explain the thinking-only loop to the user");
assertEquals(thinkingTranscript, output.get(FINAL_THINKING),
"Thinking transcript must be preserved for the UI's collapse panel");
}
// ===== CancellationException (no content stop) =====
@Test

View File

@ -0,0 +1,152 @@
package vip.mate.agent.graph.state;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.messages.ToolResponseMessage;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
class SourceEvidenceLedgerTest {
@Test
@DisplayName("records successful read_file paths and symbols")
void recordsReadFileEvidence() {
String response = """
{
"filePath": "/repo/src/main/java/vip/mate/skill/SkillController.java",
"totalLines": 120,
"startLine": 1,
"endLine": 80,
"content": " 1\\tpackage vip.mate.skill;\\n 2\\tpublic class SkillController { }\\n"
}
""";
SourceEvidenceLedger ledger = SourceEvidenceLedger.fromToolResponses(List.of(
new ToolResponseMessage.ToolResponse("c1", "read_file", response)));
assertTrue(ledger.hasPath("/repo/src/main/java/vip/mate/skill/SkillController.java"));
assertTrue(ledger.hasSymbol("SkillController"));
assertFalse(ledger.hasSymbol("SkillServiceImpl"));
}
@Test
@DisplayName("ignores failed read_file responses")
void ignoresFailedReads() {
String response = """
{"filePath": "/repo/Missing.java", "error": true, "message": "not found"}
""";
SourceEvidenceLedger ledger = SourceEvidenceLedger.fromToolResponses(List.of(
new ToolResponseMessage.ToolResponse("c1", "read_file", response)));
assertFalse(ledger.hasPath("/repo/Missing.java"));
assertTrue(ledger.failedPaths().contains("/repo/Missing.java"));
}
@Test
@DisplayName("validates Java references in final answers against evidence")
void detectsUnsupportedAnswerReferences() {
SourceEvidenceLedger ledger = SourceEvidenceLedger.fromToolResponses(List.of(
new ToolResponseMessage.ToolResponse("c1", "execute_shell_command",
"src/main/java/vip/mate/skill/SkillController.java\n")));
SourceEvidenceLedger.Validation validation = ledger.validateAnswer("""
已确认 SkillController.java 负责接口 SkillServiceImpl.java 负责业务
""");
assertFalse(validation.valid());
assertTrue(validation.unsupportedReferences().contains("SkillServiceImpl.java"));
assertFalse(validation.unsupportedReferences().contains("SkillController.java"));
}
// ====== Regression coverage for the "grep output → ledger" path ======
// Reviewer point: JAVA_PATH already accepts bare file names, so a P2
// "add JAVA_FILE_REF to plain text scan" would be redundant. These tests
// pin that contract so the next person doesn't try the same wrong fix.
@Test
@DisplayName("bare .java filename in shell stdout is recorded as both path and symbol")
void recordsBareFilenameFromShellStdout() {
// Some greps / find -printf outputs emit just the filename no path
// prefix, no `:` line marker. JAVA_PATH still matches because [+]
// demands 1 word/dot/slash chars, which "ObservationNode" satisfies.
SourceEvidenceLedger ledger = SourceEvidenceLedger.fromToolResponses(List.of(
new ToolResponseMessage.ToolResponse("c1", "execute_shell_command",
"ObservationNode.java\n")));
assertTrue(ledger.hasPath("ObservationNode.java"),
"bare filename must register under sourcePaths");
assertTrue(ledger.hasSymbol("ObservationNode"),
"the .java stem must be auto-promoted into sourceSymbols");
}
@Test
@DisplayName("grep -rn output (`path:line:body`) is parsed and the file goes into ledger")
void recordsGrepDashRnOutput() {
// Real-world grep -rn output: `relative/path:lineno:matching line`.
// JAVA_PATH greedy match consumes through the .java suffix and stops
// at the colon (\\b boundary), so the path portion lands in sourcePaths.
String grepStdout = """
src/main/java/vip/mate/agent/graph/node/ObservationNode.java:42: public class ObservationNode implements NodeAction {
src/main/java/vip/mate/agent/graph/node/ObservationNode.java:88: log.info("[Observation]");
""";
SourceEvidenceLedger ledger = SourceEvidenceLedger.fromToolResponses(List.of(
new ToolResponseMessage.ToolResponse("c1", "execute_shell_command", grepStdout)));
assertTrue(ledger.hasPath("src/main/java/vip/mate/agent/graph/node/ObservationNode.java"));
assertTrue(ledger.hasSymbol("ObservationNode"));
// Critical: an answer citing ObservationNode (no .java suffix) must NOT be
// flagged as evidence-insufficient on the strength of the grep alone.
// Use only this one symbol in the answer so the test isolates exactly
// what we're verifying (other *Node names in the sentence would be
// counted as separate symbol citations).
SourceEvidenceLedger.Validation validation = ledger.validateAnswer(
"ObservationNode 写回观察历史。");
assertTrue(validation.valid(),
"Symbol named ObservationNode is supported by the grep evidence; should not be flagged");
}
@Test
@DisplayName("real-task regression: ObservationNode + ToolGuardAuditLogEntity grep evidence supports their citations")
void regressionForRealTraceUnsupportedRefs() {
// The exact two unsupported refs from production trace 4b38f04f:
// unsupportedReferences=[ObservationNode, ToolGuardAuditLogEntity]
// If the model had genuinely seen these names in shell results, ledger
// should have accepted them. This test simulates the grep output that
// would have appeared in a real run if it passes, the production
// miss is NOT a JAVA_PATH parsing bug; root cause must be elsewhere
// (spill / compact dropping the matching lines before ActionNode
// builds the ledger).
String evidence = """
src/main/java/vip/mate/agent/graph/node/ObservationNode.java
src/main/java/vip/mate/tool/guard/entity/ToolGuardAuditLogEntity.java
""";
SourceEvidenceLedger ledger = SourceEvidenceLedger.fromToolResponses(List.of(
new ToolResponseMessage.ToolResponse("c1", "execute_shell_command", evidence)));
SourceEvidenceLedger.Validation validation = ledger.validateAnswer(
"工具结果由 ObservationNode 写回,并落库到 ToolGuardAuditLogEntity。");
assertTrue(validation.valid(),
"Both citations must be considered supported when their .java files appear in shell output. "
+ "If this fails, fix JAVA_PATH; if it passes, the production miss is in spill/compact, "
+ "not in ledger parsing.");
}
@Test
@DisplayName("citing a class with NO matching .java in any tool output is correctly flagged unsupported")
void unrelatedSymbolInAnswerIsStillFlagged() {
// Negative control for the regression test above: make sure the
// 'support' check isn't trivially over-broad symbols that have no
// backing evidence at all must still trip evidence_insufficient.
SourceEvidenceLedger ledger = SourceEvidenceLedger.fromToolResponses(List.of(
new ToolResponseMessage.ToolResponse("c1", "execute_shell_command",
"ObservationNode.java\n")));
SourceEvidenceLedger.Validation validation = ledger.validateAnswer(
"ObservationNode 协作 RandomMadeUpService 完成处理。");
assertFalse(validation.valid());
assertTrue(validation.unsupportedReferences().contains("RandomMadeUpService"));
}
}

View File

@ -0,0 +1,346 @@
package vip.mate.agent.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import vip.mate.agent.AgentService;
import vip.mate.agent.binding.service.AgentBindingService;
import vip.mate.agent.model.AgentEntity;
import vip.mate.agent.model.TemplateDTO;
import vip.mate.exception.MateClawException;
import vip.mate.skill.model.SkillEntity;
import vip.mate.skill.repository.SkillMapper;
import vip.mate.tool.model.AvailableToolDTO;
import vip.mate.tool.service.AvailableToolService;
import vip.mate.workspace.document.WorkspaceFileService;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* Hire-time pre-binding behavior for {@link TemplateService#applyTemplate}.
*
* <p>The contract being pinned: a template that declares
* {@code defaultSkillSlugs} / {@code defaultToolNames} produces an agent that
* already has those capabilities wired, and references that can't be
* resolved (slug not in this workspace, tool not in the picker) are dropped
* silently the hire MUST still succeed so a partially-installed
* environment doesn't break onboarding.
*/
class TemplateServiceBindingTest {
private static final long WORKSPACE = 1L;
private static final long CREATOR = 7L;
private static final long CREATED_AGENT_ID = 999L;
private AgentService agentService;
private WorkspaceFileService workspaceFileService;
private AgentBindingService agentBindingService;
private SkillMapper skillMapper;
private AvailableToolService availableToolService;
private TemplateService service;
private TemplateService spyService;
@BeforeEach
void setUp() {
agentService = mock(AgentService.class);
workspaceFileService = mock(WorkspaceFileService.class);
agentBindingService = mock(AgentBindingService.class);
skillMapper = mock(SkillMapper.class);
availableToolService = mock(AvailableToolService.class);
// createAgent stamps an id and echoes the entity back, matching the
// real DAO contract the production code relies on.
when(agentService.createAgent(any(AgentEntity.class))).thenAnswer(inv -> {
AgentEntity a = inv.getArgument(0);
a.setId(CREATED_AGENT_ID);
return a;
});
service = new TemplateService(
agentService,
workspaceFileService,
new ObjectMapper(),
agentBindingService,
skillMapper,
availableToolService);
spyService = spy(service);
}
/** Build a minimal template; tests append bind lists. */
private TemplateDTO baseTemplate(String id) {
TemplateDTO t = new TemplateDTO();
t.setId(id);
t.setName(id);
t.setDescription("test");
t.setAgentType("react");
t.setMaxIterations(10);
t.setSystemPrompt("## Role\ntest");
return t;
}
/** Stub the in-memory template registry so the test owns the data. */
private void registerTemplate(TemplateDTO template) {
doReturn(List.of(template)).when(spyService).listTemplates();
}
private SkillEntity skillRow(long id, String slug) {
SkillEntity s = new SkillEntity();
s.setId(id);
s.setName(slug);
s.setWorkspaceId(WORKSPACE);
return s;
}
private AvailableToolDTO availableTool(String name) {
AvailableToolDTO dto = new AvailableToolDTO();
dto.setName(name);
dto.setAvailable(true);
return dto;
}
@Test
@DisplayName("declared skill slugs resolve to ids and pre-bind on the new agent")
void preBindsDeclaredSkillSlugs() {
TemplateDTO t = baseTemplate("data-analyst-stub");
t.setDefaultSkillSlugs(List.of("sql_query", "xlsx"));
registerTemplate(t);
when(skillMapper.selectOne(any(LambdaQueryWrapper.class)))
.thenReturn(skillRow(101L, "sql_query"))
.thenReturn(skillRow(202L, "xlsx"));
AgentEntity created = spyService.applyTemplate("data-analyst-stub", WORKSPACE, CREATOR, null);
assertNotNull(created.getId());
verify(agentBindingService, times(1))
.setSkillBindings(eq(CREATED_AGENT_ID), argThat(ids ->
ids.size() == 2 && ids.contains(101L) && ids.contains(202L)));
// No tool bindings declared no tool side-effects.
verify(agentBindingService, never()).setToolBindings(anyLong(), anyList());
}
@Test
@DisplayName("missing slugs are skipped without aborting the hire")
void skipsMissingSlugsAndStillHires() {
TemplateDTO t = baseTemplate("partial-stub");
t.setDefaultSkillSlugs(List.of("ghost-skill", "sql_query"));
registerTemplate(t);
when(skillMapper.selectOne(any(LambdaQueryWrapper.class)))
.thenReturn(null) // ghost-skill not in workspace
.thenReturn(skillRow(101L, "sql_query"));
AgentEntity created = spyService.applyTemplate("partial-stub", WORKSPACE, CREATOR, null);
assertNotNull(created.getId());
// Only the resolvable slug makes it into the binding call.
verify(agentBindingService, times(1))
.setSkillBindings(eq(CREATED_AGENT_ID), argThat(ids ->
ids.size() == 1 && ids.contains(101L)));
}
@Test
@DisplayName("when every slug is unknown, setSkillBindings is never called and the agent still exists")
void noSlugsResolveSoNoBindCall() {
TemplateDTO t = baseTemplate("all-ghost-stub");
t.setDefaultSkillSlugs(List.of("ghost-a", "ghost-b"));
registerTemplate(t);
when(skillMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(null);
AgentEntity created = spyService.applyTemplate("all-ghost-stub", WORKSPACE, CREATOR, null);
assertNotNull(created.getId());
// Empty resolved list caller must NOT issue an empty
// setSkillBindings (which would otherwise wipe out future bindings
// post-create if any race wrote them in between).
verify(agentBindingService, never()).setSkillBindings(anyLong(), anyList());
}
@Test
@DisplayName("legacy templates with no binding fields behave as before")
void noBindingFieldsLeavesAgentUntouched() {
TemplateDTO t = baseTemplate("legacy-stub");
// Neither defaultSkillSlugs nor defaultToolNames set.
registerTemplate(t);
AgentEntity created = spyService.applyTemplate("legacy-stub", WORKSPACE, CREATOR, null);
assertNotNull(created.getId());
verify(agentBindingService, never()).setSkillBindings(anyLong(), anyList());
verify(agentBindingService, never()).setToolBindings(anyLong(), anyList());
}
@Test
@DisplayName("tool names are pre-filtered through the picker before binding")
void toolBindingsFilterAgainstPicker() {
TemplateDTO t = baseTemplate("tool-stub");
t.setDefaultToolNames(List.of("search", "ghost_tool", "browser_use"));
registerTemplate(t);
when(availableToolService.listAvailable()).thenReturn(List.of(
availableTool("search"),
availableTool("browser_use")));
AgentEntity created = spyService.applyTemplate("tool-stub", WORKSPACE, CREATOR, null);
assertNotNull(created.getId());
// ghost_tool is not in the picker filtered. The remaining two
// pass through; setToolBindings's own validator would otherwise
// throw on the unknown name and abort the entire bind call.
verify(agentBindingService, times(1))
.setToolBindings(eq(CREATED_AGENT_ID), argThat(names ->
names.size() == 2
&& names.contains("search")
&& names.contains("browser_use")
&& !names.contains("ghost_tool")));
}
@Test
@DisplayName("picker failure during apply skips tool binding instead of breaking the hire")
void pickerFailureDoesNotBreakHire() {
TemplateDTO t = baseTemplate("picker-down-stub");
t.setDefaultToolNames(List.of("search"));
registerTemplate(t);
when(availableToolService.listAvailable())
.thenThrow(new RuntimeException("MCP discovery upstream timeout"));
AgentEntity created = spyService.applyTemplate("picker-down-stub", WORKSPACE, CREATOR, null);
// Hire still completes; tool bind silently skipped (conservative
// stance documented on applyDefaultToolBindings).
assertEquals(CREATED_AGENT_ID, created.getId());
verify(agentBindingService, never()).setToolBindings(anyLong(), anyList());
}
@Test
@DisplayName("setSkillBindings exception propagates so @Transactional rolls back the hire")
void bindServiceExceptionPropagates() {
// Pins the documented split: resolution failures are graceful, but
// service-layer exceptions (a race deleting the skill row between
// resolve and bind, a workspace-mismatch we couldn't predict) are
// fail-stop. If someone later wraps the bind call in try/catch to
// "make it more robust", this test forces them to also revisit
// applyDefaultSkillBindings's Javadoc and the @Transactional
// rollback contract instead of silently changing behavior.
TemplateDTO t = baseTemplate("racey-stub");
t.setDefaultSkillSlugs(List.of("sql_query"));
registerTemplate(t);
when(skillMapper.selectOne(any(LambdaQueryWrapper.class)))
.thenReturn(skillRow(101L, "sql_query"));
doThrow(new MateClawException("err.skill.cross_workspace_binding", 403, "simulated race"))
.when(agentBindingService).setSkillBindings(anyLong(), anyList());
MateClawException thrown = assertThrows(MateClawException.class,
() -> spyService.applyTemplate("racey-stub", WORKSPACE, CREATOR, null));
assertEquals(403, thrown.getCode());
assertEquals("err.skill.cross_workspace_binding", thrown.getMsgKey());
}
@Test
@DisplayName("workspace lookup reads from the persisted agent — survives a service-side workspace override")
void workspaceLookupUsesPersistedAgent() {
// Defends against a future where AgentService.createAgent normalises
// workspaceId (auto-assign default, project-onto-user-default, etc.)
// the slug resolver MUST query the same workspace that the bind
// validator will check. Here we mutate the persisted agent's
// workspace to a value different from the input parameter; if the
// helper still queried the parameter, the lookup would target the
// wrong workspace and (in production) miss the seeded skill. We
// can't introspect the LambdaQueryWrapper's parameter map from a
// Mockito-only test (MyBatis-Plus lambda cache isn't bootstrapped),
// so this test pins the flow against crashes; the workspace-source
// correctness is enforced by code review on the helper itself.
when(agentService.createAgent(any(AgentEntity.class))).thenAnswer(inv -> {
AgentEntity a = inv.getArgument(0);
a.setId(CREATED_AGENT_ID);
a.setWorkspaceId(42L);
return a;
});
TemplateDTO t = baseTemplate("ws-override-stub");
t.setDefaultSkillSlugs(List.of("sql_query"));
registerTemplate(t);
when(skillMapper.selectOne(any(LambdaQueryWrapper.class)))
.thenReturn(skillRow(101L, "sql_query"));
spyService.applyTemplate("ws-override-stub", WORKSPACE /* = 1 */, CREATOR, null);
verify(agentBindingService, times(1))
.setSkillBindings(eq(CREATED_AGENT_ID), argThat(ids ->
ids.size() == 1 && ids.contains(101L)));
}
@Test
@DisplayName("null workspace on the persisted agent does not crash the helper")
void nullWorkspaceFallsBackToOne() {
// Mirrors the AgentBindingService.requireSameWorkspace fallback
// a row with workspace_id = null must not produce an `IS NULL`
// lookup that silently matches nothing. The helper falls back to
// workspace 1; without that, the LambdaQueryWrapper would still
// build but every seeded skill would miss. Smoke-tested here for
// crash-freeness; the value of the fallback (1L) is asserted by
// code review of the helper.
when(agentService.createAgent(any(AgentEntity.class))).thenAnswer(inv -> {
AgentEntity a = inv.getArgument(0);
a.setId(CREATED_AGENT_ID);
a.setWorkspaceId(null);
return a;
});
TemplateDTO t = baseTemplate("null-ws-stub");
t.setDefaultSkillSlugs(List.of("sql_query"));
registerTemplate(t);
when(skillMapper.selectOne(any(LambdaQueryWrapper.class)))
.thenReturn(skillRow(101L, "sql_query"));
spyService.applyTemplate("null-ws-stub", WORKSPACE, CREATOR, null);
verify(agentBindingService, times(1))
.setSkillBindings(eq(CREATED_AGENT_ID), argThat(ids -> ids.contains(101L)));
}
@Test
@DisplayName("blank slug entries are skipped before they reach the mapper")
void blankSlugsSkipped() {
TemplateDTO t = baseTemplate("blanks-stub");
t.setDefaultSkillSlugs(java.util.Arrays.asList("sql_query", "", null, " "));
registerTemplate(t);
when(skillMapper.selectOne(any(LambdaQueryWrapper.class)))
.thenReturn(skillRow(101L, "sql_query"));
AgentEntity created = spyService.applyTemplate("blanks-stub", WORKSPACE, CREATOR, null);
assertNotNull(created.getId());
// Only the one real slug triggers a mapper lookup only one bind.
verify(skillMapper, times(1)).selectOne(any(LambdaQueryWrapper.class));
verify(agentBindingService, times(1))
.setSkillBindings(eq(CREATED_AGENT_ID), argThat(ids ->
ids.size() == 1 && ids.contains(101L)));
}
}

View File

@ -0,0 +1,91 @@
package vip.mate.approval;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import vip.mate.agent.context.ChannelTarget;
import vip.mate.agent.context.ChatOrigin;
import static org.junit.jupiter.api.Assertions.*;
/**
* RFC-063r §2.12: Memento round-trip for cross-restart approval replay.
*
* <p>Exercises {@link ApprovalWorkflowService#restoreChatOrigin(String)}
* directly independent of the DB layer to pin the serialization
* contract: full round-trip preserves every field, and a corrupt or null
* payload falls back to {@link ChatOrigin#EMPTY} rather than throwing.
*/
class ApprovalReplayContinuityTest {
private ApprovalWorkflowService workflow;
private ObjectMapper objectMapper;
@BeforeEach
void setUp() {
objectMapper = new ObjectMapper();
// Don't run the @PostConstruct GC scheduler only need restoreChatOrigin.
workflow = new ApprovalWorkflowService(null, null, objectMapper, null);
// Inject objectMapper via reflection so the helper does not NPE.
ReflectionTestUtils.setField(workflow, "objectMapper", objectMapper);
}
@Test
void chatOrigin_persistedAndRestored_preservesAllFields() throws Exception {
ChatOrigin original = new ChatOrigin(
/* agentId */ 7L,
/* conversationId */ "wechat:chat-42",
/* requesterId */ "u-123",
/* workspaceId */ 5L,
/* workspaceBasePath */ "/data/ws/5",
/* channelId */ 9L,
/* channelTarget */ new ChannelTarget("group-a", "thread-1", "bot-001"));
String json = objectMapper.writeValueAsString(original);
ChatOrigin restored = workflow.restoreChatOrigin(json);
assertEquals(original, restored,
"Memento round-trip must preserve every field — RFC-063r §2.12");
}
@Test
void chatOrigin_corruptJson_fallsBackToEmpty() {
String corrupt = "{\"this is not\":valid JSON";
ChatOrigin restored = workflow.restoreChatOrigin(corrupt);
assertSame(ChatOrigin.EMPTY, restored,
"Corrupt payload must fall back to EMPTY without throwing");
}
@Test
void chatOrigin_nullPayload_returnsEmpty() {
assertSame(ChatOrigin.EMPTY, workflow.restoreChatOrigin(null));
}
@Test
void chatOrigin_blankPayload_returnsEmpty() {
assertSame(ChatOrigin.EMPTY, workflow.restoreChatOrigin(" "));
}
@Test
void chatOrigin_unknownFieldsInJson_areTolerated() throws Exception {
// Forward-compat: a payload written by a future build with extra
// fields must still restore the known fields.
String json = """
{
"agentId": 7,
"conversationId": "wechat:chat-42",
"requesterId": "u-123",
"workspaceId": 5,
"workspaceBasePath": "/data/ws/5",
"channelId": 9,
"channelTarget": {"targetId":"group-a","threadId":null,"accountId":null,"newField":"x"},
"futureTopLevelField": "y"
}
""";
ChatOrigin restored = workflow.restoreChatOrigin(json);
assertEquals(7L, restored.agentId());
assertEquals("wechat:chat-42", restored.conversationId());
assertEquals("group-a", restored.channelTarget().targetId());
}
}

View File

@ -0,0 +1,202 @@
package vip.mate.approval;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.apache.ibatis.session.Configuration;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import vip.mate.approval.model.ToolApprovalEntity;
import vip.mate.approval.repository.ToolApprovalMapper;
import vip.mate.workspace.conversation.ConversationService;
import java.time.Duration;
import java.time.Instant;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
/**
* GC contract for {@link ApprovalWorkflowService} (RFC-067 §4.4).
* <p>
* The pre-RFC GC lived on {@link ApprovalService} and only mutated the in-memory
* map; mate_tool_approval rows stayed PENDING forever (recover-from-DB on next
* restart resurrected them) and message metadata kept showing a ghost approval
* banner. These tests pin the migrated behavior:
* <ul>
* <li>Phase A (TTL): expired pending DB TIMEOUT + metadata DENIED + map removal</li>
* <li>Phase B (overflow): pending count over MAX oldest evicted via the same
* full-sync path</li>
* <li>Phase C (resolved cleanup): non-pending entries past RESOLVED_TTL drop
* from the map only DB / metadata are not touched</li>
* <li>Idempotent on idle ticks: nothing to GC means zero DB / metadata interactions</li>
* </ul>
*/
@ExtendWith(MockitoExtension.class)
class ApprovalWorkflowServiceGcTest {
@Mock private ToolApprovalMapper approvalMapper;
@Mock private ConversationService conversationService;
private ApprovalService approvalService;
private ApprovalWorkflowService workflow;
@BeforeAll
static void initMyBatisPlusCache() {
TableInfoHelper.initTableInfo(
new MapperBuilderAssistant(new Configuration(), ""),
ToolApprovalEntity.class);
}
@BeforeEach
void setUp() {
approvalService = new ApprovalService();
workflow = new ApprovalWorkflowService(
approvalService, approvalMapper, new ObjectMapper(), conversationService);
}
@Test
@DisplayName("Phase A: pending past PENDING_TTL goes through full DB+metadata+memory sync")
void expiredPendingGoesThroughMarkTimeout() {
// Pre-RFC: this row would silently be removed from the in-memory map but
// mate_tool_approval would stay PENDING and the next recoverFromDb would
// resurrect it. New contract: full two-phase markTimeout.
Instant created = Instant.now().minus(Duration.ofMinutes(31));
seedPending("pid-expired", "conv-1", "write_file", created);
when(approvalMapper.update(isNull(), any(Wrapper.class))).thenReturn(1);
when(conversationService.markPendingApprovalsResolved(
eq("conv-1"), any(), eq(MetadataDecision.DENIED))).thenReturn(1);
workflow.garbageCollect();
// Map cleared
assertThat(approvalService.size()).isZero();
// DB UPDATE happened exactly once (markTimeout's conditional update).
verify(approvalMapper, times(1)).update(isNull(), any(Wrapper.class));
// Metadata reconciled with DENIED.
verify(conversationService, times(1)).markPendingApprovalsResolved(
eq("conv-1"), any(), eq(MetadataDecision.DENIED));
}
@Test
@DisplayName("Phase A: pending within TTL is not touched")
void freshPendingIsKept() {
Instant created = Instant.now().minus(Duration.ofMinutes(5));
PendingApproval p = seedPending("pid-fresh", "conv-2", "search", created);
workflow.garbageCollect();
assertThat(approvalService.getPending("pid-fresh")).isPresent();
assertThat(p.getStatus()).isEqualTo("pending");
verifyNoInteractions(approvalMapper);
verifyNoInteractions(conversationService);
}
@Test
@DisplayName("Phase C: resolved entry past RESOLVED_TTL drops from map without DB / metadata touch")
void resolvedTtlExpiredIsMemoryOnlyDrop() {
// DB row already terminal workflow correctly decides this is memory-only cleanup.
Instant created = Instant.now().minus(Duration.ofHours(2));
PendingApproval p = seedPending("pid-old-approved", "conv-3", "shell", created);
p.setStatus("approved");
p.setResolvedAt(Instant.now().minus(Duration.ofHours(2)));
workflow.garbageCollect();
assertThat(approvalService.getPending("pid-old-approved")).isEmpty();
verifyNoInteractions(approvalMapper);
verifyNoInteractions(conversationService);
}
@Test
@DisplayName("Phase C: resolved entry within TTL is kept")
void freshResolvedIsKept() {
PendingApproval p = seedPending("pid-recent-approved", "conv-4", "search", Instant.now());
p.setStatus("approved");
p.setResolvedAt(Instant.now().minus(Duration.ofMinutes(10)));
workflow.garbageCollect();
assertThat(approvalService.getPending("pid-recent-approved")).isPresent();
assertThat(p.getStatus()).isEqualTo("approved");
verifyNoInteractions(approvalMapper);
verifyNoInteractions(conversationService);
}
@Test
@DisplayName("Idle GC tick (no entries): zero DB / metadata interactions")
void idleGcIsNoop() {
workflow.garbageCollect();
verifyNoInteractions(approvalMapper);
verifyNoInteractions(conversationService);
assertThat(approvalService.size()).isZero();
}
@Test
@DisplayName("markTimeout idempotent: pendingId already off PENDING -> alreadyResolved, no metadata change")
void markTimeoutAlreadyConsumed() {
PendingApproval p = seedPending("pid-already", "conv-5", "search", Instant.now());
p.setStatus("consumed");
ResolveOutcome outcome = workflow.markTimeout("pid-already");
assertThat(outcome.isAlreadyResolved()).isTrue();
verifyNoInteractions(approvalMapper);
verifyNoInteractions(conversationService);
}
@Test
@DisplayName("Phase A: per-row failure doesn't abort the sweep — other expired entries still process")
void perRowFailureContinuesSweep() {
// Two expired pendings; first one's DB UPDATE throws, second one succeeds.
// Pre-RFC's "all-or-nothing" loop would lose progress on the second; new GC
// catches per-row exceptions and continues.
Instant created = Instant.now().minus(Duration.ofMinutes(40));
seedPending("pid-fail", "conv-fail", "write_file", created);
seedPending("pid-ok", "conv-ok", "shell", created);
// First call throws, second returns 1.
when(approvalMapper.update(isNull(), any(Wrapper.class)))
.thenThrow(new RuntimeException("simulated outage"))
.thenReturn(1);
when(conversationService.markPendingApprovalsResolved(
eq("conv-ok"), any(), eq(MetadataDecision.DENIED))).thenReturn(1);
workflow.garbageCollect();
// pid-fail is still in memory (markTimeout's @Transactional roll-back leaves it untouched
// and the GC catch-block logs but continues).
assertThat(approvalService.getPending("pid-fail")).isPresent();
// pid-ok was successfully timed out.
assertThat(approvalService.getPending("pid-ok")).isEmpty();
verify(approvalMapper, times(2)).update(isNull(), any(Wrapper.class));
// Metadata reconciliation only fired for the successful row.
verify(conversationService, times(1)).markPendingApprovalsResolved(
eq("conv-ok"), any(), eq(MetadataDecision.DENIED));
}
// ---------- helpers ----------
private PendingApproval seedPending(String pendingId, String conversationId,
String toolName, Instant createdAt) {
PendingApproval p = new PendingApproval(
pendingId, conversationId, "system", toolName, "{}", "test",
createdAt, "pending");
approvalService.registerRecovered(p);
return p;
}
}

View File

@ -0,0 +1,243 @@
package vip.mate.approval;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.apache.ibatis.session.Configuration;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import vip.mate.approval.model.ToolApprovalEntity;
import vip.mate.approval.repository.ToolApprovalMapper;
import vip.mate.workspace.conversation.ConversationService;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
/**
* Recovery contract for ApprovalWorkflowService.recoverFromDb (RFC-067 §4.1).
* <p>
* The pre-RFC implementation generated a fresh random pendingId on recovery,
* which silently desynchronized the in-memory map from mate_tool_approval and
* left every later resolve()/updateDbStatus() call hitting zero rows. These
* tests pin the new contract:
* <ul>
* <li>Live row pendingMap entry preserves the DB pendingId AND createdAt
* (so PENDING_TTL math still works after restart)</li>
* <li>Expired row (expireAt past) DB TIMEOUT, metadata reconciled DENIED,
* no pendingMap entry</li>
* <li>Legacy row with expireAt NULL falls back to createdAt + PENDING_TTL
* this is the regression-prevention case for §4.1's effectiveExpireAt
* fallback. A naive "if expireAt != null && now > expireAt" check would
* silently revive ancient PENDING rows after every restart.</li>
* </ul>
*/
@ExtendWith(MockitoExtension.class)
class ApprovalWorkflowServiceRecoveryTest {
@Mock private ToolApprovalMapper approvalMapper;
@Mock private ConversationService conversationService;
private ApprovalService approvalService; // real, so registerRecovered is exercised
private ApprovalWorkflowService workflow;
@BeforeAll
static void initMyBatisPlusCache() {
// PR-2 resolveAndConsume builds a LambdaUpdateWrapper.set(...) which needs
// ToolApprovalEntity's TableInfo to be registered in MyBatis-Plus's static
// cache (a Spring context normally does this during mapper scan).
TableInfoHelper.initTableInfo(
new MapperBuilderAssistant(new Configuration(), ""),
ToolApprovalEntity.class);
}
private void initWorkflow(List<ToolApprovalEntity> dbRows) {
approvalService = new ApprovalService();
// Skip the GC scheduler initGc() spins up a daemon thread we don't need here.
// Tests interact with the registry via registerRecovered + getPending only.
workflow = new ApprovalWorkflowService(
approvalService,
approvalMapper,
new ObjectMapper(),
conversationService);
when(approvalMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(dbRows);
}
@Test
@DisplayName("Live PENDING row recovers with DB pendingId + createdAt preserved")
void recoversLiveRowPreservingIdAndCreatedAt() {
LocalDateTime created = LocalDateTime.now().minusMinutes(5);
ToolApprovalEntity row = newPendingRow("pid-live-1", "conv-1", created,
created.plusMinutes(30));
initWorkflow(List.of(row));
workflow.recoverFromDb();
PendingApproval recovered = approvalService.getPending("pid-live-1").orElse(null);
assertThat(recovered).isNotNull();
assertThat(recovered.getPendingId()).isEqualTo("pid-live-1");
assertThat(recovered.getConversationId()).isEqualTo("conv-1");
assertThat(recovered.getStatus()).isEqualTo("pending");
// createdAt round-trips with second precision (LocalDateTime Instant via system zone)
assertThat(recovered.getCreatedAt().getEpochSecond())
.isEqualTo(created.atZone(java.time.ZoneId.systemDefault()).toEpochSecond());
// Did not silently expire the live row.
verify(approvalMapper, never()).updateById(any(ToolApprovalEntity.class));
verifyNoInteractions(conversationService);
}
@Test
@DisplayName("Expired row with explicit past expireAt: DB -> TIMEOUT, metadata DENIED, not in map")
void expiredRowWithExplicitExpireAt() {
LocalDateTime created = LocalDateTime.now().minusMinutes(31);
ToolApprovalEntity row = newPendingRow("pid-exp-1", "conv-2", created,
created.plusMinutes(30));
initWorkflow(List.of(row));
when(approvalMapper.updateById(any(ToolApprovalEntity.class))).thenReturn(1);
workflow.recoverFromDb();
assertThat(approvalService.getPending("pid-exp-1")).isEmpty();
ArgumentCaptor<ToolApprovalEntity> updated = ArgumentCaptor.forClass(ToolApprovalEntity.class);
verify(approvalMapper).updateById(updated.capture());
assertThat(updated.getValue().getStatus()).isEqualTo("TIMEOUT");
assertThat(updated.getValue().getResolvedAt()).isNotNull();
verify(conversationService).markPendingApprovalsResolved(
eq("conv-2"), eq(Set.of("pid-exp-1")), eq(MetadataDecision.DENIED));
}
@Test
@DisplayName("Legacy row with expireAt=NULL still expires via createdAt + PENDING_TTL fallback")
void legacyRowFallsBackToCreatedAtPlusTtl() {
// Mirrors the §4.1 regression case: pre-RFC rows persisted by an older build
// never got an expireAt column populated. Without the fallback, recoverFromDb
// would resurrect them as live PENDING after every restart a permanent ghost
// approval source.
LocalDateTime created = LocalDateTime.now().minusMinutes(31);
ToolApprovalEntity row = newPendingRow("pid-legacy-1", "conv-3", created, null);
initWorkflow(List.of(row));
when(approvalMapper.updateById(any(ToolApprovalEntity.class))).thenReturn(1);
workflow.recoverFromDb();
assertThat(approvalService.getPending("pid-legacy-1")).isEmpty();
verify(approvalMapper).updateById(any(ToolApprovalEntity.class));
verify(conversationService).markPendingApprovalsResolved(
eq("conv-3"), eq(Set.of("pid-legacy-1")), eq(MetadataDecision.DENIED));
}
@Test
@DisplayName("DB updateById returning 0 rows: metadata is NOT touched (no drift)")
void expireSkipsMetadataWhenDbAffectsZeroRows() {
// Concurrent resolve case: another path already moved the row off PENDING
// between selectList and updateById. Metadata flip MUST be gated on DB
// success, otherwise message metadata = denied while DB is e.g. CONSUMED,
// and the next recoverFromDb would resurrect it exactly the drift we
// came here to fix.
LocalDateTime created = LocalDateTime.now().minusMinutes(31);
ToolApprovalEntity row = newPendingRow("pid-race-1", "conv-race", created,
created.plusMinutes(30));
initWorkflow(List.of(row));
when(approvalMapper.updateById(any(ToolApprovalEntity.class))).thenReturn(0);
workflow.recoverFromDb();
assertThat(approvalService.getPending("pid-race-1")).isEmpty();
verify(approvalMapper).updateById(any(ToolApprovalEntity.class));
verifyNoInteractions(conversationService);
}
@Test
@DisplayName("DB updateById throwing: metadata is NOT touched")
void expireSkipsMetadataWhenDbThrows() {
LocalDateTime created = LocalDateTime.now().minusMinutes(31);
ToolApprovalEntity row = newPendingRow("pid-throw-1", "conv-throw", created,
created.plusMinutes(30));
initWorkflow(List.of(row));
when(approvalMapper.updateById(any(ToolApprovalEntity.class)))
.thenThrow(new RuntimeException("simulated DB outage"));
workflow.recoverFromDb();
assertThat(approvalService.getPending("pid-throw-1")).isEmpty();
verifyNoInteractions(conversationService);
}
@Test
@DisplayName("Legacy row with expireAt=NULL but createdAt within TTL: still recovers as live")
void legacyRowWithinTtlStillRecovers() {
LocalDateTime created = LocalDateTime.now().minusMinutes(5);
ToolApprovalEntity row = newPendingRow("pid-legacy-live", "conv-4", created, null);
initWorkflow(List.of(row));
workflow.recoverFromDb();
assertThat(approvalService.getPending("pid-legacy-live")).isPresent();
verify(approvalMapper, never()).updateById(any(ToolApprovalEntity.class));
verifyNoInteractions(conversationService);
}
@Test
@DisplayName("Recovered pending carries its replay payload through resolveAndConsume")
void resolveAfterRecoveryYieldsRecoveredPayload() {
// Pre-RFC, recovery generated a fresh random id; the next resolveAndConsume
// either pulled the wrong record or the in-memory map was empty altogether.
// This pins that the original DB pendingId AND the replay payload (toolCallPayload)
// round-trip through recovery and still drive consume successfully through the
// PR-2 ResolveOutcome contract.
LocalDateTime created = LocalDateTime.now().minusMinutes(2);
ToolApprovalEntity row = newPendingRow("pid-resolve-1", "conv-5", created,
created.plusMinutes(30));
row.setToolCallPayload("{\"name\":\"write_file\"}");
initWorkflow(List.of(row));
// Stub the DB UPDATE that the new two-phase resolve runs; metadata mock is
// already injected and returns 0 by default which matches "no message rewrites".
when(approvalMapper.update(any(), any())).thenReturn(1);
workflow.recoverFromDb();
PendingApproval recovered = approvalService.getPending("pid-resolve-1").orElseThrow();
assertThat(recovered.getToolCallPayload()).isEqualTo("{\"name\":\"write_file\"}");
ResolveOutcome outcome = workflow.resolveAndConsume("pid-resolve-1", "alice");
assertThat(outcome.isConsumed()).isTrue();
assertThat(outcome.dbSynced()).isTrue();
assertThat(outcome.consumedSnapshot()).isNotNull();
assertThat(outcome.consumedSnapshot().getPendingId()).isEqualTo("pid-resolve-1");
assertThat(outcome.consumedSnapshot().getToolCallPayload()).isEqualTo("{\"name\":\"write_file\"}");
assertThat(outcome.consumedSnapshot().getStatus()).isEqualTo("consumed");
assertThat(outcome.consumedSnapshot().getResolvedBy()).isEqualTo("alice");
// pendingMap entry has been removed; a second consume is idempotent already_resolved.
ResolveOutcome second = workflow.resolveAndConsume("pid-resolve-1", "alice");
assertThat(second.isAlreadyResolved()).isTrue();
}
private ToolApprovalEntity newPendingRow(String pendingId, String conversationId,
LocalDateTime createdAt, LocalDateTime expireAt) {
ToolApprovalEntity e = new ToolApprovalEntity();
e.setPendingId(pendingId);
e.setConversationId(conversationId);
e.setUserId("u");
e.setToolName("write_file");
e.setToolArguments("{}");
e.setSummary("test");
e.setStatus("PENDING");
e.setCreatedAt(createdAt);
e.setExpireAt(expireAt);
return e;
}
}

View File

@ -0,0 +1,351 @@
package vip.mate.approval;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.apache.ibatis.session.Configuration;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import vip.mate.approval.model.ToolApprovalEntity;
import vip.mate.approval.repository.ToolApprovalMapper;
import vip.mate.workspace.conversation.ConversationService;
import java.util.List;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
/**
* Two-phase resolve contract for {@link ApprovalWorkflowService} (RFC-067 §4.2 / §4.3).
* <p>
* The pre-RFC implementation removed the in-memory entry FIRST then attempted DB
* UPDATE on a best-effort try / catch payload could be lost while DB stayed
* PENDING. These tests pin the new ordering:
* <ol>
* <li>snapshot (no map mutation)</li>
* <li>DB UPDATE conditional on {@code status='PENDING'} (idempotent against concurrent resolve)</li>
* <li>metadata reconciliation (same tx)</li>
* <li>memory mutation only on commit (afterCommit hook; immediate when no tx active)</li>
* </ol>
* <p>
* Tests run outside Spring's tx manager, so the {@code afterCommit} hook executes
* immediately that exercises the same observable end-state as a committed tx.
*/
@ExtendWith(MockitoExtension.class)
class ApprovalWorkflowServiceResolveTest {
@Mock private ToolApprovalMapper approvalMapper;
@Mock private ConversationService conversationService;
private ApprovalService approvalService;
private ApprovalWorkflowService workflow;
@BeforeAll
static void initMyBatisPlusCache() {
// LambdaUpdateWrapper.set / .eq need the entity's TableInfo to be registered in
// MyBatis-Plus's static cache. In a Spring context this happens during mapper
// scan; in a plain MockitoExtension test we trigger it manually.
TableInfoHelper.initTableInfo(
new MapperBuilderAssistant(new Configuration(), ""),
ToolApprovalEntity.class);
}
@BeforeEach
void setUp() {
approvalService = new ApprovalService();
workflow = new ApprovalWorkflowService(
approvalService, approvalMapper, new ObjectMapper(), conversationService);
}
@Test
@DisplayName("resolve(approved) updates DB, metadata, and snapshot status; entry stays in map")
void resolveApprovedHappyPath() {
PendingApproval pending = seedPending("pid-1", "conv-1", "write_file");
when(approvalMapper.update(isNull(), any(Wrapper.class))).thenReturn(1);
when(conversationService.markPendingApprovalsResolved(
eq("conv-1"), eq(Set.of("pid-1")), eq(MetadataDecision.APPROVED))).thenReturn(1);
ResolveOutcome outcome = workflow.resolve("pid-1", "alice", "approved");
assertThat(outcome.decision()).isEqualTo("approved");
assertThat(outcome.dbSynced()).isTrue();
assertThat(outcome.messagesRewritten()).isEqualTo(1);
assertThat(outcome.consumedSnapshot()).isNull();
// Memory: status flipped to "approved", entry stays in map (resolve does NOT remove)
assertThat(pending.getStatus()).isEqualTo("approved");
assertThat(pending.getResolvedBy()).isEqualTo("alice");
assertThat(approvalService.getPending("pid-1")).isPresent();
verify(approvalMapper, times(1)).update(isNull(), any(Wrapper.class));
verify(conversationService).markPendingApprovalsResolved(
"conv-1", Set.of("pid-1"), MetadataDecision.APPROVED);
}
@Test
@DisplayName("resolve(denied) flips metadata + snapshot to denied")
void resolveDeniedHappyPath() {
PendingApproval pending = seedPending("pid-2", "conv-2", "shell");
when(approvalMapper.update(isNull(), any(Wrapper.class))).thenReturn(1);
when(conversationService.markPendingApprovalsResolved(
eq("conv-2"), eq(Set.of("pid-2")), eq(MetadataDecision.DENIED))).thenReturn(1);
ResolveOutcome outcome = workflow.resolve("pid-2", "bob", "denied");
assertThat(outcome.decision()).isEqualTo("denied");
assertThat(outcome.dbSynced()).isTrue();
assertThat(pending.getStatus()).isEqualTo("denied");
verify(conversationService).markPendingApprovalsResolved(
"conv-2", Set.of("pid-2"), MetadataDecision.DENIED);
}
@Test
@DisplayName("resolve no-op when pendingId not in map: no DB / metadata interaction")
void resolveUnknownPendingId() {
ResolveOutcome outcome = workflow.resolve("ghost-id", "alice", "approved");
assertThat(outcome.isAlreadyResolved()).isTrue();
assertThat(outcome.dbSynced()).isFalse();
verifyNoInteractions(approvalMapper);
verifyNoInteractions(conversationService);
}
@Test
@DisplayName("resolve idempotent against concurrent resolve: DB rows=0 -> no metadata, no memory mutation")
void resolveIdempotentOnConcurrentResolve() {
PendingApproval pending = seedPending("pid-race", "conv-race", "write_file");
// Another path already moved the row off PENDING between snapshot and DB UPDATE.
when(approvalMapper.update(isNull(), any(Wrapper.class))).thenReturn(0);
ResolveOutcome outcome = workflow.resolve("pid-race", "alice", "approved");
assertThat(outcome.isAlreadyResolved()).isTrue();
assertThat(outcome.dbSynced()).isFalse();
assertThat(outcome.messagesRewritten()).isZero();
// Snapshot status was NOT flipped to approved memory stays consistent with DB.
assertThat(pending.getStatus()).isEqualTo("pending");
assertThat(approvalService.getPending("pid-race")).isPresent();
verifyNoInteractions(conversationService);
}
@Test
@DisplayName("resolveAndConsume happy path: DB CONSUMED, metadata APPROVED, snapshot removed from map")
void resolveAndConsumeHappyPath() {
PendingApproval pending = seedPending("pid-c-1", "conv-c", "write_file");
pending.setToolCallPayload("{\"name\":\"write_file\"}");
when(approvalMapper.update(isNull(), any(Wrapper.class))).thenReturn(1);
when(conversationService.markPendingApprovalsResolved(
eq("conv-c"), eq(Set.of("pid-c-1")), eq(MetadataDecision.APPROVED))).thenReturn(2);
ResolveOutcome outcome = workflow.resolveAndConsume("pid-c-1", "carol");
assertThat(outcome.isConsumed()).isTrue();
assertThat(outcome.dbSynced()).isTrue();
assertThat(outcome.messagesRewritten()).isEqualTo(2);
assertThat(outcome.consumedSnapshot()).isNotNull();
assertThat(outcome.consumedSnapshot().getToolCallPayload()).isEqualTo("{\"name\":\"write_file\"}");
// Memory: status flipped to consumed, entry REMOVED (single-shot consume).
assertThat(pending.getStatus()).isEqualTo("consumed");
assertThat(approvalService.getPending("pid-c-1")).isEmpty();
// Second consume returns idempotent already_resolved (entry is gone).
ResolveOutcome second = workflow.resolveAndConsume("pid-c-1", "carol");
assertThat(second.isAlreadyResolved()).isTrue();
}
@Test
@DisplayName("resolveAndConsume DB rows=0: no metadata, snapshot stays in map")
void resolveAndConsumeRaceLeavesMapAlone() {
PendingApproval pending = seedPending("pid-c-race", "conv-cr", "write_file");
pending.setToolCallPayload("{}");
when(approvalMapper.update(isNull(), any(Wrapper.class))).thenReturn(0);
ResolveOutcome outcome = workflow.resolveAndConsume("pid-c-race", "alice");
assertThat(outcome.isAlreadyResolved()).isTrue();
// Critical: payload is NOT lost. Replay can still find the entry next loop.
assertThat(approvalService.getPending("pid-c-race")).isPresent();
assertThat(pending.getStatus()).isEqualTo("pending");
verifyNoInteractions(conversationService);
}
@Test
@DisplayName("consumeApproved redeems the earliest approved record; missing match -> alreadyResolved")
void consumeApprovedHappyAndMiss() {
PendingApproval pending = seedPending("pid-app-1", "conv-app", "search");
// Caller previously approved but did not consume common in /approve text flow.
pending.setStatus("approved");
when(approvalMapper.update(isNull(), any(Wrapper.class))).thenReturn(1);
when(conversationService.markPendingApprovalsResolved(
eq("conv-app"), eq(Set.of("pid-app-1")), eq(MetadataDecision.APPROVED))).thenReturn(1);
ResolveOutcome consumed = workflow.consumeApproved("conv-app", "search");
assertThat(consumed.isConsumed()).isTrue();
assertThat(consumed.consumedSnapshot()).isNotNull();
assertThat(approvalService.getPending("pid-app-1")).isEmpty();
// Second call: nothing approved left no additional DB / metadata interaction.
org.mockito.Mockito.clearInvocations(approvalMapper, conversationService);
ResolveOutcome miss = workflow.consumeApproved("conv-app", "search");
assertThat(miss.isAlreadyResolved()).isTrue();
verifyNoInteractions(approvalMapper);
verifyNoInteractions(conversationService);
}
@Test
@DisplayName("cancelStalePending issues a SUPERSEDED outcome per pending in the conversation")
void cancelStalePendingMultipleEntries() {
PendingApproval a = seedPending("pid-stale-A", "conv-stale", "write_file");
PendingApproval b = seedPending("pid-stale-B", "conv-stale", "shell");
PendingApproval keep = seedPending("pid-keep", "conv-stale", "memory_recall");
when(approvalMapper.update(isNull(), any(Wrapper.class))).thenReturn(1);
when(conversationService.markPendingApprovalsResolved(
eq("conv-stale"), any(), eq(MetadataDecision.DENIED))).thenReturn(1);
List<ResolveOutcome> outcomes = workflow.cancelStalePending("conv-stale", "pid-keep");
assertThat(outcomes).hasSize(2);
assertThat(outcomes).extracting(ResolveOutcome::pendingId)
.containsExactlyInAnyOrder("pid-stale-A", "pid-stale-B");
assertThat(outcomes).allMatch(o -> "superseded".equals(o.decision()));
// Excluded entry untouched.
assertThat(approvalService.getPending("pid-keep")).isPresent();
assertThat(keep.getStatus()).isEqualTo("pending");
// Cancelled entries removed from map.
assertThat(approvalService.getPending("pid-stale-A")).isEmpty();
assertThat(approvalService.getPending("pid-stale-B")).isEmpty();
assertThat(a.getStatus()).isEqualTo("superseded");
assertThat(b.getStatus()).isEqualTo("superseded");
// Two DB updates fired (one per cancellation).
verify(approvalMapper, times(2)).update(isNull(), any(Wrapper.class));
}
@Test
@DisplayName("denyAllByConversation: every pending becomes denied; metadata reconciled per row")
void denyAllConversationSweep() {
// Stop endpoint scenario: user halts a turn while two pendings sit in the map.
PendingApproval a = seedPending("pid-stop-A", "conv-stop", "write_file");
PendingApproval b = seedPending("pid-stop-B", "conv-stop", "shell");
seedPending("pid-other-conv", "conv-other", "search"); // not in target conversation
when(approvalMapper.update(isNull(), any(Wrapper.class))).thenReturn(1);
when(conversationService.markPendingApprovalsResolved(
eq("conv-stop"), any(), eq(MetadataDecision.DENIED))).thenReturn(1);
List<ResolveOutcome> outcomes = workflow.denyAllByConversation("conv-stop", "alice");
assertThat(outcomes).hasSize(2);
assertThat(outcomes).extracting(ResolveOutcome::pendingId)
.containsExactlyInAnyOrder("pid-stop-A", "pid-stop-B");
assertThat(outcomes).allMatch(o -> "denied".equals(o.decision()));
assertThat(a.getStatus()).isEqualTo("denied");
assertThat(b.getStatus()).isEqualTo("denied");
// Targets removed from map.
assertThat(approvalService.getPending("pid-stop-A")).isEmpty();
assertThat(approvalService.getPending("pid-stop-B")).isEmpty();
// Other conversation untouched.
assertThat(approvalService.getPending("pid-other-conv")).isPresent();
// Two metadata reconciliations fired (one per pending).
verify(conversationService, times(2)).markPendingApprovalsResolved(
eq("conv-stop"), any(), eq(MetadataDecision.DENIED));
}
@Test
@DisplayName("denyAllByConversation: empty conversation -> empty outcomes, no DB / metadata interaction")
void denyAllNoPendingsIsNoop() {
seedPending("pid-other", "conv-other", "search");
List<ResolveOutcome> outcomes = workflow.denyAllByConversation("conv-empty", "alice");
assertThat(outcomes).isEmpty();
verifyNoInteractions(approvalMapper);
verifyNoInteractions(conversationService);
}
@Test
@DisplayName("denyAllByConversation: per-row failure doesn't abort the sweep")
void denyAllPerRowFailureContinues() {
seedPending("pid-fail", "conv-mix", "write_file");
seedPending("pid-ok", "conv-mix", "shell");
// First UPDATE throws, second succeeds.
when(approvalMapper.update(isNull(), any(Wrapper.class)))
.thenThrow(new RuntimeException("simulated outage"))
.thenReturn(1);
when(conversationService.markPendingApprovalsResolved(
eq("conv-mix"), any(), eq(MetadataDecision.DENIED))).thenReturn(1);
List<ResolveOutcome> outcomes = workflow.denyAllByConversation("conv-mix", "alice");
// Only the successful row makes it into the outcomes list.
assertThat(outcomes).hasSize(1);
assertThat(outcomes.get(0).pendingId()).isEqualTo("pid-ok");
// Failed row is still in memory (transactional rollback would leave it untouched).
assertThat(approvalService.getPending("pid-fail")).isPresent();
assertThat(approvalService.getPending("pid-ok")).isEmpty();
}
@Test
@DisplayName("DB UPDATE throwing propagates so @Transactional can roll back; memory untouched")
void dbThrowsPropagatesForRollback() {
PendingApproval pending = seedPending("pid-throw", "conv-throw", "write_file");
when(approvalMapper.update(isNull(), any(Wrapper.class)))
.thenThrow(new RuntimeException("simulated outage"));
try {
workflow.resolve("pid-throw", "alice", "approved");
org.junit.jupiter.api.Assertions.fail("expected RuntimeException");
} catch (RuntimeException expected) {
assertThat(expected.getMessage()).contains("simulated outage");
}
// Memory snapshot must not have flipped.
assertThat(pending.getStatus()).isEqualTo("pending");
verifyNoInteractions(conversationService);
// approvalService is a real instance in these tests, not a Mockito mock
// its untouched state is asserted via the snapshot status above.
}
@Test
@DisplayName("ResolveOutcome carries conversationId + toolName for SSE broadcast use")
void outcomeShape() {
PendingApproval pending = seedPending("pid-shape", "conv-shape", "search_web");
when(approvalMapper.update(isNull(), any(Wrapper.class))).thenReturn(1);
when(conversationService.markPendingApprovalsResolved(
eq("conv-shape"), eq(Set.of("pid-shape")), eq(MetadataDecision.DENIED))).thenReturn(0);
ResolveOutcome outcome = workflow.resolve("pid-shape", "alice", "denied");
assertThat(outcome.pendingId()).isEqualTo("pid-shape");
assertThat(outcome.conversationId()).isEqualTo("conv-shape");
assertThat(outcome.toolName()).isEqualTo("search_web");
assertThat(outcome.messagesRewritten()).isZero();
}
// ---------- helpers ----------
private PendingApproval seedPending(String pendingId, String conversationId, String toolName) {
// Use the public createPending overload, then re-key the map under the
// requested pendingId so the test asserts work against a stable id.
// The recovery constructor is package-visible from this same package.
PendingApproval p = new PendingApproval(
pendingId, conversationId, "system", toolName, "{}", "test",
java.time.Instant.now(), "pending");
approvalService.registerRecovered(p);
return p;
}
}

View File

@ -0,0 +1,89 @@
package vip.mate.architecture;
import org.junit.jupiter.api.Test;
import vip.mate.agent.graph.state.MateClawStateKeys;
import java.lang.reflect.Modifier;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Set;
import java.util.TreeSet;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.fail;
/**
* Architecture guard every state key declared on
* {@link MateClawStateKeys} that participates in graph state (i.e. is not a
* node-name constant) MUST be registered in
* {@link vip.mate.agent.AgentGraphBuilder}'s {@code KeyStrategyFactory}
* for at least one of the two graphs (ReAct + Plan-Execute).
*
* <p>Regression rationale: the post-deploy bug where {@code CHAT_ORIGIN} was
* declared on {@link MateClawStateKeys} but missing from both
* {@code KeyStrategyFactory} blocks shipped silently, and {@code spring-ai-alibaba-graph}
* dropped the key on multi-node merges, causing the channel-binding flakiness
* reported by the user. This test parses the source of
* {@code AgentGraphBuilder.java} for all
* {@code .addStrategy(MateClawStateKeys.X, ...)} mentions and asserts the
* coverage so the same kind of "forgot to register" can never ship again.
*
* <p>Excluded by suffix: any constant whose name ends with {@code _NODE}
* those are graph-node identifiers used by {@code addNode(...)}, not state
* keys.
*/
class StateKeyRegistrationCoverageTest {
private static final Pattern ADD_STRATEGY = Pattern.compile(
"\\.addStrategy\\(\\s*MateClawStateKeys\\.([A-Z_]+)");
@Test
void everyStateKeyMustBeRegisteredInKeyStrategyFactory() throws Exception {
// Read the AgentGraphBuilder source relative to mateclaw-server module root.
Path source = Paths.get("src/main/java/vip/mate/agent/AgentGraphBuilder.java")
.toAbsolutePath();
if (!Files.exists(source)) {
fail("Cannot find AgentGraphBuilder.java at " + source
+ " — has the file moved? Update this test's path.");
}
String content = Files.readString(source);
Set<String> registered = new TreeSet<>();
Matcher m = ADD_STRATEGY.matcher(content);
while (m.find()) {
registered.add(m.group(1));
}
assertTrue(registered.size() > 10,
"Suspiciously few addStrategy hits — regex broken? Found: " + registered);
Set<String> declared = new TreeSet<>();
for (var f : MateClawStateKeys.class.getDeclaredFields()) {
int mods = f.getModifiers();
if (!Modifier.isPublic(mods) || !Modifier.isStatic(mods)
|| !Modifier.isFinal(mods) || f.getType() != String.class) {
continue;
}
// Node-name constants are NOT state keys they're graph-node
// identifiers used by addNode(...). Exclude them by suffix.
if (f.getName().endsWith("_NODE")) continue;
declared.add(f.getName());
}
Set<String> missing = new TreeSet<>(declared);
missing.removeAll(registered);
if (!missing.isEmpty()) {
fail("State keys declared on MateClawStateKeys but NOT registered in any "
+ "KeyStrategyFactory in AgentGraphBuilder.java:\n"
+ " " + missing + "\n\n"
+ "Without registration, spring-ai-alibaba-graph may drop these keys on "
+ "multi-node state merges (silently, intermittently). Add an "
+ ".addStrategy(MateClawStateKeys.X, KeyStrategy.REPLACE) line for each "
+ "missing key in BOTH the ReAct and Plan-Execute KeyStrategyFactory blocks "
+ "(or document why the key is intentionally Plan-only / ReAct-only).");
}
}
}

View File

@ -0,0 +1,119 @@
package vip.mate.architecture;
import com.tngtech.archunit.core.domain.JavaClass;
import com.tngtech.archunit.core.domain.JavaClasses;
import com.tngtech.archunit.core.domain.JavaMethod;
import com.tngtech.archunit.core.importer.ClassFileImporter;
import com.tngtech.archunit.core.importer.ImportOption;
import com.tngtech.archunit.lang.ArchCondition;
import com.tngtech.archunit.lang.ConditionEvents;
import com.tngtech.archunit.lang.SimpleConditionEvent;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.model.ToolContext;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.transaction.annotation.Transactional;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
/**
* RFC-063r §2.3: every concrete {@link ToolCallback} implementation must
* override {@code call(String, ToolContext)} so it cannot silently drop the
* Spring AI {@link ToolContext} (which carries the {@code ChatOrigin}).
*
* <p>Background: the previous {@code LocaleAwareToolCallback} only overrode
* {@code call(String)}; the framework default routed
* {@code call(String, ToolContext)} back to {@code call(String)}, dropping the
* context. This test pins the rule so a future regression fails CI.
*/
class ToolCallbackToolContextForwardArchTest {
private static final JavaClasses MATECLAW_CLASSES = new ClassFileImporter()
.withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS)
.importPackages("vip.mate");
@Test
void everyToolCallbackImplementationMustOverrideCallWithToolContext() {
classes()
.that().implement(ToolCallback.class)
.and().areNotInterfaces()
.and().areNotAnnotations()
.and(haveSimpleNameNot("ToolCallback"))
.should(overrideCallWithToolContext())
.check(MATECLAW_CLASSES);
}
/**
* RFC-063r §5.2 hard rule: {@code CronJobRunner} must NEVER carry
* {@code @Transactional} (class-level or method-level). The class is
* the entry point for cron-tick execution; an inline transaction would
* either swallow self-invocation calls or worse hold a DB connection
* across the multi-minute LLM call inside {@code runAgent}, exhausting
* the HikariCP pool under concurrent cron load.
*
* <p>The three transactional segments live on
* {@code CronJobLifecycleService}; cross-bean invocation routes through
* the Spring AOP proxy and works as designed. This test pins the rule.
*/
@Test
void cronJobRunnerMustNotCarryTransactional() {
noClasses()
.that().haveSimpleName("CronJobRunner")
.and().resideInAPackage("vip.mate.cron..")
.should(beAnnotatedOrHaveAnyMethodAnnotatedWith(Transactional.class))
.because("RFC-063r §5.2: CronJobRunner.runAgent runs an LLM HTTP call (seconds-to-minutes); " +
"@Transactional would hold a DB connection during that call and exhaust HikariCP under " +
"concurrent cron load. Transactions must live on CronJobLifecycleService instead.")
.check(MATECLAW_CLASSES);
}
private static com.tngtech.archunit.base.DescribedPredicate<JavaClass> haveSimpleNameNot(String simpleName) {
return new com.tngtech.archunit.base.DescribedPredicate<>("simple name is not " + simpleName) {
@Override
public boolean test(JavaClass javaClass) {
return !javaClass.getSimpleName().equals(simpleName);
}
};
}
private static ArchCondition<JavaClass> beAnnotatedOrHaveAnyMethodAnnotatedWith(
Class<? extends java.lang.annotation.Annotation> annotation) {
String desc = annotation.getName();
return new ArchCondition<>("be annotated or have any method annotated with " + desc) {
@Override
public void check(JavaClass clazz, ConditionEvents events) {
if (clazz.isAnnotatedWith(annotation)) {
events.add(SimpleConditionEvent.satisfied(clazz,
clazz.getFullName() + " is annotated with " + desc));
return;
}
for (JavaMethod m : clazz.getMethods()) {
if (m.isAnnotatedWith(annotation)) {
events.add(SimpleConditionEvent.satisfied(clazz,
clazz.getFullName() + "#" + m.getName() + " is annotated with " + desc));
return;
}
}
}
};
}
private static ArchCondition<JavaClass> overrideCallWithToolContext() {
return new ArchCondition<>("override call(String, ToolContext)") {
@Override
public void check(JavaClass clazz, ConditionEvents events) {
boolean overrides = clazz.getMethods().stream().anyMatch(m ->
m.getName().equals("call")
&& m.getRawParameterTypes().size() == 2
&& m.getRawParameterTypes().get(0).getFullName().equals(String.class.getName())
&& m.getRawParameterTypes().get(1).getFullName().equals(ToolContext.class.getName()));
if (!overrides) {
events.add(SimpleConditionEvent.violated(clazz,
clazz.getFullName() + " does not override call(String, ToolContext); "
+ "the framework default would silently drop the ChatOrigin "
+ "(see RFC-063r §2.3)."));
}
}
};
}
}

View File

@ -0,0 +1,309 @@
package vip.mate.auth.pat;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import vip.mate.auth.pat.repository.PersonalAccessTokenMapper;
import vip.mate.exception.MateClawException;
import java.time.LocalDateTime;
import java.util.HashSet;
import java.util.Optional;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* RFC-03 Lane I1 covers {@link PersonalAccessTokenService} core contracts:
*
* <ul>
* <li>Plaintext format ({@code mc_*}) and uniqueness across mints.</li>
* <li>SHA-256 hashing is deterministic and matches a known vector a
* silent change to the hash function would invalidate every existing
* row in production, so this is enforced in test.</li>
* <li>{@link PersonalAccessTokenService#findActiveByPlaintext} rejects
* null, blank, wrong-prefix, hash-miss, disabled, and expired
* tokens with no observable difference (don't leak which one).</li>
* <li>{@link PersonalAccessTokenService#recordUse} debounces writes so
* a CI loop doesn't hammer the row.</li>
* <li>{@link PersonalAccessTokenService#revoke} requires owner match
* a token id alone is insufficient to revoke someone else's token.</li>
* </ul>
*/
@ExtendWith(MockitoExtension.class)
class PersonalAccessTokenServiceTest {
@Mock
private PersonalAccessTokenMapper mapper;
@InjectMocks
private PersonalAccessTokenService service;
private PersonalAccessTokenEntity entity;
@BeforeEach
void setUp() {
entity = new PersonalAccessTokenEntity();
entity.setId(42L);
entity.setUserId(7L);
entity.setName("ci-key");
entity.setEnabled(true);
}
// Plaintext format
@Test
@DisplayName("generated plaintext starts with mc_ and is sufficiently long for 256-bit entropy")
void plaintextFormat() {
String tok = service.generatePlaintext();
assertTrue(tok.startsWith("mc_"), "PAT must start with the observable mc_ prefix");
// 32 bytes base64 url-encoded without padding = 43 chars; total = 3 + 43 = 46.
assertEquals(46, tok.length(),
"32 bytes of entropy → 43 base64 chars + 3-char prefix; got " + tok);
}
@Test
@DisplayName("each generation yields a unique plaintext (entropy actually random)")
void plaintextUniqueness() {
Set<String> seen = new HashSet<>();
for (int i = 0; i < 100; i++) {
assertTrue(seen.add(service.generatePlaintext()),
"duplicate within 100 mints — RNG is not actually random");
}
}
// SHA-256 hashing
@Test
@DisplayName("sha256Hex matches the canonical reference vector for 'abc'")
void sha256ReferenceVector() {
// From FIPS 180-4 locking in the algorithm; if this assertion ever
// fires, every PAT in the database is invalidated by the same change.
assertEquals(
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
PersonalAccessTokenService.sha256Hex("abc"));
}
@Test
@DisplayName("sha256Hex output is always 64 lowercase hex chars")
void sha256OutputShape() {
String h = PersonalAccessTokenService.sha256Hex("any plaintext");
assertEquals(64, h.length());
assertTrue(h.matches("[0-9a-f]+"));
}
// findActiveByPlaintext rejection paths
@Test
@DisplayName("null / blank input returns empty without DB roundtrip")
void nullBlankReturnsEmpty() {
assertTrue(service.findActiveByPlaintext(null).isEmpty());
assertTrue(service.findActiveByPlaintext("").isEmpty());
assertTrue(service.findActiveByPlaintext(" ").isEmpty());
verify(mapper, never()).selectOne(any());
}
@Test
@DisplayName("token without mc_ prefix returns empty without DB roundtrip")
void wrongPrefixReturnsEmpty() {
// JWT-shaped value should not even hit the DB keeps the auth filter
// dispatch cheap when callers send either token type by mistake.
assertTrue(service.findActiveByPlaintext("eyJhbGciOiJIUzI1NiJ9...").isEmpty());
verify(mapper, never()).selectOne(any());
}
@Test
@DisplayName("hash miss returns empty")
void hashMissReturnsEmpty() {
when(mapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(null);
assertTrue(service.findActiveByPlaintext("mc_unknown_token").isEmpty());
}
@Test
@DisplayName("expired token returns empty even when the row matches")
void expiredTokenReturnsEmpty() {
entity.setExpiresAt(LocalDateTime.now().minusMinutes(1));
when(mapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(entity);
assertTrue(service.findActiveByPlaintext("mc_some_plaintext").isEmpty(),
"expired tokens must reject — past-expiry is the same as no-such-token from auth's PoV");
}
@Test
@DisplayName("active, unexpired token returns the entity")
void activeTokenReturned() {
entity.setExpiresAt(LocalDateTime.now().plusDays(7));
when(mapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(entity);
Optional<PersonalAccessTokenEntity> result = service.findActiveByPlaintext("mc_valid_plaintext");
assertTrue(result.isPresent());
assertEquals(42L, result.get().getId());
}
// recordUse debounce predicate (pure logic)
@Test
@DisplayName("shouldRecordUse — null lastUsedAt returns true (first write always proceeds)")
void shouldRecordUseFirstCall() {
assertTrue(PersonalAccessTokenService.shouldRecordUse(null, LocalDateTime.now()));
}
@Test
@DisplayName("shouldRecordUse — within 60s of last write returns false (debounced)")
void shouldRecordUseDebounced() {
LocalDateTime now = LocalDateTime.now();
// 30s ago well within the 60s window.
assertFalse(PersonalAccessTokenService.shouldRecordUse(now.minusSeconds(30), now));
}
@Test
@DisplayName("shouldRecordUse — after 60s window returns true (writes again)")
void shouldRecordUseAfterWindow() {
LocalDateTime now = LocalDateTime.now();
// 2 min ago beyond the 60s debounce.
assertTrue(PersonalAccessTokenService.shouldRecordUse(now.minusMinutes(2), now));
}
@Test
@DisplayName("shouldRecordUse — exactly at 60s boundary returns true")
void shouldRecordUseAtBoundary() {
LocalDateTime now = LocalDateTime.now();
// 61s ago just past the boundary.
assertTrue(PersonalAccessTokenService.shouldRecordUse(now.minusSeconds(61), now));
}
@Test
@DisplayName("recordUse — first write hits the mapper")
void recordUseFirstCallWrites() {
service.recordUse(entity);
verify(mapper, times(1)).updateById(any(PersonalAccessTokenEntity.class));
}
@Test
@DisplayName("recordUse — second call within debounce skips the mapper")
void recordUseDebouncedSkipsMapper() {
entity.setLastUsedAt(LocalDateTime.now());
service.recordUse(entity);
verify(mapper, never()).updateById(any(PersonalAccessTokenEntity.class));
}
@Test
@DisplayName("recordUse swallows DB errors — never fails an authenticated request")
void recordUseSwallowsErrors() {
when(mapper.updateById(any(PersonalAccessTokenEntity.class)))
.thenThrow(new RuntimeException("simulated DB outage"));
// Must not throw last-used is observability, not a correctness gate.
service.recordUse(entity);
}
// revoke ownership
@Test
@DisplayName("revoke with matching owner soft-deletes")
void revokeOwnedToken() {
when(mapper.selectById(42L)).thenReturn(entity);
when(mapper.updateById(any(PersonalAccessTokenEntity.class))).thenReturn(1);
service.revoke(42L, 7L);
verify(mapper, times(1)).updateById(any(PersonalAccessTokenEntity.class));
}
@Test
@DisplayName("revoke with wrong owner throws not-found — no info leak about token ownership")
void revokeWrongOwner() {
// Token exists but belongs to user 7, not 999.
when(mapper.selectById(42L)).thenReturn(entity);
var ex = assertThrows(MateClawException.class,
() -> service.revoke(42L, 999L));
assertTrue(ex.getMessage().contains("not found") || ex.getMessage().contains("not owned"),
"error message must indicate not-found, not 'unauthorized' — to avoid leaking which token ids exist");
// Critically: must NOT have called updateById owner check happens before any write.
verify(mapper, never()).updateById(any(PersonalAccessTokenEntity.class));
}
@Test
@DisplayName("revoke of missing token throws not-found")
void revokeMissingToken() {
when(mapper.selectById(99L)).thenReturn(null);
assertThrows(MateClawException.class,
() -> service.revoke(99L, 7L));
verify(mapper, never()).updateById(any(PersonalAccessTokenEntity.class));
}
@Test
@DisplayName("revoke of already-deleted token throws not-found (no double-delete confusion)")
void revokeAlreadyDeletedToken() {
entity.setDeleted(1);
when(mapper.selectById(42L)).thenReturn(entity);
assertThrows(MateClawException.class,
() -> service.revoke(42L, 7L));
verify(mapper, never()).updateById(any(PersonalAccessTokenEntity.class));
}
@Test
@DisplayName("create requires non-null userId")
void createRequiresUserId() {
assertThrows(MateClawException.class,
() -> service.create(null, "name", null, null));
}
@Test
@DisplayName("tokenHash never leaks via Jackson serialization (privacy regression guard)")
void tokenHashDoesNotLeakInJson() throws Exception {
// The list endpoint returns PersonalAccessTokenEntity directly to
// the client. Jackson must skip tokenHash even when other fields
// serialize normally otherwise admin UI / log middleware leaks
// the per-token digest. Smoke test on 2026-05-02 caught this.
PersonalAccessTokenEntity e = new PersonalAccessTokenEntity();
e.setId(123L);
e.setUserId(7L);
e.setName("ci-key");
e.setTokenHash("8020f458548f7b433f872da4d6828933e4f3ba421823f3e7010c9ffd3c505f20");
e.setScopes("*");
e.setEnabled(true);
String json = new ObjectMapper().writeValueAsString(e);
assertFalse(json.contains("tokenHash"),
"tokenHash field name leaked to JSON: " + json);
assertFalse(json.contains("8020f458"),
"tokenHash value leaked to JSON: " + json);
// Sanity: other fields still serialize so we didn't accidentally
// @JsonIgnore the wrong field.
assertTrue(json.contains("\"name\":\"ci-key\""));
assertTrue(json.contains("\"id\":123"));
}
@Test
@DisplayName("create returns plaintext exactly once and inserts the row")
void createReturnsPlaintext() {
when(mapper.insert(any(PersonalAccessTokenEntity.class))).thenReturn(1);
PersonalAccessTokenService.CreatedToken result = service.create(
7L, "ci-key", "*", LocalDateTime.now().plusDays(30));
assertNotNull(result);
assertNotNull(result.plaintext());
assertTrue(result.plaintext().startsWith("mc_"));
assertNotNull(result.entity());
// The row inserted into DB must NOT carry plaintext only the hash.
assertFalse(result.plaintext().equals(result.entity().getTokenHash()),
"DB must store the hash, not the plaintext");
}
}

View File

@ -0,0 +1,58 @@
package vip.mate.channel;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Pin the error-detection heuristic so a future tweak in
* {@code NodeStreamingChatHelper} that renames an error template doesn't
* silently regress IM channels back into the self-replicating 400 loop.
*/
class ChannelErrorClassifierTest {
private final ChannelErrorClassifier classifier = new ChannelErrorClassifier();
@Test
void normal_reply_is_not_error() {
assertFalse(classifier.isErrorReply("好的,我已经为您完成了任务。"));
assertFalse(classifier.isErrorReply(""));
assertFalse(classifier.isErrorReply(null));
assertFalse(classifier.isErrorReply("⏰ 定时任务已就绪:每天 00:18"));
}
@Test
void error_prefix_is_detected() {
assertTrue(classifier.isErrorReply("[错误] Bad request: Bad request, please check input"));
assertTrue(classifier.isErrorReply("[错误] 工具调用失败"));
}
@Test
void error_substrings_emitted_by_NodeStreamingChatHelper_are_detected() {
// Mirrors templates in NodeStreamingChatHelper.buildErrorResultWithType
assertTrue(classifier.isErrorReply("Bad request: invalid_request_error"));
assertTrue(classifier.isErrorReply("LLM 调用失败: connection reset"));
assertTrue(classifier.isErrorReply("LLM 调用超时"));
assertTrue(classifier.isErrorReply("LLM 调用被中断"));
assertTrue(classifier.isErrorReply("Prompt 过长: token limit exceeded"));
assertTrue(classifier.isErrorReply("认证失败: 401 Unauthorized"));
assertTrue(classifier.isErrorReply("LLM 返回空响应"));
}
@Test
void status_for_maps_correctly() {
assertEquals("error", classifier.statusFor("[错误] Bad request"));
assertEquals("completed", classifier.statusFor("Hello world"));
assertEquals("completed", classifier.statusFor(""));
}
@Test
void aicard_partial_with_error_prefix_is_detected() {
// The DingTalk AICard catch path now wraps partial output with a
// [错误] prefix; verify the classifier catches that compound shape.
String reply = "[错误] AI Card streaming failed: timeout\n\n已生成的部分内容已忽略\n部分回答 ...";
assertTrue(classifier.isErrorReply(reply));
}
}

View File

@ -0,0 +1,440 @@
package vip.mate.channel;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import vip.mate.channel.leader.ChannelLeaderElection;
import vip.mate.channel.leader.LeaderLease;
import vip.mate.channel.model.ChannelEntity;
import vip.mate.channel.service.ChannelService;
import vip.mate.exception.MateClawException;
import vip.mate.workspace.conversation.model.MessageContentPart;
import java.lang.reflect.Field;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.atomic.AtomicBoolean;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
/**
* Behavioural tests for the multi-instance reconciliation paths:
* heartbeat-driven detection of disabled / deleted / config-changed
* channels, and follower-retry cancellation on channel deletion.
*
* <p>These exercise the fixes that prevent a leader node from running
* stale config (or a deleted channel) just because the admin API call
* happened to land on a different node.
*/
class ChannelManagerReconcileTest {
private ChannelService channelService;
private ChannelLeaderElection election;
private ChannelManager manager;
private TrackingAdapter adapter;
@BeforeEach
@SuppressWarnings("unchecked")
void setUp() {
channelService = mock(ChannelService.class);
election = mock(ChannelLeaderElection.class);
manager = new ChannelManager(
channelService,
mock(ChannelMessageRouter.class),
mock(ChannelSessionStore.class),
new ObjectMapper(),
mock(vip.mate.tool.document.GeneratedFileCache.class),
mock(vip.mate.channel.notification.ApprovalNotificationService.class),
mock(vip.mate.channel.wecom.cards.WeComCardDispatcher.class),
mock(vip.mate.channel.wecom.WeComKeepaliveScheduler.class),
election);
adapter = new TrackingAdapter();
}
@AfterEach
void tearDown() {
// Shut down the leaderScheduler so test threads don't leak.
manager.destroy();
}
@Test
@DisplayName("heartbeat reconcile: disabled channel triggers local stop")
void reconcileStopsOnDisabled() {
LeaderLease lease = mock(LeaderLease.class);
seedLeaderState(42L, adapter, lease, LocalDateTime.of(2026, 1, 1, 0, 0));
ChannelEntity disabled = entity(42L, "feishu");
disabled.setEnabled(false);
disabled.setUpdateTime(LocalDateTime.of(2026, 1, 1, 0, 0));
when(channelService.getChannel(42L)).thenReturn(disabled);
manager.reconcileChannel(42L, "test-channel");
assertFalse(manager.getAdapter(42L).isPresent(),
"Disabled channel detected via reconciliation must stop local adapter");
assertTrue(adapter.stopped.get(), "Adapter stop() must be invoked");
verify(lease, times(1)).release();
}
@Test
@DisplayName("heartbeat reconcile: not-found exception triggers local stop and lease release")
void reconcileStopsOnNotFound() {
LeaderLease lease = mock(LeaderLease.class);
seedLeaderState(43L, adapter, lease, LocalDateTime.of(2026, 1, 1, 0, 0));
when(channelService.getChannel(43L))
.thenThrow(new MateClawException("err.channel.not_found", "渠道不存在: 43"));
manager.reconcileChannel(43L, "test-channel");
assertFalse(manager.getAdapter(43L).isPresent(),
"Deleted channel detected via reconciliation must stop local adapter");
assertTrue(adapter.stopped.get());
verify(lease, times(1)).release();
}
@Test
@DisplayName("heartbeat reconcile: transient DB error keeps adapter running (no false-positive stop)")
void reconcileKeepsRunningOnTransientFailure() {
LeaderLease lease = mock(LeaderLease.class);
seedLeaderState(44L, adapter, lease, LocalDateTime.of(2026, 1, 1, 0, 0));
when(channelService.getChannel(44L)).thenThrow(new RuntimeException("connection refused"));
manager.reconcileChannel(44L, "test-channel");
assertTrue(manager.getAdapter(44L).isPresent(),
"Transient lookup errors must not stop the local adapter");
assertFalse(adapter.stopped.get());
verify(lease, never()).release();
}
@Test
@DisplayName("config change to non-leader-required mode releases the lease (e.g. Feishu WS → webhook)")
void modeFlipOutOfLeaderRequiredReleasesLease() {
LeaderLease lease = mock(LeaderLease.class);
seedLeaderState(50L, adapter, lease, LocalDateTime.of(2026, 1, 1, 0, 0));
// Stub createAdapter so that the swap doesn't need a real
// network-backed Feishu/Telegram start(). The fresh adapter
// reports requiresSingleLeader=false, simulating a mode flip.
TrackingAdapter newAdapter = new TrackingAdapter() {
@Override public boolean requiresSingleLeader() { return false; }
};
ChannelManager spied = spy(manager);
doReturn(newAdapter).when(spied).createAdapter(any(ChannelEntity.class));
ChannelEntity updated = entity(50L, "feishu");
updated.setUpdateTime(LocalDateTime.of(2026, 1, 1, 0, 5));
spied.applyConfigChange(50L, updated);
verify(lease, times(1)).release();
// The new (non-leader) adapter is started locally on this node.
assertTrue(spied.getAdapter(50L).isPresent(),
"After mode flip, the local node continues running the channel as a non-leader");
assertTrue(newAdapter.started.get(), "New adapter must be started after flip");
assertTrue(adapter.stopped.get(), "Old adapter must be stopped before swap");
}
@Test
@DisplayName("config change within leader-required mode preserves the lease (in-place swap)")
void inPlaceSwapPreservesLease() {
LeaderLease lease = mock(LeaderLease.class);
seedLeaderState(51L, adapter, lease, LocalDateTime.of(2026, 1, 1, 0, 0));
TrackingAdapter newAdapter = new TrackingAdapter(); // requiresSingleLeader=true
ChannelManager spied = spy(manager);
doReturn(newAdapter).when(spied).createAdapter(any(ChannelEntity.class));
ChannelEntity updated = entity(51L, "feishu");
updated.setUpdateTime(LocalDateTime.of(2026, 1, 1, 0, 5));
spied.applyConfigChange(51L, updated);
verify(lease, never()).release();
assertTrue(spied.getAdapter(51L).isPresent());
assertTrue(newAdapter.started.get());
assertTrue(adapter.stopped.get());
}
@Test
@DisplayName("follower retry: not-found cancels the scheduled retry (no leak)")
void followerRetryCancelsOnNotFound() {
// Seed a follower retry future so we can verify cancellation.
ScheduledFuture<?> future = mock(ScheduledFuture.class);
@SuppressWarnings("unchecked")
Map<Long, ScheduledFuture<?>> followerRetries =
(Map<Long, ScheduledFuture<?>>) ReflectionTestUtils.getField(manager, "followerRetryFutures");
assertNotNull(followerRetries);
followerRetries.put(99L, future);
when(channelService.getChannel(99L))
.thenThrow(new MateClawException("err.channel.not_found", "渠道不存在: 99"));
manager.followerRetry(99L);
assertFalse(manager.hasFollowerRetry(99L),
"Deleted channel must cancel the follower retry future");
verify(future, times(1)).cancel(false);
}
@Test
@DisplayName("follower retry: transient DB error keeps retry scheduled (no false-positive cancel)")
void followerRetryKeepsOnTransientFailure() {
ScheduledFuture<?> future = mock(ScheduledFuture.class);
@SuppressWarnings("unchecked")
Map<Long, ScheduledFuture<?>> followerRetries =
(Map<Long, ScheduledFuture<?>>) ReflectionTestUtils.getField(manager, "followerRetryFutures");
followerRetries.put(100L, future);
when(channelService.getChannel(100L)).thenThrow(new RuntimeException("connection refused"));
manager.followerRetry(100L);
assertTrue(manager.hasFollowerRetry(100L),
"Transient lookup errors must not cancel the follower retry");
verify(future, never()).cancel(any(Boolean.class));
}
@Test
@DisplayName("non-leader reconcile: disabled channel on another node triggers local stop")
void nonLeaderReconcileStopsOnDisabled() {
// Seed a non-leader active adapter (no lease, no heartbeat) this is
// the state a Feishu-webhook or Telegram-webhook node lives in.
TrackingAdapter webhookAdapter = new TrackingAdapter() {
@Override public boolean requiresSingleLeader() { return false; }
};
seedNonLeaderState(60L, webhookAdapter, LocalDateTime.of(2026, 1, 1, 0, 0));
ChannelEntity disabled = entity(60L, "feishu");
disabled.setEnabled(false);
disabled.setUpdateTime(LocalDateTime.of(2026, 1, 1, 0, 0));
when(channelService.getChannel(60L)).thenReturn(disabled);
manager.reconcileChannel(60L, "webhook-channel");
assertFalse(manager.getAdapter(60L).isPresent(),
"Non-leader reconcile must stop the local adapter when admin disables on another node");
assertTrue(webhookAdapter.stopped.get());
}
@Test
@DisplayName("non-leader → leader-required flip: winner becomes leader (no direct start without election)")
void nonLeaderToLeaderFlipWinsElection() {
// Seed a non-leader webhook adapter (no lease).
TrackingAdapter webhookAdapter = new TrackingAdapter() {
@Override public boolean requiresSingleLeader() { return false; }
};
seedNonLeaderState(80L, webhookAdapter, LocalDateTime.of(2026, 1, 1, 0, 0));
// New config flips into leader-required mode.
TrackingAdapter wsAdapter = new TrackingAdapter(); // requiresSingleLeader=true
ChannelManager spied = spy(manager);
doReturn(wsAdapter).when(spied).createAdapter(any(ChannelEntity.class));
// This node wins the election.
LeaderLease lease = mock(LeaderLease.class);
when(election.tryAcquire(anyString())).thenReturn(Optional.of(lease));
ChannelEntity flipped = entity(80L, "feishu");
flipped.setUpdateTime(LocalDateTime.of(2026, 1, 1, 0, 5));
spied.applyConfigChange(80L, flipped);
assertTrue(webhookAdapter.stopped.get(), "Old non-leader adapter must be stopped");
assertTrue(wsAdapter.started.get(), "New leader-required adapter starts only after we won the election");
// Lease is recorded so the heartbeat can extend it.
@SuppressWarnings("unchecked")
Map<Long, LeaderLease> leases =
(Map<Long, LeaderLease>) ReflectionTestUtils.getField(spied, "activeLeases");
assertSame(lease, leases.get(80L), "Won lease must be tracked under the channel id");
verify(election, times(1)).tryAcquire("feishu:80");
}
@Test
@DisplayName("non-leader → leader-required flip: loser does NOT start the new adapter and enters follower retry")
void nonLeaderToLeaderFlipLosesElection() {
TrackingAdapter webhookAdapter = new TrackingAdapter() {
@Override public boolean requiresSingleLeader() { return false; }
};
seedNonLeaderState(81L, webhookAdapter, LocalDateTime.of(2026, 1, 1, 0, 0));
TrackingAdapter wsAdapter = new TrackingAdapter();
ChannelManager spied = spy(manager);
doReturn(wsAdapter).when(spied).createAdapter(any(ChannelEntity.class));
// Another node already holds the lease.
when(election.tryAcquire(anyString())).thenReturn(Optional.empty());
ChannelEntity flipped = entity(81L, "feishu");
flipped.setUpdateTime(LocalDateTime.of(2026, 1, 1, 0, 5));
spied.applyConfigChange(81L, flipped);
assertTrue(webhookAdapter.stopped.get(), "Old non-leader adapter must be stopped");
assertFalse(wsAdapter.started.get(),
"Loser must NOT call newAdapter.start() — that would open a duplicate WS bypassing the leader gate");
assertFalse(spied.getAdapter(81L).isPresent());
assertTrue(spied.hasFollowerRetry(81L), "Loser must enter follower retry to take over if the current leader dies");
verify(election, times(1)).tryAcquire("feishu:81");
}
@Test
@DisplayName("non-leader reconcile: config change on another node propagates via stop+start")
void nonLeaderReconcileAppliesConfigUpdate() {
TrackingAdapter oldAdapter = new TrackingAdapter() {
@Override public boolean requiresSingleLeader() { return false; }
};
seedNonLeaderState(61L, oldAdapter, LocalDateTime.of(2026, 1, 1, 0, 0));
TrackingAdapter newAdapter = new TrackingAdapter() {
@Override public boolean requiresSingleLeader() { return false; }
};
ChannelManager spied = spy(manager);
doReturn(newAdapter).when(spied).createAdapter(any(ChannelEntity.class));
ChannelEntity updated = entity(61L, "feishu");
updated.setUpdateTime(LocalDateTime.of(2026, 1, 1, 0, 5));
when(channelService.getChannel(61L)).thenReturn(updated);
spied.reconcileChannel(61L, "webhook-channel");
assertTrue(oldAdapter.stopped.get(), "Old non-leader adapter must be stopped");
assertTrue(newAdapter.started.get(), "New non-leader adapter must be started");
assertTrue(spied.getAdapter(61L).isPresent());
}
@Test
@DisplayName("restartChannel: when we hold a lease and new mode is non-leader, release the lease via stop+start")
void restartChannelDetectsLeaseFlip() {
LeaderLease lease = mock(LeaderLease.class);
seedLeaderState(70L, adapter, lease, LocalDateTime.of(2026, 1, 1, 0, 0));
// Make createAdapter produce a non-leader adapter for the restart.
// Without the lease-aware check, restartChannel would take the
// hot-swap path and leave the lease, heartbeat, and
// lastSeenChannelUpdateTime around to be cleaned up only by the
// next heartbeat tick causing an additional restart.
TrackingAdapter newAdapter = new TrackingAdapter() {
@Override public boolean requiresSingleLeader() { return false; }
};
ChannelManager spied = spy(manager);
doReturn(newAdapter).when(spied).createAdapter(any(ChannelEntity.class));
ChannelEntity updated = entity(70L, "feishu");
updated.setUpdateTime(LocalDateTime.of(2026, 1, 1, 0, 5));
when(channelService.getChannel(70L)).thenReturn(updated);
spied.restartChannel(70L);
verify(lease, times(1)).release();
@SuppressWarnings("unchecked")
Map<Long, LeaderLease> leases =
(Map<Long, LeaderLease>) ReflectionTestUtils.getField(spied, "activeLeases");
assertFalse(leases.containsKey(70L), "Lease must be removed from activeLeases after the mode flip");
assertTrue(adapter.stopped.get());
assertTrue(newAdapter.started.get());
}
@Test
@DisplayName("stopAll releases plugin leases and cancels plugin heartbeats (no leak on shutdown)")
@SuppressWarnings("unchecked")
void stopAllReleasesPluginLeases() {
ChannelAdapter pluginAdapter = mock(ChannelAdapter.class);
when(pluginAdapter.getChannelType()).thenReturn("custom-im");
when(pluginAdapter.getDisplayName()).thenReturn("custom-im");
LeaderLease pluginLease = mock(LeaderLease.class);
ScheduledFuture<?> pluginHeartbeat = mock(ScheduledFuture.class);
Map<String, ChannelAdapter> pluginChannels =
(Map<String, ChannelAdapter>) ReflectionTestUtils.getField(manager, "pluginChannels");
Map<String, LeaderLease> pluginLeases =
(Map<String, LeaderLease>) ReflectionTestUtils.getField(manager, "pluginLeases");
Map<String, ScheduledFuture<?>> pluginHeartbeats =
(Map<String, ScheduledFuture<?>>) ReflectionTestUtils.getField(manager, "pluginHeartbeatFutures");
pluginChannels.put("my-plugin", pluginAdapter);
pluginLeases.put("my-plugin", pluginLease);
pluginHeartbeats.put("my-plugin", pluginHeartbeat);
manager.stopAll();
verify(pluginAdapter, times(1)).stop();
verify(pluginLease, times(1)).release();
verify(pluginHeartbeat, times(1)).cancel(false);
assertTrue(pluginChannels.isEmpty());
assertTrue(pluginLeases.isEmpty());
assertTrue(pluginHeartbeats.isEmpty());
}
// ==================== helpers ====================
private ChannelEntity entity(Long id, String type) {
ChannelEntity e = new ChannelEntity();
e.setId(id);
e.setName("test-" + id);
e.setChannelType(type);
e.setEnabled(true);
return e;
}
private void seedLeaderState(Long id, ChannelAdapter adapter, LeaderLease lease,
LocalDateTime updateTime) {
@SuppressWarnings("unchecked")
Map<Long, ChannelAdapter> active =
(Map<Long, ChannelAdapter>) ReflectionTestUtils.getField(manager, "activeAdapters");
@SuppressWarnings("unchecked")
Map<Long, LeaderLease> leases =
(Map<Long, LeaderLease>) ReflectionTestUtils.getField(manager, "activeLeases");
active.put(id, adapter);
leases.put(id, lease);
lastSeenMap().put(id, updateTime);
}
/**
* Seed a non-leader-required active adapter: appears in
* {@code activeAdapters} and {@code lastSeenChannelUpdateTime}, but
* no entry in {@code activeLeases} / {@code heartbeatFutures} (those
* only exist for leader-required modes).
*/
private void seedNonLeaderState(Long id, ChannelAdapter adapter, LocalDateTime updateTime) {
@SuppressWarnings("unchecked")
Map<Long, ChannelAdapter> active =
(Map<Long, ChannelAdapter>) ReflectionTestUtils.getField(manager, "activeAdapters");
active.put(id, adapter);
lastSeenMap().put(id, updateTime);
}
@SuppressWarnings("unchecked")
private Map<Long, LocalDateTime> lastSeenMap() {
return (Map<Long, LocalDateTime>) ReflectionTestUtils.getField(manager, "lastSeenChannelUpdateTime");
}
/**
* Minimal adapter that records start/stop without opening any
* upstream connection the tests need observable state, not real
* IM behavior.
*/
private static class TrackingAdapter implements ChannelAdapter {
final AtomicBoolean started = new AtomicBoolean(false);
final AtomicBoolean stopped = new AtomicBoolean(false);
@Override public void start() { started.set(true); }
@Override public void stop() { stopped.set(true); }
@Override public boolean isRunning() { return started.get() && !stopped.get(); }
@Override public void onMessage(ChannelMessage message) {}
@Override public void sendMessage(String targetId, String content) {}
@Override public void sendContentParts(String targetId, List<MessageContentPart> parts) {}
@Override public String getChannelType() { return "feishu"; }
@Override public boolean requiresSingleLeader() { return true; }
}
}

View File

@ -0,0 +1,70 @@
package vip.mate.channel;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Pin the adaptive-debounce thresholds in
* {@link ChannelMessageRouter#pickDebounceMs(int)}.
*
* <p>The router merges same-conversation messages within a debounce window
* before forwarding to the agent. The default {@link
* ChannelMessageRouter#DEBOUNCE_MS} (500ms) is right for normal chatting
* but too short for IM clients that silently split long pasted prompts
* across multiple frames the second fragment can arrive 1-2 seconds
* after the first, missing the window. When merged content crosses
* {@link ChannelMessageRouter#LONG_TEXT_THRESHOLD} the merger switches to
* {@link ChannelMessageRouter#LONG_DEBOUNCE_MS} so it has time to absorb
* the rest. These tests document that boundary behavior so future tuning
* is intentional rather than incidental.
*/
class ChannelMessageRouterDebounceTest {
@Test
@DisplayName("short messages keep the 500ms default debounce")
void shortMessagesKeepDefaultDebounce() {
assertEquals(ChannelMessageRouter.DEBOUNCE_MS,
ChannelMessageRouter.pickDebounceMs(0));
assertEquals(ChannelMessageRouter.DEBOUNCE_MS,
ChannelMessageRouter.pickDebounceMs(50));
assertEquals(ChannelMessageRouter.DEBOUNCE_MS,
ChannelMessageRouter.pickDebounceMs(500));
assertEquals(ChannelMessageRouter.DEBOUNCE_MS,
ChannelMessageRouter.pickDebounceMs(ChannelMessageRouter.LONG_TEXT_THRESHOLD));
}
@Test
@DisplayName("crossing the threshold flips to the extended 2.5s window")
void longContentTriggersLongDebounce() {
assertEquals(ChannelMessageRouter.LONG_DEBOUNCE_MS,
ChannelMessageRouter.pickDebounceMs(ChannelMessageRouter.LONG_TEXT_THRESHOLD + 1));
assertEquals(ChannelMessageRouter.LONG_DEBOUNCE_MS,
ChannelMessageRouter.pickDebounceMs(2000));
assertEquals(ChannelMessageRouter.LONG_DEBOUNCE_MS,
ChannelMessageRouter.pickDebounceMs(6000));
assertEquals(ChannelMessageRouter.LONG_DEBOUNCE_MS,
ChannelMessageRouter.pickDebounceMs(Integer.MAX_VALUE));
}
@Test
@DisplayName("threshold + windows are sane: long > default, threshold below typical IM split")
void thresholdsAreSane() {
// The whole point the extended window must actually be larger,
// otherwise the adaptive branch is a no-op.
assertTrue(ChannelMessageRouter.LONG_DEBOUNCE_MS > ChannelMessageRouter.DEBOUNCE_MS,
"extended debounce must exceed default");
// Threshold sits below the typical ~2000-char WeCom client split
// point; if it ever crept above 2000 the merger would never
// engage on a real paste-split.
assertTrue(ChannelMessageRouter.LONG_TEXT_THRESHOLD < 2000,
"threshold must stay under the IM client's split point");
// And well above any normally typed message typing 1500+ chars
// in one bubble is extremely rare. Guards against accidentally
// applying the long-debounce penalty to ordinary chats.
assertTrue(ChannelMessageRouter.LONG_TEXT_THRESHOLD >= 1000,
"threshold must be high enough that typing doesn't trip it");
}
}

View File

@ -0,0 +1,153 @@
package vip.mate.channel;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
/**
* Pin the group-chat sender-attribution contract.
*
* <p>In groups, three users sharing one conversation send overlapping
* questions. Without {@code [@sender]} tags, the persisted history
* collapses into an unattributed wall of "user:" turns and the LLM can
* no longer tell who asked what. Without per-sender debounce boundaries,
* a paste-split fragment from user A can also accidentally absorb user
* B's text and mis-attribute it. These tests pin both contracts so a
* future refactor can't silently regress group multi-user usability.
*/
class ChannelMessageRouterGroupAttributionTest {
private static ChannelMessage groupMessage(String senderId, String senderName, String content) {
return ChannelMessage.builder()
.channelType("wecom")
.senderId(senderId)
.senderName(senderName)
.chatId("group-abc") // chatId set group context
.content(content)
.build();
}
private static ChannelMessage singleMessage(String senderId, String content) {
return ChannelMessage.builder()
.channelType("wecom")
.senderId(senderId)
.senderName(senderId)
.chatId(null) // chatId null 1:1 chat
.content(content)
.build();
}
// ===== buildGroupTag =====
@Test
@DisplayName("single chat (chatId null) → no tag, no behavior change")
void singleChatNoTag() {
assertNull(ChannelMessageRouter.buildGroupTag(singleMessage("alice", "hi")));
}
@Test
@DisplayName("group chat with senderName → [@senderName] tag")
void groupWithSenderName() {
ChannelMessage m = groupMessage("alice-id", "Alice Wang", "hi");
assertEquals("[@Alice Wang]", ChannelMessageRouter.buildGroupTag(m));
}
@Test
@DisplayName("group chat falls back to senderId when senderName is blank")
void groupFallsBackToSenderId() {
ChannelMessage m = groupMessage("alice-id", "", "hi");
assertEquals("[@alice-id]", ChannelMessageRouter.buildGroupTag(m));
}
@Test
@DisplayName("group chat with no resolvable identity returns null (don't fabricate a tag)")
void groupNoIdentity() {
ChannelMessage m = ChannelMessage.builder()
.channelType("wecom")
.chatId("group-abc")
.content("hi")
.build();
// Both senderId and senderName are null. Better to skip attribution
// than to invent "[@null]" which would corrupt the prompt.
assertNull(ChannelMessageRouter.buildGroupTag(m));
}
@Test
@DisplayName("blank chatId is treated as not-a-group")
void blankChatIdNotAGroup() {
ChannelMessage m = ChannelMessage.builder()
.channelType("wecom")
.senderId("alice")
.senderName("Alice")
.chatId(" ")
.content("hi")
.build();
assertNull(ChannelMessageRouter.buildGroupTag(m));
}
// ===== applyGroupTag =====
@Test
@DisplayName("applyGroupTag: single chat content passes through verbatim")
void applyTagSingleChatPassesThrough() {
ChannelMessage m = singleMessage("alice", "hello world");
assertEquals("hello world",
ChannelMessageRouter.applyGroupTag(m, "hello world"));
}
@Test
@DisplayName("applyGroupTag: group content gets [@sender] prefix")
void applyTagGroupPrefixes() {
ChannelMessage m = groupMessage("alice-id", "Alice", "hello world");
assertEquals("[@Alice] hello world",
ChannelMessageRouter.applyGroupTag(m, "hello world"));
}
@Test
@DisplayName("applyGroupTag: idempotent — already-prefixed content is not double-tagged")
void applyTagIdempotent() {
ChannelMessage m = groupMessage("alice-id", "Alice", "ignored");
// Simulates a code path that has already attributed the content
// (e.g. a future channel adapter that pre-tags inbound text).
assertEquals("[@Alice] hello",
ChannelMessageRouter.applyGroupTag(m, "[@Alice] hello"));
}
@Test
@DisplayName("applyGroupTag: empty content stays empty (no bare-tag artifact)")
void applyTagEmptyStaysEmpty() {
ChannelMessage m = groupMessage("alice-id", "Alice", "");
// A truly empty message (no text, no parts producing text) shouldn't
// surface as a useless "[@Alice]" turn the agent has nothing to
// act on. Skip the tag to keep persisted history clean.
assertEquals("", ChannelMessageRouter.applyGroupTag(m, ""));
assertNull(ChannelMessageRouter.applyGroupTag(m, null));
}
// ===== isSameSender (the merge boundary helper) =====
@Test
@DisplayName("isSameSender: same sender → merge allowed (paste-split / rapid follow-up)")
void sameSenderMergeAllowed() {
assertTrue(ChannelMessageRouter.isSameSender("alice", "alice"));
}
@Test
@DisplayName("isSameSender: different sender → no merge (group sender boundary)")
void differentSenderNoMerge() {
// The whole point of the group fix: A's pending must NOT absorb B's
// text, otherwise the merged buffer attributes both to A.
assertFalse(ChannelMessageRouter.isSameSender("alice", "bob"));
}
@Test
@DisplayName("isSameSender: null on either side → no merge (defensive)")
void nullSendersNoMerge() {
// Pending fixtures occasionally have null senderIds; better to start
// a fresh pending than to silently merge into an unidentified buffer.
assertFalse(ChannelMessageRouter.isSameSender(null, "alice"));
assertFalse(ChannelMessageRouter.isSameSender("alice", null));
assertFalse(ChannelMessageRouter.isSameSender(null, null));
}
}

View File

@ -0,0 +1,212 @@
package vip.mate.channel;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
/**
* RFC-03 Lane K3 covers {@link MediaPathGuard} validation rules.
*
* <p>Each test reads as a property of "what every channel adapter must
* agree on": no path traversal, no implicit directory writes, no
* surprise extensions, no DoS via giant files. Failures bind to the
* stable {@link MediaPathGuard.Reason} codes so audit / metrics
* downstream can group violations without parsing message text.
*/
class MediaPathGuardTest {
private static MediaPathGuard.Policy policy(Path workspace) {
return new MediaPathGuard.Policy(
workspace,
Set.of("png", "jpg", "pdf", "txt"),
10 * 1024 * 1024 // 10 MiB
);
}
// Containment
@Test
@DisplayName("file under workspace root → returns canonical path")
void fileInWorkspaceAccepted(@TempDir Path workspace) throws Exception {
Path file = workspace.resolve("hello.txt");
Files.writeString(file, "hi");
Path resolved = MediaPathGuard.validate(file, policy(workspace));
assertNotNull(resolved);
assertEquals(workspace.toRealPath(), resolved.getParent());
}
@Test
@DisplayName("file outside workspace via ../ → PATH_OUTSIDE_WORKSPACE")
void traversalRejected(@TempDir Path workspace, @TempDir Path elsewhere) throws Exception {
Path victim = elsewhere.resolve("secret.txt");
Files.writeString(victim, "top secret");
var ex = assertThrows(MediaPathGuard.MediaValidationException.class,
() -> MediaPathGuard.validate(victim, policy(workspace)));
assertEquals(MediaPathGuard.Reason.PATH_OUTSIDE_WORKSPACE, ex.reason());
}
@Test
@DisplayName("workspace prefix-name overlap not exploitable — /ws-foo doesn't match /ws-foobar")
void prefixOverlapIsNotContainment(@TempDir Path tmp) throws Exception {
// /tmp/ws/ workspace
// /tmp/wsbig/file.txt attacker file with a similar prefix
Path workspace = tmp.resolve("ws");
Path neighbor = tmp.resolve("wsbig");
Files.createDirectory(workspace);
Files.createDirectory(neighbor);
Path attackerFile = neighbor.resolve("file.txt");
Files.writeString(attackerFile, "x");
var ex = assertThrows(MediaPathGuard.MediaValidationException.class,
() -> MediaPathGuard.validate(attackerFile, policy(workspace)));
assertEquals(MediaPathGuard.Reason.PATH_OUTSIDE_WORKSPACE, ex.reason(),
"Path.startsWith must compare elements, not strings — otherwise wsbig looks like a child of ws");
}
@Test
@DisplayName("missing file → FILE_MISSING (not opaque IO_ERROR)")
void missingFile(@TempDir Path workspace) {
Path nope = workspace.resolve("does-not-exist.txt");
var ex = assertThrows(MediaPathGuard.MediaValidationException.class,
() -> MediaPathGuard.validate(nope, policy(workspace)));
assertEquals(MediaPathGuard.Reason.FILE_MISSING, ex.reason(),
"missing files have a dedicated reason so audit output isn't misleading");
}
// Type
@Test
@DisplayName("directory passed in place of file → NOT_A_REGULAR_FILE")
void directoryRejected(@TempDir Path workspace) throws Exception {
Path subdir = workspace.resolve("subdir");
Files.createDirectory(subdir);
var ex = assertThrows(MediaPathGuard.MediaValidationException.class,
() -> MediaPathGuard.validate(subdir, policy(workspace)));
assertEquals(MediaPathGuard.Reason.NOT_A_REGULAR_FILE, ex.reason());
}
// Extension
@Test
@DisplayName("extension allowlist case-insensitive")
void extensionCaseInsensitive(@TempDir Path workspace) throws Exception {
Path uppercaseExt = workspace.resolve("HelloWorld.PNG");
Files.write(uppercaseExt, new byte[]{0x1a});
// Should pass policy allows "png" lowercase, file has "PNG".
Path ok = MediaPathGuard.validate(uppercaseExt, policy(workspace));
assertNotNull(ok);
}
@Test
@DisplayName("policy allowlist normalizes leading-dot extensions")
void allowlistAcceptsDotPrefix(@TempDir Path workspace) throws Exception {
Path file = workspace.resolve("a.txt");
Files.writeString(file, "x");
// Same policy, but the dev specified ".txt" instead of "txt" both work.
var p = new MediaPathGuard.Policy(workspace, Set.of(".txt"), 1024L);
assertNotNull(MediaPathGuard.validate(file, p));
}
@Test
@DisplayName("extension not in allowlist → EXTENSION_NOT_ALLOWED")
void extensionNotAllowed(@TempDir Path workspace) throws Exception {
Path file = workspace.resolve("malware.exe");
Files.write(file, new byte[]{0x4d, 0x5a});
var ex = assertThrows(MediaPathGuard.MediaValidationException.class,
() -> MediaPathGuard.validate(file, policy(workspace)));
assertEquals(MediaPathGuard.Reason.EXTENSION_NOT_ALLOWED, ex.reason());
}
@Test
@DisplayName("file with no extension → EXTENSION_NOT_ALLOWED (defensive)")
void noExtension(@TempDir Path workspace) throws Exception {
Path file = workspace.resolve("README");
Files.writeString(file, "doc");
var ex = assertThrows(MediaPathGuard.MediaValidationException.class,
() -> MediaPathGuard.validate(file, policy(workspace)));
assertEquals(MediaPathGuard.Reason.EXTENSION_NOT_ALLOWED, ex.reason());
}
// Size
@Test
@DisplayName("file at policy size cap is accepted (boundary)")
void atCapAccepted(@TempDir Path workspace) throws Exception {
Path file = workspace.resolve("at-cap.txt");
Files.write(file, new byte[100]);
var p = new MediaPathGuard.Policy(workspace, Set.of("txt"), 100L);
Path ok = MediaPathGuard.validate(file, p);
assertNotNull(ok);
}
@Test
@DisplayName("file 1 byte over cap → FILE_TOO_LARGE")
void overCapRejected(@TempDir Path workspace) throws Exception {
Path file = workspace.resolve("big.txt");
Files.write(file, new byte[101]);
var p = new MediaPathGuard.Policy(workspace, Set.of("txt"), 100L);
var ex = assertThrows(MediaPathGuard.MediaValidationException.class,
() -> MediaPathGuard.validate(file, p));
assertEquals(MediaPathGuard.Reason.FILE_TOO_LARGE, ex.reason());
}
// Returned canonical path
@Test
@DisplayName("returned path is canonical (toRealPath) — TOCTOU-safe for downstream callers")
void canonicalPathReturned(@TempDir Path workspace) throws Exception {
// Use a relative path that resolves to a real file via . segment
// the returned value must drop the redundant segment.
Path realFile = workspace.resolve("real.png");
Files.write(realFile, new byte[]{1});
Path withDotSegment = workspace.resolve(".").resolve("real.png");
Path canonical = MediaPathGuard.validate(withDotSegment, policy(workspace));
assertEquals(realFile.toRealPath(), canonical);
assertNotEquals(withDotSegment, canonical,
"validate must return the canonical form, not echo the user-supplied path verbatim");
}
// Policy guards
@Test
@DisplayName("non-positive maxBytes → IllegalArgumentException at policy construction")
void zeroMaxBytesRejected(@TempDir Path workspace) {
assertThrows(IllegalArgumentException.class,
() -> new MediaPathGuard.Policy(workspace, Set.of("txt"), 0L));
assertThrows(IllegalArgumentException.class,
() -> new MediaPathGuard.Policy(workspace, Set.of("txt"), -100L));
}
@Test
@DisplayName("extensionOf — public-internals helper coverage")
void extensionOfHelper() {
assertEquals("png", MediaPathGuard.extensionOf(Path.of("a.png")));
assertEquals("png", MediaPathGuard.extensionOf(Path.of("PATH/a.PNG")));
assertEquals("", MediaPathGuard.extensionOf(Path.of("README")));
assertEquals("", MediaPathGuard.extensionOf(Path.of("trailing.")));
assertEquals("gz", MediaPathGuard.extensionOf(Path.of("archive.tar.gz")),
"double-dot filenames should report the rightmost segment");
}
}

View File

@ -0,0 +1,100 @@
package vip.mate.channel.feishu;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
class FeishuTextSplitTest {
@Test
@DisplayName("short content returns single-element list unchanged")
void shortContent() {
List<String> chunks = FeishuChannelAdapter.splitTextForFeishu("hello world", 100);
assertEquals(List.of("hello world"), chunks);
}
@Test
@DisplayName("null/empty returns empty list")
void nullEmpty() {
assertTrue(FeishuChannelAdapter.splitTextForFeishu(null, 100).isEmpty());
assertTrue(FeishuChannelAdapter.splitTextForFeishu("", 100).isEmpty());
}
@Test
@DisplayName("split prefers paragraph (\\n\\n) boundary over hard cut")
void prefersParagraphBoundary() {
String content = "first paragraph here\n\nsecond paragraph here";
// maxChars chosen so the paragraph boundary lands in the second half
List<String> chunks = FeishuChannelAdapter.splitTextForFeishu(content, 30);
assertEquals(2, chunks.size());
assertEquals("first paragraph here\n\n", chunks.get(0));
assertEquals("second paragraph here", chunks.get(1));
}
@Test
@DisplayName("split falls through to line boundary when no paragraph break")
void fallsThroughToLine() {
String content = "line1 with content\nline2 with content\nline3 with content";
List<String> chunks = FeishuChannelAdapter.splitTextForFeishu(content, 25);
assertTrue(chunks.size() >= 2);
// each chunk should end at \n boundary or be the final chunk
for (int i = 0; i < chunks.size() - 1; i++) {
assertTrue(chunks.get(i).endsWith("\n"),
"Non-final chunk should end at line boundary: '" + chunks.get(i) + "'");
}
assertEquals(content, String.join("", chunks),
"Concatenation must reconstruct the original");
}
@Test
@DisplayName("oversized single line falls through to whitespace boundary")
void fallsThroughToWhitespace() {
String content = "word ".repeat(200); // 1000 chars, no \n
List<String> chunks = FeishuChannelAdapter.splitTextForFeishu(content, 100);
assertTrue(chunks.size() >= 10);
for (String chunk : chunks) {
assertTrue(chunk.length() <= 100,
"Each chunk must be within limit, got " + chunk.length());
}
assertEquals(content, String.join("", chunks));
}
@Test
@DisplayName("zero-boundary content (no spaces, no newlines) hard-cuts")
void hardCutWhenNoBoundary() {
String content = "x".repeat(1000);
List<String> chunks = FeishuChannelAdapter.splitTextForFeishu(content, 250);
assertEquals(4, chunks.size());
for (String chunk : chunks) {
assertEquals(250, chunk.length());
}
assertEquals(content, String.join("", chunks));
}
@Test
@DisplayName("default 4000-char limit holds for very long markdown answer")
void realisticLongAnswer() {
// Simulate a 12K-char LLM answer with paragraph breaks every ~400 chars
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 30; i++) {
sb.append("段落 ").append(i).append("")
.append("这里是一些内容,模拟一个真实的长回答。".repeat(10))
.append("\n\n");
}
String content = sb.toString();
List<String> chunks = FeishuChannelAdapter.splitTextForFeishu(
content, FeishuChannelAdapter.MAX_TEXT_MESSAGE_CHARS);
assertTrue(chunks.size() >= 2,
"12K-char answer should split into multiple chunks, got " + chunks.size());
for (String chunk : chunks) {
assertTrue(chunk.length() <= FeishuChannelAdapter.MAX_TEXT_MESSAGE_CHARS,
"Chunk exceeds limit: " + chunk.length());
}
assertEquals(content, String.join("", chunks),
"Reconstruction lossless");
}
}

View File

@ -0,0 +1,124 @@
package vip.mate.channel.leader;
import net.javacrumbs.shedlock.core.LockConfiguration;
import net.javacrumbs.shedlock.core.LockProvider;
import net.javacrumbs.shedlock.core.SimpleLock;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import java.time.Duration;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
class ChannelLeaderElectionTest {
@Test
@DisplayName("tryAcquire returns empty when LockProvider rejects (another node owns the lock)")
void tryAcquireWhenLockHeldElsewhere() {
LockProvider provider = mock(LockProvider.class);
when(provider.lock(any(LockConfiguration.class))).thenReturn(Optional.empty());
ChannelLeaderElection election = new ChannelLeaderElection(provider);
Optional<LeaderLease> lease = election.tryAcquire("feishu:42");
assertTrue(lease.isEmpty(), "Expected empty optional when lock is held elsewhere");
}
@Test
@DisplayName("tryAcquire returns a lease when LockProvider grants the lock")
void tryAcquireWhenLockGranted() {
LockProvider provider = mock(LockProvider.class);
SimpleLock simpleLock = mock(SimpleLock.class);
when(provider.lock(any(LockConfiguration.class))).thenReturn(Optional.of(simpleLock));
ChannelLeaderElection election = new ChannelLeaderElection(provider);
Optional<LeaderLease> lease = election.tryAcquire("qq:7");
assertTrue(lease.isPresent());
assertEquals("channel-leader:qq:7", lease.get().getName());
}
@Test
@DisplayName("Lock name is prefixed so leases don't collide with other ShedLock users (e.g. cron)")
void lockNameIsPrefixed() {
LockProvider provider = mock(LockProvider.class);
SimpleLock simpleLock = mock(SimpleLock.class);
when(provider.lock(any(LockConfiguration.class))).thenReturn(Optional.of(simpleLock));
ChannelLeaderElection election = new ChannelLeaderElection(provider);
election.tryAcquire("feishu:42");
ArgumentCaptor<LockConfiguration> captor = ArgumentCaptor.forClass(LockConfiguration.class);
verify(provider).lock(captor.capture());
assertEquals("channel-leader:feishu:42", captor.getValue().getName());
}
@Test
@DisplayName("Lease extend() reports success when ShedLock returns a new SimpleLock")
void leaseExtendSuccess() {
SimpleLock current = mock(SimpleLock.class);
SimpleLock next = mock(SimpleLock.class);
when(current.extend(any(Duration.class), any(Duration.class))).thenReturn(Optional.of(next));
LeaderLease lease = new LeaderLease("test", current);
assertTrue(lease.extend(Duration.ofSeconds(60)));
}
@Test
@DisplayName("Lease extend() reports failure when ShedLock returns empty (lock lost)")
void leaseExtendLost() {
SimpleLock current = mock(SimpleLock.class);
when(current.extend(any(Duration.class), any(Duration.class))).thenReturn(Optional.empty());
LeaderLease lease = new LeaderLease("test", current);
assertFalse(lease.extend(Duration.ofSeconds(60)));
}
@Test
@DisplayName("Lease extend() swallows exceptions and reports failure — heartbeats must not crash the scheduler")
void leaseExtendCatchesException() {
SimpleLock current = mock(SimpleLock.class);
when(current.extend(any(Duration.class), any(Duration.class)))
.thenThrow(new RuntimeException("db connection lost"));
LeaderLease lease = new LeaderLease("test", current);
assertFalse(lease.extend(Duration.ofSeconds(60)));
}
@Test
@DisplayName("Lease release() unlocks the underlying SimpleLock exactly once even if called twice")
void leaseReleaseIdempotent() {
SimpleLock simpleLock = mock(SimpleLock.class);
LeaderLease lease = new LeaderLease("test", simpleLock);
lease.release();
lease.release();
verify(simpleLock, times(1)).unlock();
}
@Test
@DisplayName("Lease release() swallows unlock exceptions so shutdown can't be blocked by them")
void leaseReleaseCatchesException() {
SimpleLock simpleLock = mock(SimpleLock.class);
doThrow(new RuntimeException("db gone")).when(simpleLock).unlock();
LeaderLease lease = new LeaderLease("test", simpleLock);
assertDoesNotThrow(lease::release);
}
@Test
@DisplayName("Once released, extend() always returns false without touching the underlying lock")
void extendAfterReleaseIsFalse() {
SimpleLock simpleLock = mock(SimpleLock.class);
LeaderLease lease = new LeaderLease("test", simpleLock);
lease.release();
assertFalse(lease.extend(Duration.ofSeconds(60)));
verify(simpleLock, never()).extend(any(Duration.class), any(Duration.class));
}
}

View File

@ -0,0 +1,151 @@
package vip.mate.channel.leader;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import vip.mate.channel.ChannelMessageRouter;
import vip.mate.channel.discord.DiscordChannelAdapter;
import vip.mate.channel.feishu.FeishuChannelAdapter;
import vip.mate.channel.model.ChannelEntity;
import vip.mate.channel.qq.QQChannelAdapter;
import vip.mate.channel.telegram.TelegramChannelAdapter;
import vip.mate.channel.wecom.WeComChannelAdapter;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.mock;
/**
* Behavioural test for the {@code requiresSingleLeader()} hook on the
* Feishu and QQ adapters. This is what gates leader election in
* {@code ChannelManager}, so a regression that flips the answer would
* silently re-introduce the multi-instance connection-limit bug.
*/
class SingleLeaderHookTest {
private final ObjectMapper objectMapper = new ObjectMapper();
private final ChannelMessageRouter router = mock(ChannelMessageRouter.class);
private ChannelEntity channel(String type, String configJson) {
ChannelEntity e = new ChannelEntity();
e.setId(1L);
e.setName("test");
e.setChannelType(type);
e.setConfigJson(configJson);
e.setEnabled(true);
return e;
}
@Test
@DisplayName("Feishu in WebSocket mode requires single leader (default mode)")
void feishuWebsocketRequiresLeader() {
FeishuChannelAdapter adapter = new FeishuChannelAdapter(
channel("feishu", "{\"app_id\":\"x\",\"app_secret\":\"y\"}"),
router, objectMapper);
assertTrue(adapter.requiresSingleLeader(),
"Default Feishu mode is websocket and must require single leader");
}
@Test
@DisplayName("Feishu explicitly set to websocket mode requires single leader")
void feishuExplicitWebsocketRequiresLeader() {
FeishuChannelAdapter adapter = new FeishuChannelAdapter(
channel("feishu",
"{\"app_id\":\"x\",\"app_secret\":\"y\",\"connection_mode\":\"websocket\"}"),
router, objectMapper);
assertTrue(adapter.requiresSingleLeader());
}
@Test
@DisplayName("Feishu in webhook mode does NOT require single leader (load-balanced HTTP)")
void feishuWebhookDoesNotRequireLeader() {
FeishuChannelAdapter adapter = new FeishuChannelAdapter(
channel("feishu",
"{\"app_id\":\"x\",\"app_secret\":\"y\",\"connection_mode\":\"webhook\"}"),
router, objectMapper);
assertFalse(adapter.requiresSingleLeader(),
"Webhook callbacks are HTTP-fanned by the LB, so all nodes may subscribe");
}
@Test
@DisplayName("QQ always requires single leader (gateway rejects duplicate IDENTIFY)")
void qqAlwaysRequiresLeader() {
QQChannelAdapter adapter = new QQChannelAdapter(
channel("qq", "{\"app_id\":\"x\",\"client_secret\":\"y\"}"),
router, objectMapper);
assertTrue(adapter.requiresSingleLeader());
}
@Test
@DisplayName("WeCom always requires single leader (WS-only aibot transport)")
void wecomAlwaysRequiresLeader() {
WeComChannelAdapter adapter = new WeComChannelAdapter(
channel("wecom", "{\"bot_id\":\"x\",\"secret\":\"y\"}"),
router, objectMapper,
mock(vip.mate.channel.notification.ApprovalNotificationService.class),
mock(vip.mate.channel.wecom.cards.WeComCardDispatcher.class),
mock(vip.mate.channel.wecom.WeComKeepaliveScheduler.class));
assertTrue(adapter.requiresSingleLeader());
}
@Test
@DisplayName("Discord always requires single leader (Gateway WS, 1 session per token)")
void discordAlwaysRequiresLeader() {
DiscordChannelAdapter adapter = new DiscordChannelAdapter(
channel("discord", "{\"bot_token\":\"x\"}"),
router, objectMapper);
assertTrue(adapter.requiresSingleLeader());
}
@Test
@DisplayName("Telegram in long-polling mode requires single leader (default mode)")
void telegramPollingRequiresLeader() {
TelegramChannelAdapter adapter = new TelegramChannelAdapter(
channel("telegram", "{\"bot_token\":\"x\"}"),
router, objectMapper);
assertTrue(adapter.requiresSingleLeader(),
"Default Telegram mode is long-polling and must require single leader");
}
@Test
@DisplayName("Telegram explicitly set to polling requires single leader")
void telegramExplicitPollingRequiresLeader() {
TelegramChannelAdapter adapter = new TelegramChannelAdapter(
channel("telegram",
"{\"bot_token\":\"x\",\"connection_mode\":\"polling\"}"),
router, objectMapper);
assertTrue(adapter.requiresSingleLeader());
}
@Test
@DisplayName("Telegram in webhook mode (explicit + url) does NOT require single leader")
void telegramExplicitWebhookDoesNotRequireLeader() {
TelegramChannelAdapter adapter = new TelegramChannelAdapter(
channel("telegram",
"{\"bot_token\":\"x\",\"connection_mode\":\"webhook\",\"webhook_url\":\"https://example.com/hook\"}"),
router, objectMapper);
assertFalse(adapter.requiresSingleLeader(),
"Webhook callbacks are HTTP-fanned by the LB, so all nodes may subscribe");
}
@Test
@DisplayName("Telegram legacy config (no connection_mode + webhook_url set) infers webhook → no leader")
void telegramLegacyWebhookInferredDoesNotRequireLeader() {
TelegramChannelAdapter adapter = new TelegramChannelAdapter(
channel("telegram",
"{\"bot_token\":\"x\",\"webhook_url\":\"https://example.com/hook\"}"),
router, objectMapper);
assertFalse(adapter.requiresSingleLeader(),
"Legacy config without connection_mode but with webhook_url must be inferred as webhook");
}
@Test
@DisplayName("Telegram connection_mode=webhook but webhook_url blank falls back to polling → leader required")
void telegramWebhookWithoutUrlIsPolling() {
TelegramChannelAdapter adapter = new TelegramChannelAdapter(
channel("telegram",
"{\"bot_token\":\"x\",\"connection_mode\":\"webhook\"}"),
router, objectMapper);
assertTrue(adapter.requiresSingleLeader(),
"Webhook mode without a URL falls through to polling and must require single leader");
}
}

View File

@ -0,0 +1,132 @@
package vip.mate.channel.verifier;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import java.util.Collections;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
/**
* Unit tests for the wizard preflight path. We only exercise the
* fail-fast branches that need no network (missing credentials), since
* the happy paths require live upstream services. End-to-end coverage
* happens in the nightly integration job described in RFC-084 §8.
*/
class ChannelVerifierTest {
private final ObjectMapper objectMapper = new ObjectMapper();
@Test
void resultHelpers_buildExpectedShapes() {
VerificationResult ok = VerificationResult.ok(42, "hi", Map.of("a", 1));
assertTrue(ok.ok());
assertFalse(ok.skipped());
assertEquals("hi", ok.headline());
assertEquals(1, ok.identity().get("a"));
VerificationResult bad = VerificationResult.failed(7, "nope", "token", "fix it");
assertFalse(bad.ok());
assertEquals("token", bad.invalidField());
assertEquals("fix it", bad.hint());
assertTrue(bad.identity().isEmpty());
VerificationResult skipped = VerificationResult.skipped("nothing to verify");
assertTrue(skipped.ok());
assertTrue(skipped.skipped());
}
@Test
void telegramVerifier_failsFast_withoutToken() {
TelegramVerifier verifier = new TelegramVerifier(objectMapper);
VerificationResult r = verifier.verify(new VerificationRequest(
"telegram", Collections.emptyMap(), 1L));
assertFalse(r.ok());
assertEquals("bot_token", r.invalidField());
// Should not reach the network duration is 0.
assertEquals(0, r.durationMs());
}
@Test
void discordVerifier_failsFast_withoutToken() {
DiscordVerifier verifier = new DiscordVerifier(objectMapper);
VerificationResult r = verifier.verify(new VerificationRequest(
"discord", Collections.emptyMap(), 1L));
assertFalse(r.ok());
assertEquals("bot_token", r.invalidField());
assertEquals(0, r.durationMs());
}
@Test
void slackVerifier_failsFast_withoutToken() {
SlackVerifier verifier = new SlackVerifier();
VerificationResult r = verifier.verify(new VerificationRequest(
"slack", Collections.emptyMap(), 1L));
assertFalse(r.ok());
assertEquals("bot_token", r.invalidField());
}
@Test
void wecomVerifier_failsFast_withoutCredentials() {
WeComVerifier verifier = new WeComVerifier(objectMapper);
VerificationResult missingBoth = verifier.verify(new VerificationRequest(
"wecom", Collections.emptyMap(), 1L));
assertFalse(missingBoth.ok());
assertEquals("bot_id", missingBoth.invalidField());
assertEquals(0, missingBoth.durationMs());
VerificationResult missingSecret = verifier.verify(new VerificationRequest(
"wecom", Map.of("bot_id", "bot_xxxxx"), 1L));
assertFalse(missingSecret.ok());
assertEquals("secret", missingSecret.invalidField());
}
@Test
void feishuVerifier_failsFast_withoutCredentials() {
FeishuVerifier verifier = new FeishuVerifier(objectMapper);
VerificationResult missingAppId = verifier.verify(new VerificationRequest(
"feishu", Collections.emptyMap(), 1L));
assertFalse(missingAppId.ok());
assertEquals("app_id", missingAppId.invalidField());
VerificationResult missingSecret = verifier.verify(new VerificationRequest(
"feishu", Map.of("app_id", "cli_xxxxx"), 1L));
assertFalse(missingSecret.ok());
assertEquals("app_secret", missingSecret.invalidField());
}
@Test
void dingtalkVerifier_failsFast_withoutCredentials() {
DingTalkVerifier verifier = new DingTalkVerifier(objectMapper);
VerificationResult missingClientId = verifier.verify(new VerificationRequest(
"dingtalk", Collections.emptyMap(), 1L));
assertFalse(missingClientId.ok());
assertEquals("client_id", missingClientId.invalidField());
VerificationResult missingSecret = verifier.verify(new VerificationRequest(
"dingtalk", Map.of("client_id", "dingxxxxxxxx"), 1L));
assertFalse(missingSecret.ok());
assertEquals("client_secret", missingSecret.invalidField());
}
@Test
void weixinVerifier_failsFast_withoutToken() {
WeixinVerifier verifier = new WeixinVerifier(objectMapper);
VerificationResult r = verifier.verify(new VerificationRequest(
"weixin", Collections.emptyMap(), 1L));
assertFalse(r.ok());
assertEquals("bot_token", r.invalidField());
}
@Test
void registry_indexesByChannelType_andLetsLastWin() {
TelegramVerifier first = new TelegramVerifier(objectMapper);
TelegramVerifier second = new TelegramVerifier(objectMapper);
ChannelVerifierRegistry registry = new ChannelVerifierRegistry(java.util.List.of(first, second));
registry.index();
assertTrue(registry.find("telegram").isPresent());
assertSame(second, registry.find("telegram").orElseThrow());
assertTrue(registry.find("nonexistent").isEmpty());
}
}

View File

@ -0,0 +1,91 @@
package vip.mate.channel.web;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import vip.mate.workspace.conversation.model.MessageEntity;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Truth-table for {@link ChatController#derivePersistStatus} (RFC-067 §4.6).
* <p>
* The pre-RFC controller had three different inline derivations across normal /
* queued / replay {@code doOnComplete}; queued and replay hardcoded
* {@code completed} which caused {@code awaiting_approval} turns to be silently
* downgraded the frontend would then call {@code expirePendingApprovals} and
* ghost-clear the banner. These tests pin the unified five-way truth table so a
* future refactor can't reintroduce the divergence.
*/
class ChatControllerPersistStatusTest {
@Test
@DisplayName("awaiting_approval wins over every other condition (top of priority)")
void awaitingApprovalTakesPrecedence() {
// Even when stop+error fired AFTER the approval gate, persistence must
// still surface awaiting_approval the turn isn't truly finished and
// the frontend's done handler skips expire on this status.
assertThat(ChatController.derivePersistStatus(true, true, true,
ChatStreamTracker.InterruptType.USER_INTERRUPT_WITH_FOLLOWUP))
.isEqualTo("awaiting_approval");
assertThat(ChatController.derivePersistStatus(true, false, false, null))
.isEqualTo("awaiting_approval");
}
@Test
@DisplayName("error beats every non-approval state")
void errorOnTypedErrorPrefix() {
assertThat(ChatController.derivePersistStatus(false, true, false, null))
.isEqualTo("error");
// Stop coexists with error still error (BaseAgent sanitization needs to
// skip these regardless of whether the user pressed Stop afterward).
assertThat(ChatController.derivePersistStatus(false, true, true,
ChatStreamTracker.InterruptType.USER_STOP))
.isEqualTo("error");
}
@Test
@DisplayName("clean finish → completed")
void completedOnCleanFinish() {
assertThat(ChatController.derivePersistStatus(false, false, false, null))
.isEqualTo("completed");
}
@Test
@DisplayName("user-stop without follow-up → stopped")
void stoppedOnPlainStop() {
assertThat(ChatController.derivePersistStatus(false, false, true,
ChatStreamTracker.InterruptType.USER_STOP))
.isEqualTo("stopped");
// Null InterruptType (defensive) also collapses to stopped mirrors
// historical behavior where wasStopped+null was the common shape on
// doOnCancel paths before the typed enum landed.
assertThat(ChatController.derivePersistStatus(false, false, true, null))
.isEqualTo("stopped");
}
@Test
@DisplayName("user interrupt-with-followup → interrupted (queued message takes over)")
void interruptedOnFollowupQueue() {
assertThat(ChatController.derivePersistStatus(false, false, true,
ChatStreamTracker.InterruptType.USER_INTERRUPT_WITH_FOLLOWUP))
.isEqualTo("interrupted");
}
@Test
@DisplayName("empty completed turns persist an explicit placeholder")
void emptyCompletedTurnUsesPlaceholder() {
assertThat(ChatController.emptyAssistantPlaceholder("completed"))
.isEqualTo("[本次没有输出]");
assertThat(ChatController.emptyAssistantPlaceholder("awaiting_approval"))
.isEqualTo("[等待审批]");
}
@Test
@DisplayName("done.persisted reflects whether an assistant row was actually saved")
void donePersistedFollowsSavedAssistant() {
MessageEntity saved = new MessageEntity();
assertThat(ChatController.isAssistantPersisted(saved)).isTrue();
assertThat(ChatController.isAssistantPersisted(null)).isFalse();
}
}

View File

@ -0,0 +1,146 @@
package vip.mate.channel.web;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.*;
/**
* Tests for {@link ChatStreamTracker#addBatchedEventRelay}: size-driven flush,
* time-driven flush, and pass-through ordering for non-batched events.
*/
class ChatStreamTrackerBatchedRelayTest {
private ChatStreamTracker newTracker() {
return new ChatStreamTracker(new ObjectMapper());
}
private record Captured(String name, String json) {}
/**
* Spin until {@code condition} is true or {@code timeoutMs} elapses.
* Polling instead of {@code Awaitility} to keep the test classpath
* dependency-free (the project doesn't bundle Awaitility).
*/
private static boolean waitUntil(java.util.function.BooleanSupplier condition, long timeoutMs) {
long deadline = System.currentTimeMillis() + timeoutMs;
while (System.currentTimeMillis() < deadline) {
if (condition.getAsBoolean()) return true;
try {
Thread.sleep(20);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return false;
}
}
return condition.getAsBoolean();
}
@Test
@DisplayName("Buffer flushes when batch size threshold is hit")
void flushAtBatchSize() {
ChatStreamTracker tracker = newTracker();
String src = "src-batch-size";
tracker.register(src);
List<Captured> captured = new CopyOnWriteArrayList<>();
Runnable deregister = tracker.addBatchedEventRelay(src, "parent",
3, 5_000L, // batch=3, flushMs large so the timer never fires
(name, json) -> captured.add(new Captured(name, json)));
try {
tracker.broadcast(src, "tool_call_started", "{\"name\":\"a\"}");
tracker.broadcast(src, "tool_call_completed", "{\"name\":\"a\",\"ok\":true}");
assertTrue(captured.isEmpty(), "Below batch threshold — no flush yet");
tracker.broadcast(src, "tool_call_started", "{\"name\":\"b\"}");
// 3rd buffered event triggers immediate flush.
assertTrue(waitUntil(() -> !captured.isEmpty(), 1_000),
"Flush should happen at batch threshold");
List<Captured> snapshot = new ArrayList<>(captured);
assertEquals(1, snapshot.size(),
"Single delegation_batch envelope expected at the size threshold");
assertEquals("delegation_batch", snapshot.get(0).name());
assertTrue(snapshot.get(0).json().contains("delegation_batch"));
} finally {
deregister.run();
}
}
@Test
@DisplayName("Buffer flushes at the elapsed-time boundary")
void flushOnTimer() {
ChatStreamTracker tracker = newTracker();
String src = "src-batch-time";
tracker.register(src);
List<Captured> captured = new CopyOnWriteArrayList<>();
Runnable deregister = tracker.addBatchedEventRelay(src, "parent",
100, 200L, // huge batch, short timer
(name, json) -> captured.add(new Captured(name, json)));
try {
tracker.broadcast(src, "tool_call_started", "{\"name\":\"a\"}");
tracker.broadcast(src, "tool_call_completed", "{\"name\":\"a\",\"ok\":true}");
// Wait for the scheduler to fire (200ms + slack).
assertTrue(waitUntil(() -> !captured.isEmpty(), 2_000),
"Time-driven flush expected within 2s");
assertEquals("delegation_batch", captured.get(0).name(),
"Time-driven flush must produce a delegation_batch envelope");
} finally {
deregister.run();
}
}
@Test
@DisplayName("Pass-through events fire immediately and preserve ordering")
void passThroughPreservesOrdering() {
ChatStreamTracker tracker = newTracker();
String src = "src-pass-through";
tracker.register(src);
List<Captured> captured = new CopyOnWriteArrayList<>();
Runnable deregister = tracker.addBatchedEventRelay(src, "parent",
100, 5_000L, // size and time thresholds both far away
(name, json) -> captured.add(new Captured(name, json)));
try {
// Two batchable events buffer up.
tracker.broadcast(src, "tool_call_started", "{\"name\":\"a\"}");
tracker.broadcast(src, "tool_call_completed", "{\"name\":\"a\",\"ok\":true}");
// Pass-through event: must flush prior buffer, then fire itself.
tracker.broadcast(src, "phase", "{\"phase\":\"reasoning\"}");
assertTrue(waitUntil(() -> captured.size() >= 2, 2_000));
// Order: delegation_batch (drained buffer) then phase.
assertEquals("delegation_batch", captured.get(0).name(),
"Pass-through must drain buffered events first");
assertEquals("phase", captured.get(1).name(),
"Pass-through event must follow the flushed batch");
} finally {
deregister.run();
}
}
@Test
@DisplayName("Deregister flushes any pending events before unsubscribing")
void deregisterFlushesPending() {
ChatStreamTracker tracker = newTracker();
String src = "src-shutdown";
tracker.register(src);
AtomicInteger sawBatch = new AtomicInteger(0);
Runnable deregister = tracker.addBatchedEventRelay(src, "parent",
100, 60_000L,
(name, json) -> {
if ("delegation_batch".equals(name)) sawBatch.incrementAndGet();
});
tracker.broadcast(src, "tool_call_started", "{}");
tracker.broadcast(src, "tool_call_completed", "{}");
deregister.run();
assertEquals(1, sawBatch.get(),
"Deregistration must drain pending events as one final batch");
}
}

View File

@ -0,0 +1,185 @@
package vip.mate.channel.web;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyEmitter;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.io.IOException;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.CopyOnWriteArrayList;
import static org.junit.jupiter.api.Assertions.*;
/**
* Verifies that {@link ChatStreamTracker#broadcastChunked} splits oversize
* payloads into ordered {@code tool_result_chunk} events with the documented
* envelope shape, and leaves small payloads intact.
*/
class ChatStreamTrackerChunkedBroadcastTest {
private ChatStreamTracker newTracker() {
return new ChatStreamTracker(new ObjectMapper());
}
/**
* Captures (eventName, jsonData) tuples by intercepting Spring's
* {@code send(SseEventBuilder)} path. The builder emits multiple
* "event:..." / "data:..." entries when rendered to a Set, so we walk
* the rendered set once and collect a single logical pair.
*/
private static final class CapturingEmitter extends SseEmitter {
final List<Map<String, String>> events = new CopyOnWriteArrayList<>();
CapturingEmitter() {
super(60_000L);
}
@Override
public void send(SseEventBuilder builder) throws IOException {
Set<ResponseBodyEmitter.DataWithMediaType> entries = builder.build();
// Spring renders the SSE event as:
// 1) header string: "event:<name>\ndata:" (note: data: prefix
// already attached, no payload yet)
// 2) the actual data object (here always a JSON string)
// 3) terminator string: "\n\n"
// We detect the header from prefix scanning, then take the next
// String entry as the payload. Anything else is ignored.
String name = null;
String payload = null;
boolean expectPayload = false;
for (ResponseBodyEmitter.DataWithMediaType d : entries) {
Object obj = d.getData();
if (!(obj instanceof String text)) continue;
if (text.contains("event:") && text.contains("data:")) {
int evStart = text.indexOf("event:") + "event:".length();
int evEnd = text.indexOf('\n', evStart);
if (evEnd < 0) evEnd = text.length();
name = text.substring(evStart, evEnd).trim();
expectPayload = true;
} else if (expectPayload && payload == null && !text.equals("\n\n")) {
payload = text;
expectPayload = false;
}
}
Map<String, String> entry = new LinkedHashMap<>();
entry.put("event", name != null ? name : "");
entry.put("data", payload != null ? payload : "");
events.add(entry);
}
}
@Test
@DisplayName("Small payload broadcasts as a single event unchanged")
void smallPayloadBroadcastsUnchanged() {
ChatStreamTracker tracker = newTracker();
String cid = "small-payload";
tracker.register(cid);
CapturingEmitter emitter = new CapturingEmitter();
tracker.attach(cid, emitter);
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("toolCallId", "call-1");
payload.put("toolName", "echo");
payload.put("result", "small text");
payload.put("success", true);
tracker.broadcastChunked(cid, "tool_call_completed", payload, "call-1");
long completedCount = emitter.events.stream()
.filter(e -> "tool_call_completed".equals(e.get("event"))).count();
long chunkCount = emitter.events.stream()
.filter(e -> "tool_result_chunk".equals(e.get("event"))).count();
assertEquals(1, completedCount, "Expected single tool_call_completed event");
assertEquals(0, chunkCount, "No chunk events for small payload");
}
@Test
@DisplayName("Large payload splits into ordered tool_result_chunk events with final flag")
void largePayloadChunksAndTerminates() throws Exception {
ChatStreamTracker tracker = newTracker();
String cid = "large-payload";
tracker.register(cid);
CapturingEmitter emitter = new CapturingEmitter();
tracker.attach(cid, emitter);
// Build a result well above CHUNK_SIZE (8192 bytes) so the splitter
// produces multiple chunks. 30 KiB ensures at least 4 splits even
// after envelope overhead.
StringBuilder big = new StringBuilder(30_000);
for (int i = 0; i < 3000; i++) {
big.append("0123456789");
}
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("toolCallId", "call-large");
payload.put("toolName", "shell");
payload.put("result", big.toString());
payload.put("success", true);
tracker.broadcastChunked(cid, "tool_call_completed", payload, "call-large");
// 1. Header event preserved with empty result + chunked=true.
List<Map<String, String>> completed = new ArrayList<>();
List<Map<String, String>> chunks = new ArrayList<>();
for (Map<String, String> e : emitter.events) {
if ("tool_call_completed".equals(e.get("event"))) completed.add(e);
else if ("tool_result_chunk".equals(e.get("event"))) chunks.add(e);
}
assertEquals(1, completed.size(), "Header event should fire exactly once");
assertTrue(chunks.size() >= 4,
"Expected several chunk events; got " + chunks.size());
ObjectMapper mapper = new ObjectMapper();
Map<?, ?> header = mapper.readValue(completed.get(0).get("data"), Map.class);
assertEquals(Boolean.TRUE, header.get("chunked"));
assertEquals("call-large", header.get("chunkRef"));
assertEquals("", header.get("result"),
"Header must replace long field with empty placeholder");
// 2. Chunk envelope: kind / scope / ref / seq monotonic / final on last.
StringBuilder reconstructed = new StringBuilder();
for (int i = 0; i < chunks.size(); i++) {
Map<?, ?> chunk = mapper.readValue(chunks.get(i).get("data"), Map.class);
assertEquals("tool_result", chunk.get("kind"));
assertEquals("parent", chunk.get("scope"));
assertEquals("call-large", chunk.get("ref"));
assertEquals(i, chunk.get("seq"), "Chunks must be in seq order");
boolean isLast = (i == chunks.size() - 1);
assertEquals(isLast, chunk.get("final"),
"Only the last chunk should set final=true");
reconstructed.append((String) chunk.get("delta"));
}
assertEquals(big.toString(), reconstructed.toString(),
"Concatenated chunks must reproduce the original result verbatim");
}
@Test
@DisplayName("Disabling chunked transport keeps single-event behavior")
void disabledChunkingIsPassThrough() {
ChatStreamTracker tracker = newTracker();
tracker.setChunkedToolResultsEnabled(false);
String cid = "disabled";
tracker.register(cid);
CapturingEmitter emitter = new CapturingEmitter();
tracker.attach(cid, emitter);
StringBuilder big = new StringBuilder(15_000);
for (int i = 0; i < 1500; i++) big.append("0123456789");
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("toolCallId", "call-x");
payload.put("toolName", "shell");
payload.put("result", big.toString());
payload.put("success", true);
tracker.broadcastChunked(cid, "tool_call_completed", payload, "call-x");
long chunkCount = emitter.events.stream()
.filter(e -> "tool_result_chunk".equals(e.get("event"))).count();
assertEquals(0, chunkCount, "Chunking disabled — must not emit chunk events");
}
}

View File

@ -0,0 +1,109 @@
package vip.mate.channel.web;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.http.server.ServletServerHttpResponse;
import org.springframework.mock.web.MockHttpServletResponse;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import static org.junit.jupiter.api.Assertions.*;
/**
* RFC-058 PR-1: ensure {@link Utf8SseEmitter} explicitly stamps
* {@code Content-Type: text/event-stream;charset=UTF-8} on the response.
*
* <p>Spring's default {@link org.springframework.web.servlet.mvc.method.annotation.SseEmitter}
* leaves the charset off, which on Windows / GBK locale Chrome and through
* certain reverse proxies leads to mojibake for Chinese characters.
*/
class Utf8SseEmitterTest {
@Test
@DisplayName("extendResponse stamps charset=UTF-8 when Content-Type is unset")
void stampsUtf8WhenContentTypeUnset() throws Exception {
Utf8SseEmitter emitter = new Utf8SseEmitter(10_000L);
MockHttpServletResponse servlet = new MockHttpServletResponse();
ServerHttpResponse response = new ServletServerHttpResponse(servlet);
invokeExtendResponse(emitter, response);
MediaType contentType = response.getHeaders().getContentType();
assertNotNull(contentType, "Content-Type must be set");
assertEquals("text", contentType.getType());
assertEquals("event-stream", contentType.getSubtype());
assertEquals(StandardCharsets.UTF_8, contentType.getCharset(),
"charset must be explicitly UTF-8 (not null)");
}
@Test
@DisplayName("extendResponse stamps charset=UTF-8 when Content-Type lacks charset")
void stampsUtf8WhenContentTypeMissingCharset() throws Exception {
Utf8SseEmitter emitter = new Utf8SseEmitter(10_000L);
MockHttpServletResponse servlet = new MockHttpServletResponse();
ServerHttpResponse response = new ServletServerHttpResponse(servlet);
// Simulate Spring default: text/event-stream WITHOUT charset
response.getHeaders().setContentType(MediaType.parseMediaType("text/event-stream"));
invokeExtendResponse(emitter, response);
MediaType contentType = response.getHeaders().getContentType();
assertNotNull(contentType.getCharset(), "charset must be filled in");
assertEquals(StandardCharsets.UTF_8, contentType.getCharset());
}
@Test
@DisplayName("extendResponse does NOT override an explicit non-UTF8 charset")
void doesNotClobberExplicitCharset() throws Exception {
Utf8SseEmitter emitter = new Utf8SseEmitter(10_000L);
MockHttpServletResponse servlet = new MockHttpServletResponse();
ServerHttpResponse response = new ServletServerHttpResponse(servlet);
// Caller explicitly chose ISO-8859-1 we must respect it
MediaType iso = new MediaType("text", "event-stream", StandardCharsets.ISO_8859_1);
response.getHeaders().setContentType(iso);
invokeExtendResponse(emitter, response);
MediaType contentType = response.getHeaders().getContentType();
assertEquals(StandardCharsets.ISO_8859_1, contentType.getCharset(),
"Explicit caller-set charset must not be overridden");
}
@Test
@DisplayName("Utf8SseEmitter constructor accepts timeout like SseEmitter")
void constructorAcceptsTimeout() {
Utf8SseEmitter emitter = new Utf8SseEmitter(60_000L);
assertEquals(60_000L, emitter.getTimeout());
}
@Test
@DisplayName("Default constructor works (no timeout)")
void defaultConstructorWorks() {
Utf8SseEmitter emitter = new Utf8SseEmitter();
assertNull(emitter.getTimeout(), "Default constructor leaves timeout null");
}
/**
* {@code extendResponse} is {@code protected} on the framework class.
* Reflection is the cleanest way to exercise it without spinning up a
* full DispatcherServlet for a one-line behavioural assertion.
*/
private static void invokeExtendResponse(Utf8SseEmitter emitter, ServerHttpResponse response)
throws Exception {
Method m = findExtendResponseMethod(emitter.getClass());
m.setAccessible(true);
m.invoke(emitter, response);
}
private static Method findExtendResponseMethod(Class<?> cls) throws NoSuchMethodException {
for (Class<?> c = cls; c != null; c = c.getSuperclass()) {
for (Method m : c.getDeclaredMethods()) {
if ("extendResponse".equals(m.getName())) return m;
}
}
throw new NoSuchMethodException("extendResponse not found on " + cls);
}
}

View File

@ -0,0 +1,205 @@
package vip.mate.channel.wecom;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import vip.mate.channel.ChannelMessageRouter;
import vip.mate.channel.model.ChannelEntity;
import vip.mate.channel.notification.ApprovalNotificationService;
import vip.mate.channel.wecom.cards.WeComCardDispatcher;
import vip.mate.workspace.conversation.model.MessageContentPart;
import java.lang.reflect.Method;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
/**
* Pin {@code msgtype=appmsg} parsing covers the four sub-variants
* users actually forward to bots in production: PDF / Word / Excel
* (file), image cards, miniprograms, and public-account article links.
*
* <p>Without this branch, every forwarded PDF / article / miniprogram
* fell into the inbound switch's default and got silently dropped.
* These tests pin (1) the text marker shape so prompts stay stable,
* (2) the attached-media routing for file and image variants, and
* (3) the link/miniprogram fallbacks so the agent at least knows
* something was shared.
*/
class AppmsgContentTest {
private WeComChannelAdapter adapter;
private Method extract;
@BeforeEach
void setUp() throws Exception {
ChannelEntity entity = new ChannelEntity();
entity.setId(1L);
entity.setChannelType("wecom");
entity.setConfigJson("{\"media_download_enabled\": false}");
adapter = new WeComChannelAdapter(
entity,
Mockito.mock(ChannelMessageRouter.class),
new ObjectMapper(),
Mockito.mock(ApprovalNotificationService.class),
Mockito.mock(WeComCardDispatcher.class),
Mockito.mock(WeComKeepaliveScheduler.class));
extract = WeComChannelAdapter.class.getDeclaredMethod(
"extractAppmsgContent",
Map.class, String.class, String.class, String.class, String.class);
extract.setAccessible(true);
}
@SuppressWarnings("unchecked")
private Object invoke(Map<String, Object> body) throws Exception {
return extract.invoke(adapter, body, "msg-1", "alice", "alice", "single");
}
private String text(Object ctx) throws Exception {
return (String) ctx.getClass().getMethod("text").invoke(ctx);
}
@SuppressWarnings("unchecked")
private List<MessageContentPart> parts(Object ctx) throws Exception {
return (List<MessageContentPart>) ctx.getClass().getMethod("attachedParts").invoke(ctx);
}
@Test
@DisplayName("appmsg.file → file content part + [文件: filename] marker")
void fileVariant() throws Exception {
Object ctx = invoke(Map.of("appmsg", Map.of(
"title", "report.pdf",
"file", Map.of(
"url", "https://example.com/report.pdf",
"aeskey", "k",
"filename", "report.pdf"))));
assertEquals("[文件: report.pdf]", text(ctx));
assertEquals(1, parts(ctx).size());
assertEquals("file", parts(ctx).get(0).getType());
}
@Test
@DisplayName("appmsg.image → image content part + [图片: title] marker")
void imageVariant() throws Exception {
Object ctx = invoke(Map.of("appmsg", Map.of(
"title", "周末聚会",
"image", Map.of(
"url", "https://example.com/photo.jpg",
"aeskey", "k"))));
assertEquals("[图片: 周末聚会]", text(ctx));
assertEquals(1, parts(ctx).size());
assertEquals("image", parts(ctx).get(0).getType());
}
@Test
@DisplayName("appmsg.image with no title → bare [图片] marker")
void imageVariantNoTitle() throws Exception {
Object ctx = invoke(Map.of("appmsg", Map.of(
"image", Map.of(
"url", "https://example.com/p.jpg",
"aeskey", "k"))));
assertEquals("[图片]", text(ctx));
assertEquals(1, parts(ctx).size());
}
@Test
@DisplayName("appmsg.miniprogram → [小程序: title] marker, no attached media")
void miniprogramVariant() throws Exception {
Object ctx = invoke(Map.of("appmsg", Map.of(
"title", "外卖小程序",
"miniprogram", Map.of("title", "美团外卖"))));
assertEquals("[小程序: 美团外卖]", text(ctx));
assertTrue(parts(ctx).isEmpty());
}
@Test
@DisplayName("appmsg.miniprogram with no inner title falls back to top-level title")
void miniprogramTitleFallback() throws Exception {
Object ctx = invoke(Map.of("appmsg", Map.of(
"title", "顶层标题",
"miniprogram", Map.of())));
assertEquals("[小程序: 顶层标题]", text(ctx));
}
@Test
@DisplayName("appmsg.url (public-account article) → [链接] + title + desc + url multi-line + paste-body hint")
void linkVariant() throws Exception {
Object ctx = invoke(Map.of("appmsg", Map.of(
"title", "深度好文AI 的未来",
"description", "本文探讨 AI 在企业的落地路径",
"url", "https://mp.weixin.qq.com/s/abc123")));
String t = text(ctx);
assertTrue(t.startsWith("[链接] 深度好文AI 的未来"),
"title should follow [链接] tag; got: " + t);
assertTrue(t.contains("本文探讨 AI 在企业的落地路径"),
"description must be present; got: " + t);
assertTrue(t.contains("https://mp.weixin.qq.com/s/abc123"),
"URL must be in the text so agent can reference it; got: " + t);
// Public-account body is captcha-gated agent must be told not to
// hallucinate content from the title.
assertTrue(t.contains("公众号文章"),
"public-account article hint must be appended; got: " + t);
assertTrue(t.contains("不要凭标题猜测内容"),
"directive against title-only guessing must be present; got: " + t);
assertTrue(parts(ctx).isEmpty(), "link variant produces no attached media");
}
@Test
@DisplayName("non-public-account links (regular URLs) do NOT get the paste-body hint")
void linkVariantNonWeixinUrlNoHint() throws Exception {
// Generic web links don't have the captcha-gate problem fetching
// the body via a tool is straightforward, so adding the hint would
// be misleading.
Object ctx = invoke(Map.of("appmsg", Map.of(
"title", "GitHub README",
"url", "https://github.com/example/repo")));
String t = text(ctx);
assertTrue(t.contains("https://github.com/example/repo"));
assertFalse(t.contains("公众号文章"),
"non-mp.weixin.qq.com URLs must not trigger the public-account hint; got: " + t);
}
@Test
@DisplayName("link with title only, no description")
void linkVariantNoDesc() throws Exception {
Object ctx = invoke(Map.of("appmsg", Map.of(
"title", "标题",
"url", "https://example.com")));
String t = text(ctx);
assertTrue(t.contains("[链接] 标题"));
assertTrue(t.contains("https://example.com"));
}
@Test
@DisplayName("unknown appmsg variant with title → [appmsg: title] marker")
void unknownVariantWithTitle() throws Exception {
Object ctx = invoke(Map.of("appmsg", Map.of(
"title", "未知卡片",
"weird_field", Map.of())));
assertEquals("[appmsg: 未知卡片]", text(ctx));
}
@Test
@DisplayName("totally empty appmsg → bare [appmsg] marker (agent at least knows something arrived)")
void emptyAppmsg() throws Exception {
Object ctx = invoke(Map.of("appmsg", Map.of()));
assertEquals("[appmsg]", text(ctx));
assertTrue(parts(ctx).isEmpty());
}
@Test
@DisplayName("file variant uses appmsg.title as filename when file.filename missing")
void fileFilenameFallback() throws Exception {
Object ctx = invoke(Map.of("appmsg", Map.of(
"title", "周报.docx",
"file", Map.of(
"url", "https://example.com/x",
"aeskey", "k"))));
// filename comes from title since file.filename is absent
assertTrue(text(ctx).contains("周报.docx"),
"marker should carry the title as filename; got: " + text(ctx));
}
}

View File

@ -0,0 +1,109 @@
package vip.mate.channel.wecom;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import vip.mate.channel.ChannelMessageRouter;
import vip.mate.channel.model.ChannelEntity;
import vip.mate.channel.notification.ApprovalNotificationService;
import vip.mate.channel.wecom.cards.WeComCardDispatcher;
import java.lang.reflect.Method;
import java.util.concurrent.ConcurrentHashMap;
import static org.junit.jupiter.api.Assertions.*;
/**
* Pin the group-chat reply-slot cache contract. WeCom AI Bot platform
* blocks {@code aibot_send_msg} in group chats proactive pushes (cron
* summaries, async-task forwards, image-generation completions) must
* ride {@code aibot_respond_msg} bound to a prior frame's reqId.
*
* <p>Without this cache, any group push silently failed: the test rig
* here exercises the cache plumbing directly so future changes to the
* cache eviction strategy or the lookup helper don't regress group
* delivery semantics.
*/
class GroupReplyReqIdCacheTest {
private WeComChannelAdapter adapter;
@BeforeEach
void setUp() {
ChannelEntity entity = new ChannelEntity();
entity.setId(1L);
entity.setChannelType("wecom");
entity.setConfigJson("{}");
adapter = new WeComChannelAdapter(
entity,
Mockito.mock(ChannelMessageRouter.class),
new ObjectMapper(),
Mockito.mock(ApprovalNotificationService.class),
Mockito.mock(WeComCardDispatcher.class),
Mockito.mock(WeComKeepaliveScheduler.class));
}
@Test
@DisplayName("unknown chatId yields null — single chats fall through to aibot_send_msg")
void unknownChatYieldsNull() {
assertNull(adapter.pickGroupReplyReqId("never-seen-chat"));
assertNull(adapter.pickGroupReplyReqId(""));
assertNull(adapter.pickGroupReplyReqId(null));
}
@Test
@DisplayName("remembered group reqId is returned by the lookup")
void rememberAndLookup() throws Exception {
Method remember = WeComChannelAdapter.class.getDeclaredMethod(
"rememberGroupReplyReqId", String.class, String.class);
remember.setAccessible(true);
remember.invoke(adapter, "group-1", "req-aaa");
assertEquals("req-aaa", adapter.pickGroupReplyReqId("group-1"));
// Most-recent semantics: a newer reqId for the same group overwrites.
remember.invoke(adapter, "group-1", "req-bbb");
assertEquals("req-bbb", adapter.pickGroupReplyReqId("group-1"));
}
@Test
@DisplayName("cache stays bounded under flood — no unbounded growth")
void cacheBounded() throws Exception {
Method remember = WeComChannelAdapter.class.getDeclaredMethod(
"rememberGroupReplyReqId", String.class, String.class);
remember.setAccessible(true);
// Exceed the 1000-entry max with 1500 distinct groups.
for (int i = 0; i < 1500; i++) {
remember.invoke(adapter, "group-" + i, "req-" + i);
}
// Inspect the underlying cache size via reflection.
java.lang.reflect.Field f = WeComChannelAdapter.class.getDeclaredField("lastChatReqIds");
f.setAccessible(true);
@SuppressWarnings("unchecked")
ConcurrentHashMap<String, String> map = (ConcurrentHashMap<String, String>) f.get(adapter);
assertTrue(map.size() <= 1000,
"cache must not grow beyond LAST_CHAT_REQ_IDS_MAX_SIZE; got " + map.size());
}
@Test
@DisplayName("each group gets independent reqId tracking — no cross-group leakage")
void independentPerGroup() throws Exception {
Method remember = WeComChannelAdapter.class.getDeclaredMethod(
"rememberGroupReplyReqId", String.class, String.class);
remember.setAccessible(true);
remember.invoke(adapter, "group-A", "req-A1");
remember.invoke(adapter, "group-B", "req-B1");
assertEquals("req-A1", adapter.pickGroupReplyReqId("group-A"));
assertEquals("req-B1", adapter.pickGroupReplyReqId("group-B"));
// Updating one doesn't affect the other.
remember.invoke(adapter, "group-A", "req-A2");
assertEquals("req-A2", adapter.pickGroupReplyReqId("group-A"));
assertEquals("req-B1", adapter.pickGroupReplyReqId("group-B"));
}
}

View File

@ -0,0 +1,171 @@
package vip.mate.channel.wecom;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import vip.mate.channel.ChannelMessageRouter;
import vip.mate.channel.model.ChannelEntity;
import vip.mate.channel.notification.ApprovalNotificationService;
import vip.mate.channel.wecom.cards.WeComCardDispatcher;
import vip.mate.workspace.conversation.model.MessageContentPart;
import java.lang.reflect.Method;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
/**
* Unit tests for {@code WeComChannelAdapter.extractQuoteContext} the
* inbound quote-message parser that converts WeCom's {@code body.quote}
* field into a prefix string + attached media parts the agent can read.
*
* <p>Quoted-message context is the most common reason agent replies "go
* off-topic" on IM: the user long-presses a previous bubble, types a
* follow-up like "解释一下", and assumes the agent sees both. Without
* this parser the agent only saw the new text and silently lost the
* referenced content.
*
* <p>These tests pin (1) the prefix string shape so prompts stay stable
* across releases, (2) flattening rules for {@code mixed} quotes, and
* (3) the empty-result contract (null when nothing useful to extract)
* so the caller can treat null as "no quote context".
*/
class QuoteContextTest {
private WeComChannelAdapter adapter;
private Method extract;
@BeforeEach
void setUp() throws Exception {
ChannelEntity entity = new ChannelEntity();
entity.setId(1L);
entity.setChannelType("wecom");
entity.setConfigJson("{\"media_download_enabled\": false}"); // skip real downloads
adapter = new WeComChannelAdapter(
entity,
Mockito.mock(ChannelMessageRouter.class),
new ObjectMapper(),
Mockito.mock(ApprovalNotificationService.class),
Mockito.mock(WeComCardDispatcher.class),
Mockito.mock(WeComKeepaliveScheduler.class));
extract = WeComChannelAdapter.class.getDeclaredMethod(
"extractQuoteContext",
Map.class, String.class, String.class, String.class, String.class);
extract.setAccessible(true);
}
private Object invoke(Map<String, Object> body) throws Exception {
return extract.invoke(adapter, body, "msg-1", "alice", "alice", "single");
}
@Test
@DisplayName("missing quote field returns null")
void noQuote() throws Exception {
assertNull(invoke(Map.of()));
assertNull(invoke(Map.of("text", Map.of("content", "hi"))));
}
@Test
@DisplayName("blank msgtype returns null (defensive)")
void blankQuoteType() throws Exception {
assertNull(invoke(Map.of("quote", Map.of("msgtype", ""))));
}
@Test
@DisplayName("text quote produces a [引用消息: ...] prefix and no attached parts")
void textQuote() throws Exception {
Object ctx = invoke(Map.of("quote", Map.of(
"msgtype", "text",
"text", Map.of("content", "你好图片是什么意思"))));
assertNotNull(ctx);
// QuoteContext is a private record exercise via reflection on accessor methods.
String prefix = (String) ctx.getClass().getMethod("prefix").invoke(ctx);
@SuppressWarnings("unchecked")
List<MessageContentPart> parts = (List<MessageContentPart>)
ctx.getClass().getMethod("attachedParts").invoke(ctx);
assertEquals("[引用消息: 你好图片是什么意思]\n", prefix);
assertTrue(parts.isEmpty(), "text-only quote attaches no media");
}
@Test
@DisplayName("image quote attaches a part and notes [图片] in prefix")
void imageQuote() throws Exception {
Object ctx = invoke(Map.of("quote", Map.of(
"msgtype", "image",
"image", Map.of(
"url", "https://example.com/x.jpg",
"aeskey", "k"))));
assertNotNull(ctx);
String prefix = (String) ctx.getClass().getMethod("prefix").invoke(ctx);
@SuppressWarnings("unchecked")
List<MessageContentPart> parts = (List<MessageContentPart>)
ctx.getClass().getMethod("attachedParts").invoke(ctx);
assertEquals("[引用消息: [图片]]\n", prefix);
assertEquals(1, parts.size());
assertEquals("image", parts.get(0).getType());
}
@Test
@DisplayName("file quote uses the original filename in the prefix")
void fileQuote() throws Exception {
Object ctx = invoke(Map.of("quote", Map.of(
"msgtype", "file",
"file", Map.of(
"url", "https://example.com/x.pdf",
"filename", "report.pdf"))));
String prefix = (String) ctx.getClass().getMethod("prefix").invoke(ctx);
@SuppressWarnings("unchecked")
List<MessageContentPart> parts = (List<MessageContentPart>)
ctx.getClass().getMethod("attachedParts").invoke(ctx);
assertEquals("[引用消息: [文件: report.pdf]]\n", prefix);
assertEquals(1, parts.size());
assertEquals("file", parts.get(0).getType());
}
@Test
@DisplayName("voice quote with ASR text gets surfaced; without ASR shows [语音消息]")
void voiceQuote() throws Exception {
Object withAsr = invoke(Map.of("quote", Map.of(
"msgtype", "voice",
"voice", Map.of("content", "明天开会"))));
assertEquals("[引用消息: [语音] 明天开会]\n",
withAsr.getClass().getMethod("prefix").invoke(withAsr));
Object empty = invoke(Map.of("quote", Map.of(
"msgtype", "voice",
"voice", Map.of("content", ""))));
assertEquals("[引用消息: [语音消息]]\n",
empty.getClass().getMethod("prefix").invoke(empty));
}
@Test
@DisplayName("mixed quote flattens to a space-joined summary and merges attached parts")
void mixedQuote() throws Exception {
Object ctx = invoke(Map.of("quote", Map.of(
"msgtype", "mixed",
"mixed", Map.of("msg_item", List.of(
Map.of("msgtype", "text", "text", Map.of("content", "看这张图")),
Map.of("msgtype", "image", "image", Map.of(
"url", "https://example.com/y.jpg",
"aeskey", "k")))))));
String prefix = (String) ctx.getClass().getMethod("prefix").invoke(ctx);
@SuppressWarnings("unchecked")
List<MessageContentPart> parts = (List<MessageContentPart>)
ctx.getClass().getMethod("attachedParts").invoke(ctx);
assertEquals("[引用消息: 看这张图 [图片]]\n", prefix);
assertEquals(1, parts.size(), "mixed image gets attached as a media part");
}
@Test
@DisplayName("unknown quote sub-type still produces a [<type>] tag (informative, not silent)")
void unknownQuoteType() throws Exception {
Object ctx = invoke(Map.of("quote", Map.of(
"msgtype", "appmsg",
"appmsg", Map.of("title", "some link"))));
String prefix = (String) ctx.getClass().getMethod("prefix").invoke(ctx);
assertEquals("[引用消息: [appmsg]]\n", prefix);
}
}

View File

@ -0,0 +1,542 @@
package vip.mate.channel.wecom;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import vip.mate.channel.ChannelMessageRouter;
import vip.mate.channel.model.ChannelEntity;
import vip.mate.channel.notification.ApprovalNotificationService;
import vip.mate.channel.wecom.cards.WeComCardDispatcher;
import java.io.IOException;
import java.lang.reflect.Field;
import java.net.http.WebSocket;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Function;
import static org.junit.jupiter.api.Assertions.*;
/**
* RFC-32 §3.0 PR-0 stress catalog six tests covering every concurrency
* race the v2.0~v2.5.1 review chain identified.
*
* <p>Each test runs against a {@link TestableAdapter} that overrides
* {@code sendFrame} so no real WebSocket is touched. Other state
* (running flag, lifecycle gate, pendingAcks map) is poked via
* reflection keeping production-code visibility tweaks to a minimum
* (just {@code workerIdleTimeoutMs} and dropping {@code private} from
* {@code sendFrame}).
*
* <p>None of these tests sleep more than ~3s total even at high
* iteration counts, so they're safe to run in regular CI rather than
* a separate stress-only profile.
*/
class ReplyQueueStressTest {
private TestableAdapter adapter;
private ObjectMapper mapper;
@BeforeEach
void setUp() throws Exception {
ChannelEntity entity = new ChannelEntity();
entity.setId(1L);
entity.setName("test-wecom");
entity.setChannelType("wecom");
entity.setConfigJson("{}");
ChannelMessageRouter router = Mockito.mock(ChannelMessageRouter.class);
ApprovalNotificationService approvalSvc = Mockito.mock(ApprovalNotificationService.class);
WeComCardDispatcher cardDispatcher = Mockito.mock(WeComCardDispatcher.class);
WeComKeepaliveScheduler keepalive = Mockito.mock(WeComKeepaliveScheduler.class);
mapper = new ObjectMapper();
adapter = new TestableAdapter(entity, router, mapper, approvalSvc, cardDispatcher, keepalive);
// Manually bring the adapter to a "running and ready" state without
// doing a real WS handshake. This is what doStart + connectWebSocket +
// markReady would have produced on a live system.
setRunning(adapter, true);
invokePrivate(adapter, "ensureReplyExecutor");
invokePrivate(adapter, "openReplyQueue");
// Most tests run with a much shorter idle timeout so the worker's
// 60-second poll doesn't dominate test wall-clock time.
adapter.workerIdleTimeoutMs = 80;
}
@AfterEach
void tearDown() throws Exception {
// Belt-and-suspenders cleanup: even if an assertion failed, drop
// the executor so dangling worker threads don't bleed into the
// next test.
try {
invokePrivate(adapter, "releaseConnectionResources", new Class<?>[]{String.class}, "test-teardown");
} catch (Exception ignored) {}
setRunning(adapter, false);
}
// =====================================================================
// S-1: same reqId serial dispatch
// =====================================================================
@Nested
@DisplayName("S-1 same reqId serial dispatch")
class S1_SerialDispatch {
@Test
@DisplayName("three frames on same reqId: only one in flight at a time")
void serialPerReqId() throws Exception {
String reqId = "req_s1";
// Don't auto-ACK; tests will release ACKs one by one.
adapter.autoAck = false;
CompletableFuture<Map<String, Object>> f1 = adapter.callSendFrameWithAck(reqId, frame(reqId, "msg1"));
CompletableFuture<Map<String, Object>> f2 = adapter.callSendFrameWithAck(reqId, frame(reqId, "msg2"));
CompletableFuture<Map<String, Object>> f3 = adapter.callSendFrameWithAck(reqId, frame(reqId, "msg3"));
// Worker thread starts asynchronously give it a tick to dequeue
// the first task and dispatch sendFrame.
assertEquals("msg1", awaitFrameText(adapter, 500),
"first frame must dispatch within 500ms");
// No further frame may dispatch until the first ACK arrives.
// Sleep ~150ms ( 2x adapter.workerIdleTimeoutMs) and assert
// the queue stayed empty.
Thread.sleep(150);
assertNull(adapter.sentFrames.poll(), "second frame must NOT dispatch before first ACK");
// Release ACK 1 frame 2 should now dispatch.
completeAck(adapter, reqId);
assertEquals("msg2", awaitFrameText(adapter, 500));
Thread.sleep(150);
assertNull(adapter.sentFrames.poll(), "third frame must NOT dispatch before second ACK");
completeAck(adapter, reqId);
assertEquals("msg3", awaitFrameText(adapter, 500));
completeAck(adapter, reqId);
// All three futures should now complete successfully.
assertNotNull(f1.get(500, TimeUnit.MILLISECONDS));
assertNotNull(f2.get(500, TimeUnit.MILLISECONDS));
assertNotNull(f3.get(500, TimeUnit.MILLISECONDS));
}
}
// =====================================================================
// S-2: sendFrame sync throw future fails immediately
// =====================================================================
@Nested
@DisplayName("S-2 sendFrame sync throw → future fails fast")
class S2_SendFrameThrow {
@Test
@DisplayName("future fails within 200ms on IOException, not the 5s ACK timeout")
void syncThrowFailsFast() throws Exception {
adapter.sendFrameBehavior = frame -> {
throw new RuntimeException("simulated ws sendText failure", new IOException("ws null"));
};
long t0 = System.nanoTime();
CompletableFuture<Map<String, Object>> future =
adapter.callSendFrameWithAck("req_s2", frame("req_s2", "x"));
ExecutionException ex = assertThrows(ExecutionException.class,
() -> future.get(500, TimeUnit.MILLISECONDS),
"future must complete (exceptionally) within 500ms");
long elapsedMs = (System.nanoTime() - t0) / 1_000_000;
assertTrue(elapsedMs < 200,
"should fail-fast in under 200ms, took " + elapsedMs + "ms");
assertNotNull(ex.getCause());
}
}
// =====================================================================
// S-3: idle-close vs late-enqueue race × many iterations × many threads
// =====================================================================
@Nested
@DisplayName("S-3 worker idle-close vs late enqueue: no orphans across N iterations")
class S3_IdleRace {
@Test
@DisplayName("100 iterations × 8 threads: every offered task completes")
void noOrphansUnderRace() throws Exception {
// Tighten idle timeout to 30ms so each iteration cycles through
// open busy idle-close in the low-100ms range.
adapter.workerIdleTimeoutMs = 30;
adapter.autoAck = true; // ACK as soon as worker dispatches
int threads = 8;
int iterationsPerThread = 100;
ExecutorService pool = Executors.newFixedThreadPool(threads);
CountDownLatch latch = new CountDownLatch(1);
ConcurrentLinkedQueue<CompletableFuture<Map<String, Object>>> all =
new ConcurrentLinkedQueue<>();
for (int t = 0; t < threads; t++) {
final int tid = t;
pool.submit(() -> {
try {
latch.await();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
for (int i = 0; i < iterationsPerThread; i++) {
// Mix reqIds: some shared (forces worker reuse), some
// unique (forces fresh-state path).
String reqId = (i % 3 == 0)
? "shared_req"
: "t" + tid + "_i" + i;
all.add(adapter.callSendFrameWithAck(reqId, frame(reqId, "p")));
// Random tiny delay so worker idle-close has a chance
// to interleave with late offers.
if (i % 10 == 0) {
try { Thread.sleep(35); } catch (InterruptedException ie) { return; }
}
}
});
}
latch.countDown();
pool.shutdown();
assertTrue(pool.awaitTermination(20, TimeUnit.SECONDS), "submission threads must finish");
// Every offered future must eventually complete. 5s budget for the
// worker(s) to drain. Track failures with reasons for debuggability.
int total = all.size();
int orphans = 0;
int succeeded = 0;
int failed = 0;
long deadline = System.currentTimeMillis() + 5_000;
for (CompletableFuture<Map<String, Object>> f : all) {
long remaining = Math.max(0, deadline - System.currentTimeMillis());
try {
f.get(remaining, TimeUnit.MILLISECONDS);
succeeded++;
} catch (TimeoutException te) {
orphans++;
} catch (Exception e) {
// ExecutionException or interrupt counted as completed
// (test only cares that no future hangs forever).
failed++;
}
}
assertEquals(0, orphans,
"no future may remain pending after the queue drains; " +
"total=" + total + " ok=" + succeeded + " err=" + failed +
" orphans=" + orphans);
}
}
// =====================================================================
// S-4: release in progress all enqueues fast-fail
// =====================================================================
@Nested
@DisplayName("S-4 release window: enqueues fail fast, no orphans")
class S4_ReleaseRace {
@Test
@DisplayName("100 concurrent enqueues during release: all complete in <1s")
void releaseFailsFast() throws Exception {
// Spawn 100 concurrent enqueues. Halfway through, trigger
// releaseConnectionResources on a separate thread.
int N = 100;
ExecutorService pool = Executors.newFixedThreadPool(16);
CountDownLatch start = new CountDownLatch(1);
ConcurrentLinkedQueue<CompletableFuture<Map<String, Object>>> futures =
new ConcurrentLinkedQueue<>();
for (int i = 0; i < N; i++) {
final int idx = i;
pool.submit(() -> {
try { start.await(); } catch (InterruptedException ignored) {}
futures.add(adapter.callSendFrameWithAck("req_s4_" + idx, frame("req_s4_" + idx, "p")));
});
}
// Trigger release shortly after enqueue burst begins.
pool.submit(() -> {
try { start.await(); } catch (InterruptedException ignored) {}
try {
Thread.sleep(5); // a small lead so some enqueues land first
invokePrivate(adapter, "releaseConnectionResources",
new Class<?>[]{String.class}, "s4-test");
} catch (Exception ignored) {}
});
start.countDown();
pool.shutdown();
assertTrue(pool.awaitTermination(5, TimeUnit.SECONDS));
// Every future must complete in <1s either success (offered
// before gate closed and worker drained) or IllegalStateException
// (gate closed by release).
long t0 = System.nanoTime();
int hangs = 0;
for (CompletableFuture<Map<String, Object>> f : futures) {
try {
f.get(1_000, TimeUnit.MILLISECONDS);
} catch (TimeoutException te) {
hangs++;
} catch (Exception ignored) {}
}
long elapsedMs = (System.nanoTime() - t0) / 1_000_000;
assertEquals(0, hangs, hangs + " future(s) hung during release window");
assertTrue(elapsedMs < 2_000,
"all " + futures.size() + " futures should resolve in <2s, took " + elapsedMs + "ms");
}
}
// =====================================================================
// S-5: executor ready but markReady not called fast-fail
// =====================================================================
@Nested
@DisplayName("S-5 lifecycle gate: enqueue before markReady fails fast")
class S5_GateClosed {
@Test
@DisplayName("with executor present but accepting=false, enqueue returns failed future immediately")
void closedGateFailsFast() throws Exception {
// Force the lifecycle into "executor ready, transport not ready"
// (the exact window R-7 covers).
adapter.workerIdleTimeoutMs = 60_000; // restore to default we don't want the worker pool churning
// Take the gate down without going through release.
Field gate = WeComChannelAdapter.class.getDeclaredField("replyQueueAccepting");
gate.setAccessible(true);
((AtomicBoolean) gate.get(adapter)).set(false);
long t0 = System.nanoTime();
CompletableFuture<Map<String, Object>> f =
adapter.callSendFrameWithAck("req_s5", frame("req_s5", "x"));
ExecutionException ex = assertThrows(ExecutionException.class,
() -> f.get(200, TimeUnit.MILLISECONDS));
long elapsedMs = (System.nanoTime() - t0) / 1_000_000;
assertTrue(elapsedMs < 100,
"fast-fail should be near-instant (sync resolution), took " + elapsedMs + "ms");
assertInstanceOf(IllegalStateException.class, ex.getCause(),
"must surface the gate-closed reason as IllegalStateException");
assertTrue(ex.getCause().getMessage().contains("not accepting"),
"error message must mention 'not accepting'; got: " + ex.getCause().getMessage());
// No frame should ever have been queued.
assertNull(adapter.sentFrames.poll(),
"sendFrame must not be invoked when gate is closed");
}
}
// =====================================================================
// S-6: release ordering accepting=false happens-before ws.close()
// =====================================================================
@Nested
@DisplayName("S-6 release ordering: accepting flips first")
class S6_ReleaseOrdering {
@Test
@DisplayName("when ws.sendClose runs, replyQueueAccepting is already false")
void acceptingFalseBeforeWsClose() throws Exception {
// Install an instrumented WebSocket that records the gate value
// at the moment sendClose() is invoked.
AtomicBoolean acceptingAtCloseTime = new AtomicBoolean(true);
AtomicBoolean closeWasCalled = new AtomicBoolean(false);
WebSocket fakeWs = (WebSocket) java.lang.reflect.Proxy.newProxyInstance(
WebSocket.class.getClassLoader(),
new Class<?>[]{WebSocket.class},
(proxy, method, args) -> {
if ("sendClose".equals(method.getName())) {
// Snapshot gate state at the exact moment release
// is calling close on us. The S-6 invariant:
// step 0 must have already flipped accepting.
Field gate = WeComChannelAdapter.class.getDeclaredField("replyQueueAccepting");
gate.setAccessible(true);
acceptingAtCloseTime.set(((AtomicBoolean) gate.get(adapter)).get());
closeWasCalled.set(true);
return CompletableFuture.completedFuture(proxy);
}
if (method.getReturnType() == boolean.class) return false;
if (method.getReturnType() == long.class) return 0L;
return null;
});
// Inject the fake into the adapter and verify accepting is true
// (i.e. we're in normal operation about to release).
Field wsField = WeComChannelAdapter.class.getDeclaredField("webSocket");
wsField.setAccessible(true);
wsField.set(adapter, fakeWs);
Field gate = WeComChannelAdapter.class.getDeclaredField("replyQueueAccepting");
gate.setAccessible(true);
assertTrue(((AtomicBoolean) gate.get(adapter)).get(),
"precondition: accepting must be true before release");
invokePrivate(adapter, "releaseConnectionResources",
new Class<?>[]{String.class}, "s6-test");
assertTrue(closeWasCalled.get(), "release must invoke ws.sendClose");
assertFalse(acceptingAtCloseTime.get(),
"step 0 (accepting=false) must happen-before ws.sendClose; " +
"if this fails, the release method body has been re-ordered " +
"and an enqueue could land between accepting and ws teardown");
}
}
// =====================================================================
// Helpers
// =====================================================================
/** Build the canonical aibot_respond_msg frame the adapter uses. */
private static Map<String, Object> frame(String reqId, String text) {
return Map.of(
"cmd", "aibot_respond_msg",
"headers", Map.of("req_id", reqId),
"body", Map.of("msgtype", "text", "text", Map.of("content", text))
);
}
/** Read the most-recent dispatched frame's text content. Polls up to {@code timeoutMs}. */
private static String awaitFrameText(TestableAdapter a, long timeoutMs) throws Exception {
Map<String, Object> f = a.sentFrames.poll(timeoutMs, TimeUnit.MILLISECONDS);
assertNotNull(f, "no frame dispatched within " + timeoutMs + "ms");
@SuppressWarnings("unchecked")
Map<String, Object> body = (Map<String, Object>) f.get("body");
@SuppressWarnings("unchecked")
Map<String, Object> txt = (Map<String, Object>) body.get("text");
return (String) txt.get("content");
}
/** Complete the in-flight ACK future for the given reqId. Returns true if found. */
@SuppressWarnings({"unchecked", "rawtypes"})
private static boolean completeAck(WeComChannelAdapter a, String reqId) throws Exception {
Field f = WeComChannelAdapter.class.getDeclaredField("pendingAcks");
f.setAccessible(true);
ConcurrentHashMap<String, CompletableFuture> map =
(ConcurrentHashMap<String, CompletableFuture>) f.get(a);
// Wait briefly for the worker to register the future before completing.
long deadline = System.currentTimeMillis() + 500;
CompletableFuture future = null;
while (System.currentTimeMillis() < deadline) {
future = map.get(reqId);
if (future != null) break;
Thread.sleep(5);
}
if (future == null) return false;
future.complete(Map.of("errcode", 0));
return true;
}
private static void setRunning(WeComChannelAdapter a, boolean v) throws Exception {
// running lives on AbstractChannelAdapter; walk the class chain to find it.
Field running = findField(a.getClass(), "running");
((AtomicBoolean) running.get(a)).set(v);
}
private static Field findField(Class<?> cls, String name) throws NoSuchFieldException {
for (Class<?> c = cls; c != null; c = c.getSuperclass()) {
try {
Field f = c.getDeclaredField(name);
f.setAccessible(true);
return f;
} catch (NoSuchFieldException ignored) {
// keep walking
}
}
throw new NoSuchFieldException(name + " not found in class chain rooted at " + cls);
}
private static Object invokePrivate(WeComChannelAdapter a, String method) throws Exception {
return invokePrivate(a, method, new Class<?>[0]);
}
private static Object invokePrivate(WeComChannelAdapter a, String method,
Class<?>[] paramTypes, Object... args) throws Exception {
var m = WeComChannelAdapter.class.getDeclaredMethod(method, paramTypes);
m.setAccessible(true);
return m.invoke(a, args);
}
/**
* Test-only adapter that captures dispatched frames and lets each
* test choose between auto-ACK or manual ACK release.
*/
static class TestableAdapter extends WeComChannelAdapter {
final LinkedBlockingQueue<Map<String, Object>> sentFrames = new LinkedBlockingQueue<>();
/** When false, tests must manually call {@code completeAck}. */
volatile boolean autoAck = true;
/** Optional behavior injected per-test (return value ignored; thrown exceptions propagate). */
volatile Function<Map<String, Object>, Void> sendFrameBehavior = null;
TestableAdapter(ChannelEntity entity, ChannelMessageRouter router,
ObjectMapper mapper, ApprovalNotificationService approvalSvc,
WeComCardDispatcher cardDispatcher, WeComKeepaliveScheduler keepalive) {
super(entity, router, mapper, approvalSvc, cardDispatcher, keepalive);
}
@Override
void sendFrame(Map<String, Object> frame) {
sentFrames.offer(frame);
Function<Map<String, Object>, Void> beh = sendFrameBehavior;
if (beh != null) {
beh.apply(frame); // may throw
return;
}
if (autoAck) {
String reqId = extractReqId(frame);
if (reqId != null) {
// Schedule async ACK on a tiny delay so the worker has time
// to register the future before we complete it.
AUTOACK.submit(() -> {
try {
Thread.sleep(2);
completeAck(this, reqId);
} catch (Exception ignored) {}
});
}
}
}
/** Expose package-private sendFrameWithAck to tests. */
CompletableFuture<Map<String, Object>> callSendFrameWithAck(String reqId, Map<String, Object> frame) {
try {
var m = WeComChannelAdapter.class.getDeclaredMethod("sendFrameWithAck", String.class, Map.class);
m.setAccessible(true);
@SuppressWarnings("unchecked")
CompletableFuture<Map<String, Object>> f =
(CompletableFuture<Map<String, Object>>) m.invoke(this, reqId, frame);
return f;
} catch (Exception e) {
throw new RuntimeException(e);
}
}
@SuppressWarnings("unchecked")
private static String extractReqId(Map<String, Object> frame) {
Map<String, Object> headers = (Map<String, Object>) frame.get("headers");
return headers == null ? null : (String) headers.get("req_id");
}
private static final ExecutorService AUTOACK = Executors.newCachedThreadPool(r -> {
Thread t = new Thread(r, "test-auto-ack");
t.setDaemon(true);
return t;
});
}
}

View File

@ -0,0 +1,207 @@
package vip.mate.channel.wecom;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import vip.mate.channel.ChannelMessageRouter;
import vip.mate.channel.model.ChannelEntity;
import vip.mate.channel.notification.ApprovalNotificationService;
import vip.mate.channel.wecom.cards.WeComCardDispatcher;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import static org.junit.jupiter.api.Assertions.*;
/**
* Exercises the chunk-content dedup added to
* {@link WeComChannelAdapter#replyStream(String, String, String, boolean, String)}
* (RFC-32 §2.1.3). Without dedup, every token-level update during tool
* argument streaming would emit a fresh frame even when the visible
* content didn't change flickering the IM client.
*
* <p>Run pattern: drop {@code sendFrame} into a queue so we can count
* how many frames actually went out for a given content sequence,
* without touching a real WebSocket.
*/
class ReplyStreamDedupTest {
private TestableAdapter adapter;
private LinkedBlockingQueue<Map<String, Object>> sentFrames;
@BeforeEach
void setUp() throws Exception {
ChannelEntity entity = new ChannelEntity();
entity.setId(1L);
entity.setChannelType("wecom");
entity.setConfigJson("{}");
adapter = new TestableAdapter(
entity,
Mockito.mock(ChannelMessageRouter.class),
new ObjectMapper(),
Mockito.mock(ApprovalNotificationService.class),
Mockito.mock(WeComCardDispatcher.class),
Mockito.mock(WeComKeepaliveScheduler.class));
sentFrames = adapter.sentFrames;
// Bring the adapter to "running + accepting" so sendFrameWithAck doesn't
// fast-fail on the lifecycle gate (PR-0).
Field running = adapter.getClass().getSuperclass().getSuperclass().getDeclaredField("running");
running.setAccessible(true);
((AtomicBoolean) running.get(adapter)).set(true);
Method ensure = WeComChannelAdapter.class.getDeclaredMethod("ensureReplyExecutor");
ensure.setAccessible(true);
ensure.invoke(adapter);
Method open = WeComChannelAdapter.class.getDeclaredMethod("openReplyQueue");
open.setAccessible(true);
open.invoke(adapter);
// Long idle so the worker doesn't churn during the short test.
adapter.workerIdleTimeoutMs = 60_000L;
}
@Test
@DisplayName("identical non-final chunks dedup: only first goes out")
void identicalChunksDedup() throws Exception {
Method m = WeComChannelAdapter.class.getDeclaredMethod(
"replyStream", String.class, String.class, String.class, boolean.class);
m.setAccessible(true);
m.invoke(adapter, "rid", "stream-1", "Hello", false);
m.invoke(adapter, "rid", "stream-1", "Hello", false); // dup skipped
m.invoke(adapter, "rid", "stream-1", "Hello", false); // dup skipped
// Only the first frame should have been dispatched (give worker a beat).
Map<String, Object> first = sentFrames.poll(500, TimeUnit.MILLISECONDS);
assertNotNull(first, "first non-final chunk should have dispatched");
assertNull(sentFrames.poll(200, TimeUnit.MILLISECONDS),
"duplicate non-final chunks must be deduplicated");
}
@Test
@DisplayName("changed content always goes out")
void changedContentDispatches() throws Exception {
Method m = WeComChannelAdapter.class.getDeclaredMethod(
"replyStream", String.class, String.class, String.class, boolean.class);
m.setAccessible(true);
m.invoke(adapter, "rid", "stream-1", "Hello", false);
m.invoke(adapter, "rid", "stream-1", "Hello world", false); // changed goes
m.invoke(adapter, "rid", "stream-1", "Hello world", false); // dup skipped
// 2 frames expected (poll up to 500ms each)
Map<String, Object> f1 = sentFrames.poll(500, TimeUnit.MILLISECONDS);
Map<String, Object> f2 = sentFrames.poll(500, TimeUnit.MILLISECONDS);
assertNotNull(f1);
assertNotNull(f2);
assertNull(sentFrames.poll(200, TimeUnit.MILLISECONDS),
"no third frame: only 2 distinct contents should have been sent");
}
@Test
@DisplayName("finish=true always goes out, even with identical content")
void finishAlwaysDispatches() throws Exception {
Method m = WeComChannelAdapter.class.getDeclaredMethod(
"replyStream", String.class, String.class, String.class, boolean.class);
m.setAccessible(true);
m.invoke(adapter, "rid", "stream-1", "Done", false);
m.invoke(adapter, "rid", "stream-1", "Done", true); // SAME content but finish=true goes
Map<String, Object> f1 = sentFrames.poll(500, TimeUnit.MILLISECONDS);
Map<String, Object> f2 = sentFrames.poll(500, TimeUnit.MILLISECONDS);
assertNotNull(f1);
assertNotNull(f2, "finish=true must always dispatch even when content matches the previous chunk");
}
@Test
@DisplayName("dedup is per-streamId; different streams don't interfere")
void perStreamIsolation() throws Exception {
Method m = WeComChannelAdapter.class.getDeclaredMethod(
"replyStream", String.class, String.class, String.class, boolean.class);
m.setAccessible(true);
m.invoke(adapter, "rid", "stream-A", "X", false);
m.invoke(adapter, "rid", "stream-B", "X", false); // different stream must dispatch
Map<String, Object> f1 = sentFrames.poll(500, TimeUnit.MILLISECONDS);
Map<String, Object> f2 = sentFrames.poll(500, TimeUnit.MILLISECONDS);
assertNotNull(f1);
assertNotNull(f2,
"dedup memory must be per-streamId — same content on a different stream still dispatches");
}
@Test
@DisplayName("after finish=true, the dedup slot is cleared so the next stream with same content goes")
void finishClearsDedupSlot() throws Exception {
Method m = WeComChannelAdapter.class.getDeclaredMethod(
"replyStream", String.class, String.class, String.class, boolean.class);
m.setAccessible(true);
m.invoke(adapter, "rid", "stream-1", "X", false);
m.invoke(adapter, "rid", "stream-1", "X", true); // finish, clears slot
m.invoke(adapter, "rid", "stream-1", "X", false); // new chunk slot was cleared, so goes
// 3 frames expected total
for (int i = 0; i < 3; i++) {
assertNotNull(sentFrames.poll(500, TimeUnit.MILLISECONDS),
"expected frame #" + (i + 1) + " to dispatch");
}
}
/**
* Test-only adapter that captures dispatched frames AND auto-completes
* each {@code pendingAcks} future shortly after the frame goes out, so
* the per-reqId serial worker can dequeue the next task without waiting
* the full 5s {@code orTimeout}. Without auto-ack, the dedup tests that
* dispatch multiple distinct frames would each block ~5s on the prior
* frame's ACK.
*/
static class TestableAdapter extends WeComChannelAdapter {
final LinkedBlockingQueue<Map<String, Object>> sentFrames = new LinkedBlockingQueue<>();
private static final ExecutorService AUTOACK = Executors.newCachedThreadPool(r -> {
Thread t = new Thread(r, "test-autoack-dedup");
t.setDaemon(true);
return t;
});
TestableAdapter(ChannelEntity entity, ChannelMessageRouter router,
ObjectMapper mapper, ApprovalNotificationService approvalSvc,
WeComCardDispatcher cardDispatcher, WeComKeepaliveScheduler keepalive) {
super(entity, router, mapper, approvalSvc, cardDispatcher, keepalive);
}
@Override
@SuppressWarnings("unchecked")
void sendFrame(Map<String, Object> frame) {
sentFrames.offer(frame);
// Mirror what the WeCom server would do in production: ACK the
// outbound request so the worker's task.future().join() unblocks
// and the next frame in the same reqId queue can dispatch.
Map<String, Object> headers = (Map<String, Object>) frame.get("headers");
if (headers == null) return;
String reqId = (String) headers.get("req_id");
if (reqId == null || reqId.isBlank()) return;
AUTOACK.submit(() -> completeAckSoon(reqId));
}
private void completeAckSoon(String reqId) {
try {
// Brief delay so the worker has reliably completed
// pendingAcks.put before we look it up.
Thread.sleep(2);
Field f = WeComChannelAdapter.class.getDeclaredField("pendingAcks");
f.setAccessible(true);
@SuppressWarnings("unchecked")
ConcurrentHashMap<String, CompletableFuture<Map<String, Object>>> pending =
(ConcurrentHashMap<String, CompletableFuture<Map<String, Object>>>) f.get(this);
CompletableFuture<Map<String, Object>> fut = pending.get(reqId);
if (fut != null) fut.complete(Map.of("errcode", 0));
} catch (Exception ignored) {}
}
}
}

View File

@ -0,0 +1,77 @@
package vip.mate.channel.wecom;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Method;
import static org.junit.jupiter.api.Assertions.*;
/**
* Pin the alignment between {@code WeComChannelAdapter.inboundConversationId}
* and {@code ChannelMessageRouter.buildConversationId}.
*
* <p>These two compute the same logical conversation id from different code
* paths: the adapter pre-computes it to choose the per-conversation
* upload directory <em>before</em> the {@link vip.mate.channel.ChannelMessage}
* exists, and the router computes it from the {@code ChannelMessage}
* downstream. They MUST agree on the same string format, otherwise
* inbound media saves to one directory while messages persist under a
* different conversationId and the {@code /api/v1/chat/files/{convId}/...}
* endpoint's owner check fails for every fetch (403 broken images).
*
* <p>The format both produce: {@code wecom:{chatId}} for groups,
* {@code wecom:{senderId}} for 1:1 no {@code group:} infix.
*/
class WeComInboundConversationIdTest {
private static String inboundConversationId(String senderId, String chatId, String chatType) throws Exception {
Method m = WeComChannelAdapter.class.getDeclaredMethod(
"inboundConversationId", String.class, String.class, String.class);
m.setAccessible(true);
return (String) m.invoke(null, senderId, chatId, chatType);
}
@Test
@DisplayName("group → wecom:{chatId} (no 'group:' infix, matches router)")
void groupChatIdFormat() throws Exception {
// The bug fix: previously returned "wecom:group:abc" which mismatched
// the router's "wecom:abc" quoted-image fileUrls hit a 403 because
// isConversationOwner couldn't find a "wecom:group:abc" row in
// mate_conversation.
assertEquals("wecom:group-abc",
inboundConversationId("XuZhanFu", "group-abc", "group"));
}
@Test
@DisplayName("1:1 → wecom:{senderId} (chatId is irrelevant in single chats)")
void singleChatSenderFormat() throws Exception {
// Single-chat case never had the bug because both adapter and
// router fell back to senderId pin it so a future refactor of
// either side doesn't accidentally diverge.
assertEquals("wecom:XuZhanFu",
inboundConversationId("XuZhanFu", null, "single"));
assertEquals("wecom:XuZhanFu",
inboundConversationId("XuZhanFu", "ignored-when-single", "single"));
}
@Test
@DisplayName("matches ChannelMessageRouter.buildConversationId for both group and 1:1")
void matchesRouterFormat() throws Exception {
// Router's identifier picker:
// chatId != null "{channelType}:{chatId}" (group)
// chatId == null "{channelType}:{senderId}" (single)
// Inbound side passes chatId for groups, null/ignored for 1:1.
// Both must arrive at the same string, exact-equal.
// group: router gets chatId from the ChannelMessage builder
String routerGroup = "wecom" + ":" + "group-xyz";
assertEquals(routerGroup,
inboundConversationId("Alice", "group-xyz", "group"));
// single: router falls back to senderId (chatId is null on the message)
String routerSingle = "wecom" + ":" + "Alice";
assertEquals(routerSingle,
inboundConversationId("Alice", null, "single"));
}
}

View File

@ -0,0 +1,163 @@
package vip.mate.channel.wecom;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;
/**
* Verify the WeComKeepaliveScheduler bookkeeping + force-finish path.
*
* <p>The 20s/180s timing constants come from QwenPaw and are already
* validated empirically in production; we don't re-test the exact
* scheduling intervals here (would require either real wall-clock waits
* or invasive ScheduledExecutor mocking). Instead we cover:
* <ul>
* <li>start/stop/shutdownAll bookkeeping is correct</li>
* <li>the force-finish branch (180s ceiling) calls
* {@link WeComChannelAdapter#replyStreamFinishForKeepalive} AND
* {@link WeComChannelAdapter#invalidateReplyContext} the
* RFC-32 §2.1.2 invariant that prevents the next real reply from
* reusing a closed stream slot</li>
* <li>the refresh branch (still under ceiling) calls
* {@link WeComChannelAdapter#replyStreamRefreshForKeepalive} only</li>
* </ul>
*
* <p>Force-finish is exercised by reflection-overriding {@code startedAt}
* to a long-ago timestamp on a tracked StreamState, then invoking the
* private {@code tick} method. This bypasses the ScheduledExecutor
* entirely so tests run in milliseconds.
*/
class WeComKeepaliveSchedulerTest {
private WeComKeepaliveScheduler scheduler;
private WeComChannelAdapter adapter;
@BeforeEach
void setUp() {
scheduler = new WeComKeepaliveScheduler();
adapter = Mockito.mock(WeComChannelAdapter.class);
}
@Test
@DisplayName("start adds a stream entry; stop removes it")
void startStopBookkeeping() {
assertEquals(0, scheduler.activeStreamCount());
scheduler.start(adapter, "req-1", "stream-1", "user-alice");
assertEquals(1, scheduler.activeStreamCount());
scheduler.stop("stream-1");
assertEquals(0, scheduler.activeStreamCount());
}
@Test
@DisplayName("start is idempotent — second call for same streamId is a no-op")
void startIdempotent() {
scheduler.start(adapter, "req-1", "stream-1", "user-alice");
scheduler.start(adapter, "req-1", "stream-1", "user-alice");
assertEquals(1, scheduler.activeStreamCount(), "second start must not double-track");
}
@Test
@DisplayName("start is null-tolerant — null/blank args silently drop")
void startNullTolerant() {
scheduler.start(null, "r", "s", "t");
scheduler.start(adapter, null, "s", "t");
scheduler.start(adapter, "", "s", "t");
scheduler.start(adapter, "r", null, "t");
scheduler.start(adapter, "r", "", "t");
assertEquals(0, scheduler.activeStreamCount(),
"null/blank args must not add entries");
}
@Test
@DisplayName("shutdownAll clears every tracked stream")
void shutdownAllClears() {
scheduler.start(adapter, "req-1", "stream-1", "user-alice");
scheduler.start(adapter, "req-2", "stream-2", "user-bob");
assertEquals(2, scheduler.activeStreamCount());
scheduler.shutdownAll();
assertEquals(0, scheduler.activeStreamCount());
}
@Test
@DisplayName("force-finish path: replyStreamFinishForKeepalive + invalidateReplyContext + stop")
void forceFinishPath() throws Exception {
scheduler.start(adapter, "req-x", "stream-x", "user-alice");
// Reflectively rewind startedAt so the next tick sees elapsed > 180s
Object state = getStreamState("stream-x");
Field startedAt = state.getClass().getDeclaredField("startedAt");
startedAt.setAccessible(true);
// Java's `final long` fields normally resist setAccessible.set unfortunately
// primitives also need the modifiers hack on JDK 17+. Use Unsafe-free path:
// the field happens to be declared `final` in the static record, so we mutate
// via setLong (which works for primitives even on final fields when accessible
// is true on JDK17 verified locally).
startedAt.setLong(state, System.currentTimeMillis() - 200_000L);
// Manually invoke the private tick(StreamState) no ScheduledExecutor
// wall-clock wait
Method tick = WeComKeepaliveScheduler.class.getDeclaredMethod(
"tick", Class.forName(WeComKeepaliveScheduler.class.getName() + "$StreamState"));
tick.setAccessible(true);
tick.invoke(scheduler, state);
verify(adapter, times(1)).replyStreamFinishForKeepalive(
eq("req-x"), eq("stream-x"), eq(WeComKeepaliveScheduler.PROCESSING_TEXT));
verify(adapter, times(1)).invalidateReplyContext(eq("user-alice"), eq("stream-x"));
verify(adapter, never()).replyStreamRefreshForKeepalive(any(), any(), any());
// After force-finish, the stream is removed from the tracker
assertEquals(0, scheduler.activeStreamCount());
}
@Test
@DisplayName("refresh path: replyStreamRefreshForKeepalive only — no force-finish below ceiling")
void refreshPathBelowCeiling() throws Exception {
scheduler.start(adapter, "req-y", "stream-y", "user-bob");
// Don't rewind startedAt; the state is fresh well under 180s.
Object state = getStreamState("stream-y");
Method tick = WeComKeepaliveScheduler.class.getDeclaredMethod(
"tick", Class.forName(WeComKeepaliveScheduler.class.getName() + "$StreamState"));
tick.setAccessible(true);
tick.invoke(scheduler, state);
verify(adapter, times(1)).replyStreamRefreshForKeepalive(
eq("req-y"), eq("stream-y"), eq(WeComKeepaliveScheduler.PROCESSING_TEXT));
verify(adapter, never()).replyStreamFinishForKeepalive(any(), any(), any());
verify(adapter, never()).invalidateReplyContext(any(), any());
// Still tracked refresh ticks don't unregister
assertEquals(1, scheduler.activeStreamCount());
}
@Test
@DisplayName("constants match the QwenPaw-verified values (20s refresh / 180s ceiling)")
void constantsMatch() {
assertEquals(20L, WeComKeepaliveScheduler.REFRESH_INTERVAL_SECONDS);
assertEquals(180L, WeComKeepaliveScheduler.MAX_DURATION_SECONDS);
assertEquals("🤔 思考中...", WeComKeepaliveScheduler.PROCESSING_TEXT);
}
// Pull a tracked StreamState by streamId via reflection. The states map
// lives behind a private final ConcurrentHashMap.
private Object getStreamState(String streamId) throws Exception {
Field statesField = WeComKeepaliveScheduler.class.getDeclaredField("states");
statesField.setAccessible(true);
@SuppressWarnings("unchecked")
Map<String, Object> states = (Map<String, Object>) statesField.get(scheduler);
Object st = states.get(streamId);
assertNotNull(st, "expected stream " + streamId + " to be tracked");
return st;
}
}

View File

@ -0,0 +1,115 @@
package vip.mate.channel.wecom;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import vip.mate.channel.wecom.WeComChannelAdapter.WeComUploadLimitDecision;
import static org.junit.jupiter.api.Assertions.*;
import static vip.mate.channel.wecom.WeComChannelAdapter.applyWeComUploadLimits;
import static vip.mate.channel.wecom.WeComChannelAdapter.FILE_MAX_BYTES;
import static vip.mate.channel.wecom.WeComChannelAdapter.IMAGE_MAX_BYTES;
import static vip.mate.channel.wecom.WeComChannelAdapter.VIDEO_MAX_BYTES;
import static vip.mate.channel.wecom.WeComChannelAdapter.VOICE_MAX_BYTES;
/**
* Pin the WeCom upload-limits decision matrix.
*
* <p>The platform server enforces these limits at the chunk-finish step
* (after we've already uploaded all bytes). Without the client-side
* pre-check, a 25 MB PDF would chunk-upload for ~minutes, then the
* server rejects the finish frame, and the user sees nothing arrive.
* These tests pin the boundary so future tweaks (e.g. WeCom raising
* limits) are intentional.
*/
class WeComUploadLimitsTest {
@Test
@DisplayName("normal-sized file passes through with native media type")
void normalFilePasses() {
WeComUploadLimitDecision d = applyWeComUploadLimits(1_000_000, "file", null);
assertFalse(d.rejected());
assertFalse(d.downgraded());
assertEquals("file", d.finalMediaType());
}
@Test
@DisplayName("file at exactly 20MB still passes; over rejects")
void fileBoundary() {
WeComUploadLimitDecision pass = applyWeComUploadLimits(FILE_MAX_BYTES, "file", null);
assertFalse(pass.rejected());
WeComUploadLimitDecision fail = applyWeComUploadLimits(FILE_MAX_BYTES + 1, "file", null);
assertTrue(fail.rejected());
assertNotNull(fail.rejectReason());
assertTrue(fail.rejectReason().contains("20MB"),
"reject reason should mention 20MB; got: " + fail.rejectReason());
}
@Test
@DisplayName("image over 10MB downgrades to file with friendly note")
void oversizedImageDowngrades() {
WeComUploadLimitDecision d = applyWeComUploadLimits(IMAGE_MAX_BYTES + 1, "image", "image/png");
assertFalse(d.rejected());
assertTrue(d.downgraded());
assertEquals("file", d.finalMediaType());
assertNotNull(d.downgradeNote());
assertTrue(d.downgradeNote().contains("图片"));
assertTrue(d.downgradeNote().contains("10MB"));
}
@Test
@DisplayName("image at exactly 10MB still passes as image")
void imageAtBoundary() {
WeComUploadLimitDecision d = applyWeComUploadLimits(IMAGE_MAX_BYTES, "image", "image/jpeg");
assertFalse(d.rejected());
assertFalse(d.downgraded());
assertEquals("image", d.finalMediaType());
}
@Test
@DisplayName("video over 10MB downgrades to file")
void oversizedVideoDowngrades() {
WeComUploadLimitDecision d = applyWeComUploadLimits(VIDEO_MAX_BYTES + 1, "video", "video/mp4");
assertEquals("file", d.finalMediaType());
assertTrue(d.downgraded());
assertTrue(d.downgradeNote().contains("视频"));
}
@Test
@DisplayName("voice with non-AMR mime downgrades to file regardless of size")
void voiceWrongMimeDowngrades() {
WeComUploadLimitDecision d = applyWeComUploadLimits(500_000, "voice", "audio/mpeg");
assertEquals("file", d.finalMediaType());
assertTrue(d.downgraded());
assertTrue(d.downgradeNote().contains("AMR"));
}
@Test
@DisplayName("voice in AMR but over 2MB downgrades to file")
void voiceOversizedAmrDowngrades() {
WeComUploadLimitDecision d = applyWeComUploadLimits(VOICE_MAX_BYTES + 1, "voice", "audio/amr");
assertEquals("file", d.finalMediaType());
assertTrue(d.downgraded());
assertTrue(d.downgradeNote().contains("语音"));
assertTrue(d.downgradeNote().contains("2MB"));
}
@Test
@DisplayName("voice in AMR within 2MB passes natively")
void voiceAmrInBoundsPasses() {
WeComUploadLimitDecision d = applyWeComUploadLimits(VOICE_MAX_BYTES, "voice", "audio/amr");
assertFalse(d.rejected());
assertFalse(d.downgraded());
assertEquals("voice", d.finalMediaType());
}
@Test
@DisplayName("absolute 20MB cap trumps every modality-specific downgrade")
void absoluteCapTrumpsDowngrade() {
// An image at 25MB is over both 10MB image limit AND 20MB absolute cap.
// The absolute cap fires first (reject), not the downgrade path.
WeComUploadLimitDecision d = applyWeComUploadLimits(25L * 1024 * 1024, "image", "image/png");
assertTrue(d.rejected());
assertFalse(d.downgraded());
}
}

View File

@ -0,0 +1,143 @@
package vip.mate.channel.wecom.cards.tool_guard;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import vip.mate.channel.wecom.cards.CardOversizedException;
import java.nio.charset.StandardCharsets;
import static org.junit.jupiter.api.Assertions.*;
/**
* Unit tests for the WeCom 1024-byte button.key encoding contract.
*
* <p>The encoding is the only place in PR-1 where a card payload can
* exceed a hard server limit and force the adapter to fall back to
* text. These tests pin both the happy-path encoding shape and the
* overflow behaviour so future changes to button.key fields can't
* silently break either.
*/
class ToolGuardButtonKeyTest {
private ToolGuardButtonKey buttonKey;
@BeforeEach
void setUp() {
buttonKey = new ToolGuardButtonKey(new ObjectMapper());
}
@Test
@DisplayName("encode produces decodable JSON with stable field order")
void encodeDecodeRoundTrip() {
String encoded = buttonKey.encode(
ToolGuardButtonKey.Action.APPROVE,
"abc123def456",
"shell_exec",
"HIGH"
);
// Stable order ensures byte-length predictability + makes log
// greps deterministic.
assertTrue(encoded.startsWith("{\"a\":\"approve\""),
"first field must be 'a' (action); got: " + encoded);
assertTrue(encoded.contains("\"rid\":\"abc123def456\""));
assertTrue(encoded.contains("\"tool\":\"shell_exec\""));
assertTrue(encoded.contains("\"sev\":\"HIGH\""));
ToolGuardButtonKey.Decoded decoded = buttonKey.decode(encoded);
assertNotNull(decoded);
assertEquals(ToolGuardButtonKey.Action.APPROVE, decoded.action());
assertEquals("abc123def456", decoded.pendingId());
assertEquals("shell_exec", decoded.toolName());
assertEquals("HIGH", decoded.severity());
}
@Test
@DisplayName("encode throws CardOversizedException at exactly the 1024-byte threshold")
void overflowAt1024Bytes() {
// toolName 1100 chars of pure ASCII (1100 bytes) single character per byte
// forces the JSON over 1024 even with all the structural overhead.
String hugeTool = "x".repeat(1100);
CardOversizedException ex = assertThrows(CardOversizedException.class,
() -> buttonKey.encode(
ToolGuardButtonKey.Action.DENY,
"rid",
hugeTool,
"MEDIUM"));
assertTrue(ex.getMessage().contains("button.key payload"),
"exception message should reference button.key payload, got: " + ex.getMessage());
assertTrue(ex.getMessage().contains("1024"),
"exception message should mention the 1024 limit, got: " + ex.getMessage());
}
@Test
@DisplayName("encode handles Chinese tool names within the 1024-byte budget")
void encodeChineseToolName() {
String chinese = "执行命令".repeat(40); // 4 chars * 40 = 160 chars, ~480 UTF-8 bytes
String encoded = buttonKey.encode(
ToolGuardButtonKey.Action.APPROVE,
"uuid-1234",
chinese,
"MEDIUM"
);
// sanity: each Chinese char = 3 UTF-8 bytes; 160 chars 480 bytes;
// overhead 50 bytes; total well under 1024
int bytes = encoded.getBytes(StandardCharsets.UTF_8).length;
assertTrue(bytes < 1024, "expected < 1024 bytes for moderate Chinese, got " + bytes);
ToolGuardButtonKey.Decoded decoded = buttonKey.decode(encoded);
assertNotNull(decoded);
assertEquals(chinese, decoded.toolName());
}
@Test
@DisplayName("decode returns null for malformed JSON, unknown action, or missing rid")
void decodeMalformed() {
// Garbage JSON
assertNull(buttonKey.decode("not json"));
assertNull(buttonKey.decode("{not closed"));
// Unknown action
assertNull(buttonKey.decode("{\"a\":\"reboot\",\"rid\":\"x\"}"));
// Missing rid
assertNull(buttonKey.decode("{\"a\":\"approve\"}"));
// Blank rid
assertNull(buttonKey.decode("{\"a\":\"approve\",\"rid\":\"\"}"));
// Null / blank input
assertNull(buttonKey.decode(null));
assertNull(buttonKey.decode(""));
assertNull(buttonKey.decode(" "));
}
@Test
@DisplayName("decode tolerates extra/unknown fields (forward-compat)")
void decodeForwardCompat() {
String json = "{\"a\":\"deny\",\"rid\":\"r1\",\"tool\":\"t\",\"sev\":\"LOW\",\"future\":42}";
ToolGuardButtonKey.Decoded decoded = buttonKey.decode(json);
assertNotNull(decoded);
assertEquals(ToolGuardButtonKey.Action.DENY, decoded.action());
}
@Test
@DisplayName("encoded JSON respects the 1024-byte boundary on either side")
void boundaryExact() {
// 950 ASCII chars + JSON overhead (~50 bytes for the structural braces,
// commas, quotes, and the 'a'/'rid'/'tool'/'sev' field labels) lands
// around 1010 bytes comfortably under the 1024 limit.
String near = "a".repeat(950);
String encoded = buttonKey.encode(
ToolGuardButtonKey.Action.APPROVE,
"x",
near,
"M"
);
assertNotNull(encoded);
assertTrue(encoded.getBytes(StandardCharsets.UTF_8).length <= ToolGuardButtonKey.MAX_KEY_BYTES,
"950-char tool name must encode within 1024 bytes; got "
+ encoded.getBytes(StandardCharsets.UTF_8).length);
// Push past the limit must throw
String over = "a".repeat(1100);
assertThrows(CardOversizedException.class,
() -> buttonKey.encode(ToolGuardButtonKey.Action.APPROVE, "x", over, "M"));
}
}

View File

@ -0,0 +1,198 @@
package vip.mate.channel.wecom.cards.tool_guard;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.Mockito;
import vip.mate.approval.ApprovalService;
import vip.mate.approval.PendingApproval;
import vip.mate.channel.ChannelMessage;
import vip.mate.channel.wecom.WeComChannelAdapter;
import java.util.Map;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.*;
/**
* Tests for the validate-before-render invariant (RFC-32 v2.1 / R-5).
*
* <p>The earlier draft (v2.0) did "render resolved card → inject /approve →
* router rejects unauthorized" — meaning a Lee click on Zhang's pending
* would briefly show "✅ 已批准 by 李四" on the card before the router
* silently dropped the command. v2.1 reorders to validate first, then
* render the resolved card matching the validation result, then inject
* the command only when authorized.
*/
class ToolGuardCardHandlerTest {
private ApprovalService approvalService;
private WeComChannelAdapter adapter;
private ToolGuardButtonKey buttonKey;
private ToolGuardCardHandler handler;
@BeforeEach
void setUp() {
approvalService = Mockito.mock(ApprovalService.class);
adapter = Mockito.mock(WeComChannelAdapter.class);
buttonKey = new ToolGuardButtonKey(new ObjectMapper());
handler = new ToolGuardCardHandler(approvalService, buttonKey);
}
@Test
@DisplayName("unauthorized click renders 'unauthorized' card and does NOT inject command")
void unauthorizedClickDoesNotInject() {
// Given: a pending whose original requester is "alice"
PendingApproval pending = pendingFor("pid_xyz", "alice", "shell_exec");
when(approvalService.getPending("pid_xyz")).thenReturn(Optional.of(pending));
// When: bob (NOT alice) clicks "approve"
Map<String, Object> frame = inboundFrame("evt_req_1", buttonKey.encode(
ToolGuardButtonKey.Action.APPROVE, "pid_xyz", "shell_exec", "HIGH"));
handler.handle(adapter, frame, tce(frame), fromBlock("bob"));
// Then: card was updated to "unauthorized" state
ArgumentCaptor<Map<String, Object>> cardCaptor = cardArgCaptor();
verify(adapter, times(1)).updateTemplateCard(eq("evt_req_1"), cardCaptor.capture());
@SuppressWarnings("unchecked")
Map<String, Object> mainTitle = (Map<String, Object>) cardCaptor.getValue().get("main_title");
assertNotNull(mainTitle);
String title = (String) mainTitle.get("title");
assertTrue(title.contains("仅原请求者"),
"unauthorized card must say '仅原请求者可审批'; got: " + title);
// and CRITICALLY, no /approve command was injected
verify(adapter, never()).injectSyntheticMessage(any(ChannelMessage.class));
}
@Test
@DisplayName("expired pending renders 'expired' card and does NOT inject command")
void expiredPendingShowsExpiredCard() {
when(approvalService.getPending("pid_old")).thenReturn(Optional.empty());
Map<String, Object> frame = inboundFrame("evt_req_2", buttonKey.encode(
ToolGuardButtonKey.Action.APPROVE, "pid_old", "shell_exec", "MEDIUM"));
handler.handle(adapter, frame, tce(frame), fromBlock("alice"));
ArgumentCaptor<Map<String, Object>> cardCaptor = cardArgCaptor();
verify(adapter, times(1)).updateTemplateCard(eq("evt_req_2"), cardCaptor.capture());
@SuppressWarnings("unchecked")
Map<String, Object> mainTitle = (Map<String, Object>) cardCaptor.getValue().get("main_title");
assertTrue(((String) mainTitle.get("title")).contains("过期"),
"expired card title must mention 过期; got: " + mainTitle.get("title"));
verify(adapter, never()).injectSyntheticMessage(any(ChannelMessage.class));
}
@Test
@DisplayName("authorized approve click: render resolved card AND inject /approve")
void authorizedApproveInjectsCommand() {
PendingApproval pending = pendingFor("pid_ok", "alice", "shell_exec");
when(approvalService.getPending("pid_ok")).thenReturn(Optional.of(pending));
Map<String, Object> frame = inboundFrame("evt_req_3", buttonKey.encode(
ToolGuardButtonKey.Action.APPROVE, "pid_ok", "shell_exec", "HIGH"));
handler.handle(adapter, frame, tce(frame), fromBlock("alice"));
ArgumentCaptor<Map<String, Object>> cardCaptor = cardArgCaptor();
verify(adapter, times(1)).updateTemplateCard(eq("evt_req_3"), cardCaptor.capture());
@SuppressWarnings("unchecked")
Map<String, Object> mainTitle = (Map<String, Object>) cardCaptor.getValue().get("main_title");
assertTrue(((String) mainTitle.get("title")).contains("已批准"),
"title must announce success; got: " + mainTitle.get("title"));
// Synthetic command should be injected with the right text
ArgumentCaptor<ChannelMessage> msgCaptor = ArgumentCaptor.forClass(ChannelMessage.class);
verify(adapter, times(1)).injectSyntheticMessage(msgCaptor.capture());
ChannelMessage injected = msgCaptor.getValue();
assertEquals("/approve pid_ok", injected.getContent());
assertEquals("alice", injected.getSenderId());
assertEquals("text", injected.getContentType());
}
@Test
@DisplayName("authorized deny click: injects /deny")
void authorizedDenyInjectsCommand() {
PendingApproval pending = pendingFor("pid_d", "alice", "shell_exec");
when(approvalService.getPending("pid_d")).thenReturn(Optional.of(pending));
Map<String, Object> frame = inboundFrame("evt_req_4", buttonKey.encode(
ToolGuardButtonKey.Action.DENY, "pid_d", "shell_exec", "HIGH"));
handler.handle(adapter, frame, tce(frame), fromBlock("alice"));
ArgumentCaptor<ChannelMessage> msgCaptor = ArgumentCaptor.forClass(ChannelMessage.class);
verify(adapter, times(1)).injectSyntheticMessage(msgCaptor.capture());
assertEquals("/deny pid_d", msgCaptor.getValue().getContent());
}
@Test
@DisplayName("system-owned pending allows ANY clicker (no original requester)")
void systemPendingAcceptsAnyClicker() {
PendingApproval pending = pendingFor("pid_sys", "system", "shell_exec");
when(approvalService.getPending("pid_sys")).thenReturn(Optional.of(pending));
Map<String, Object> frame = inboundFrame("evt_req_5", buttonKey.encode(
ToolGuardButtonKey.Action.APPROVE, "pid_sys", "shell_exec", "MEDIUM"));
handler.handle(adapter, frame, tce(frame), fromBlock("anyone"));
verify(adapter).injectSyntheticMessage(any(ChannelMessage.class));
}
@Test
@DisplayName("malformed event_key drops the event silently — no card update, no command")
void malformedEventKeyIgnored() {
Map<String, Object> frame = inboundFrame("evt_req_6", "{not json");
handler.handle(adapter, frame, tce(frame), fromBlock("alice"));
verify(adapter, never()).updateTemplateCard(anyString(), any());
verify(adapter, never()).injectSyntheticMessage(any(ChannelMessage.class));
}
// ---- helpers ----
private static PendingApproval pendingFor(String pendingId, String requester, String tool) {
PendingApproval p = new PendingApproval(
pendingId, "wecom:alice", requester, tool, "{}", "test approval");
// Status defaults to "pending" via the constructor
return p;
}
private static Map<String, Object> inboundFrame(String reqId, String eventKey) {
return Map.of(
"cmd", "aibot_event_callback",
"headers", Map.of("req_id", reqId),
"body", Map.of(
"chattype", "single",
"chatid", "alice",
"from", Map.of("userid", "alice"),
"event", Map.of(
"eventtype", "template_card_event",
"template_card_event", Map.of(
"task_id", "tg_approval_pid_xyz",
"event_key", eventKey
)
)
)
);
}
@SuppressWarnings("unchecked")
private static Map<String, Object> tce(Map<String, Object> frame) {
Map<String, Object> body = (Map<String, Object>) frame.get("body");
Map<String, Object> event = (Map<String, Object>) body.get("event");
return (Map<String, Object>) event.get("template_card_event");
}
private static Map<String, Object> fromBlock(String userid) {
return Map.of("userid", userid);
}
@SuppressWarnings("unchecked")
private static ArgumentCaptor<Map<String, Object>> cardArgCaptor() {
return (ArgumentCaptor<Map<String, Object>>) (ArgumentCaptor<?>) ArgumentCaptor.forClass(Map.class);
}
}

View File

@ -0,0 +1,104 @@
package vip.mate.channel.wecom.cards.tool_guard;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import vip.mate.channel.notification.ApprovalNotice;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
/**
* Pin the WeCom button_interaction approval card payload shape.
*
* <p>The structure is server-validated any drift (rename a field,
* change button_list location, omit task_id prefix) silently fails on
* the WeCom side at runtime. These tests catch that at compile-test
* time so renames don't ship without protocol awareness.
*/
class ToolGuardCardRendererTest {
private final ToolGuardButtonKey buttonKey = new ToolGuardButtonKey(new ObjectMapper());
private final ToolGuardCardRenderer renderer = new ToolGuardCardRenderer(buttonKey);
@Test
@DisplayName("approval card has the WeCom button_interaction shape")
@SuppressWarnings("unchecked")
void approvalCardShape() {
ApprovalNotice notice = new ApprovalNotice(
"abc12345def67890",
"shell_exec",
"Run system command",
"rm -rf /tmp/cache",
"HIGH",
List.of(),
"/approve abc",
"/deny abc"
);
Map<String, Object> card = renderer.render(notice);
assertEquals("button_interaction", card.get("card_type"));
assertEquals("tg_approval_abc12345def67890", card.get("task_id"),
"task_id must carry the tg_approval_ prefix so the inbound dispatcher can route the click");
Map<String, Object> mainTitle = (Map<String, Object>) card.get("main_title");
assertNotNull(mainTitle);
assertEquals("🛡️ 工具审批", mainTitle.get("title"));
String desc = (String) mainTitle.get("desc");
assertTrue(desc.contains("shell_exec"), "subtitle must include tool name; got: " + desc);
List<Map<String, Object>> buttons = (List<Map<String, Object>>) card.get("button_list");
assertNotNull(buttons);
assertEquals(2, buttons.size());
Map<String, Object> approve = buttons.get(0);
assertEquals("批准", approve.get("text"));
assertEquals(1, approve.get("style"));
String approveKey = (String) approve.get("key");
ToolGuardButtonKey.Decoded a = buttonKey.decode(approveKey);
assertNotNull(a);
assertEquals(ToolGuardButtonKey.Action.APPROVE, a.action());
assertEquals("abc12345def67890", a.pendingId());
Map<String, Object> deny = buttons.get(1);
assertEquals("拒绝", deny.get("text"));
assertEquals(2, deny.get("style"));
ToolGuardButtonKey.Decoded d = buttonKey.decode((String) deny.get("key"));
assertNotNull(d);
assertEquals(ToolGuardButtonKey.Action.DENY, d.action());
}
@Test
@DisplayName("resolved card uses text_notice + carries non-zero card_action.type")
@SuppressWarnings("unchecked")
void resolvedCardShape() {
Map<String, Object> resolved = ToolGuardCardRenderer.buildResolvedCard(
"tg_approval_abc", "✅ 已批准", "Tool x 已批准 by 张三");
assertEquals("text_notice", resolved.get("card_type"));
assertEquals("tg_approval_abc", resolved.get("task_id"));
Map<String, Object> cardAction = (Map<String, Object>) resolved.get("card_action");
assertNotNull(cardAction, "WeCom rejects text_notice cards without card_action");
assertEquals(1, cardAction.get("type"),
"card_action.type must be 1 or 2; type=0 is rejected by the bot endpoint");
assertNotNull(cardAction.get("url"));
}
@Test
@DisplayName("resolved card truncates over-long desc to ~30 chars + ellipsis")
@SuppressWarnings("unchecked")
void resolvedDescTruncated() {
String longDesc = "a".repeat(100);
Map<String, Object> resolved = ToolGuardCardRenderer.buildResolvedCard(
"tg_approval_x", "", longDesc);
Map<String, Object> mainTitle = (Map<String, Object>) resolved.get("main_title");
String desc = (String) mainTitle.get("desc");
assertTrue(desc.length() <= 30, "desc must be ≤30 chars after truncation, got " + desc.length());
assertTrue(desc.endsWith(""), "truncation marker must be present; got: " + desc);
}
}

View File

@ -0,0 +1,124 @@
package vip.mate.cron.config;
import net.javacrumbs.shedlock.core.LockConfiguration;
import net.javacrumbs.shedlock.core.LockProvider;
import net.javacrumbs.shedlock.core.SimpleLock;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.TestPropertySource;
import vip.mate.MateClawApplication;
import java.time.Duration;
import java.time.Instant;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* RFC-03 Lane G2 integration test exercises the full path:
*
* <ol>
* <li>Flyway migration {@code V74__shedlock_table.sql} ran successfully
* against the in-memory H2 (otherwise context startup would fail).</li>
* <li>{@link ShedLockConfig} wired a {@link LockProvider} bean.</li>
* <li>The provider's lock/unlock semantics actually exclude concurrent
* holders i.e. node-A node-B contention works as expected.</li>
* </ol>
*
* <p>Single-node deployments hit only the trivial path (acquire from this
* JVM always succeeds), so a CI test that only exercises one acquirer
* would miss the multi-node behavior we actually shipped this for.
* Simulating two nodes against the same H2 database catches the
* contention path.
*/
@SpringBootTest(
classes = MateClawApplication.class,
webEnvironment = SpringBootTest.WebEnvironment.NONE
)
@TestPropertySource(properties = {
"spring.datasource.url=jdbc:h2:mem:shedlock_test_${random.uuid};MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE;DB_CLOSE_DELAY=-1",
"spring.ai.dashscope.api-key=test-key",
"spring.main.web-application-type=none"
})
class ShedLockIntegrationTest {
@Autowired
private LockProvider lockProvider;
@Autowired
private JdbcTemplate jdbcTemplate;
@Test
@DisplayName("V74 created the shedlock table with the expected columns")
void shedlockTableExists() {
// information_schema lookup works on H2 MySQL-mode and on MySQL itself.
Long count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM information_schema.tables WHERE table_name = 'shedlock'",
Long.class);
assertNotNull(count);
assertEquals(1L, count, "shedlock table should be created by V74");
}
@Test
@DisplayName("acquire then release lets a sibling acquire immediately")
void acquireAndRelease() {
String name = "test-lock-acquire-release";
// First node acquires.
Optional<SimpleLock> a = lockProvider.lock(new LockConfiguration(
Instant.now(), name, Duration.ofMinutes(5), Duration.ZERO));
assertTrue(a.isPresent(), "first acquirer should succeed");
// Sibling tries while A holds it must be excluded.
Optional<SimpleLock> b = lockProvider.lock(new LockConfiguration(
Instant.now(), name, Duration.ofMinutes(5), Duration.ZERO));
assertFalse(b.isPresent(), "second acquirer should be blocked while first holds the lock");
// A releases.
a.get().unlock();
// Sibling tries again should now succeed.
Optional<SimpleLock> c = lockProvider.lock(new LockConfiguration(
Instant.now(), name, Duration.ofMinutes(5), Duration.ZERO));
assertTrue(c.isPresent(), "third acquirer should succeed after release");
c.get().unlock();
}
@Test
@DisplayName("different lock names are independent — two jobs both proceed")
void independentLocks() {
Optional<SimpleLock> jobA = lockProvider.lock(new LockConfiguration(
Instant.now(), "cron-job-A", Duration.ofMinutes(5), Duration.ZERO));
Optional<SimpleLock> jobB = lockProvider.lock(new LockConfiguration(
Instant.now(), "cron-job-B", Duration.ofMinutes(5), Duration.ZERO));
assertTrue(jobA.isPresent());
assertTrue(jobB.isPresent(),
"different lock names must not block each other — multi-job parallelism is the whole point");
jobA.get().unlock();
jobB.get().unlock();
}
@Test
@DisplayName("lockAtLeastFor prevents instant re-acquire by the same caller")
void lockAtLeastForHonored() {
String name = "test-lock-at-least";
// Hold the lock for at least 2 seconds even if we release immediately.
Optional<SimpleLock> first = lockProvider.lock(new LockConfiguration(
Instant.now(), name, Duration.ofMinutes(5), Duration.ofSeconds(2)));
assertTrue(first.isPresent());
first.get().unlock(); // unlock returns, but lockAtLeastFor still applies
// Immediate re-acquire should fail because lockAtLeastFor=2s hasn't elapsed.
Optional<SimpleLock> second = lockProvider.lock(new LockConfiguration(
Instant.now(), name, Duration.ofMinutes(5), Duration.ZERO));
assertFalse(second.isPresent(),
"lockAtLeastFor must keep the entry inaccessible for its duration even after unlock");
}
}

View File

@ -0,0 +1,176 @@
package vip.mate.cron.delivery;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.messages.AssistantMessage;
import vip.mate.cron.model.CronJobEntity;
import vip.mate.dashboard.model.CronJobRunEntity;
import vip.mate.dashboard.repository.CronJobRunMapper;
import java.util.HashSet;
import java.util.Set;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicReference;
import java.util.stream.IntStream;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;
/**
* RFC-063r §2.6.1: Template-Method invariants SQL CAS claim, marker
* methods after success / failure, exception propagation.
*/
class AbstractCronResultDeliveryTest {
private CronJobRunMapper runMapper;
private CronJobEntity job;
private CronJobRunEntity run;
/**
* Pre-warm MyBatis Plus's lambda column cache. Without this the
* production code's {@code new LambdaUpdateWrapper<CronJobRunEntity>()}
* throws "can not find lambda cache" the cache is normally populated
* during Spring context init, which we skip in unit tests.
*/
@BeforeAll
static void initMpLambdaCache() {
MybatisConfiguration cfg = new MybatisConfiguration();
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(cfg, ""), CronJobRunEntity.class);
}
@BeforeEach
void setUp() {
runMapper = mock(CronJobRunMapper.class);
job = new CronJobEntity();
job.setId(1L);
run = new CronJobRunEntity();
run.setId(42L);
run.setStatus("succeeded");
}
@Test
void deliver_claimsSuccessfully_marksDelivered() {
// First update = the claim CAS, returns 1 (won the race)
// Second update = the markDelivered, returns 1
when(runMapper.update(any(), any(Wrapper.class))).thenReturn(1, 1);
AbstractCronResultDelivery strategy = new AbstractCronResultDelivery(runMapper) {
@Override public boolean supports(CronJobEntity j) { return true; }
@Override
protected DeliveryOutcome doDeliver(CronJobEntity j, AssistantMessage r, CronJobRunEntity run) {
return DeliveryOutcome.delivered("user-x");
}
};
DeliveryOutcome outcome = strategy.deliver(job, new AssistantMessage("hi"), run);
assertEquals(DeliveryOutcome.Status.DELIVERED, outcome.status());
assertEquals("user-x", outcome.target());
verify(runMapper, times(2)).update(any(), any(Wrapper.class)); // claim + markDelivered
}
@Test
void deliver_claimAlreadyTaken_returnsSkippedAndDoesNotInvokeDoDeliver() {
// Claim returns 0 another listener already won the CAS
when(runMapper.update(any(), any(Wrapper.class))).thenReturn(0);
AtomicReference<Boolean> doDeliverInvoked = new AtomicReference<>(false);
AbstractCronResultDelivery strategy = new AbstractCronResultDelivery(runMapper) {
@Override public boolean supports(CronJobEntity j) { return true; }
@Override
protected DeliveryOutcome doDeliver(CronJobEntity j, AssistantMessage r, CronJobRunEntity run) {
doDeliverInvoked.set(true);
return DeliveryOutcome.delivered("never");
}
};
DeliveryOutcome outcome = strategy.deliver(job, new AssistantMessage("hi"), run);
assertEquals(DeliveryOutcome.Status.SKIPPED, outcome.status());
assertEquals("already-claimed-by-other-instance", outcome.reason());
assertFalse(doDeliverInvoked.get(), "doDeliver must not run after a failed CAS claim");
verify(runMapper, times(1)).update(any(), any(Wrapper.class)); // only the failed claim
}
@Test
void deliver_doDeliverThrows_marksNotDeliveredAndRethrows() {
// Claim returns 1, then markNotDelivered returns 1
when(runMapper.update(any(), any(Wrapper.class))).thenReturn(1, 1);
RuntimeException oops = new RuntimeException("Slack 503 Service Unavailable");
AbstractCronResultDelivery strategy = new AbstractCronResultDelivery(runMapper) {
@Override public boolean supports(CronJobEntity j) { return true; }
@Override
protected DeliveryOutcome doDeliver(CronJobEntity j, AssistantMessage r, CronJobRunEntity run) {
throw oops;
}
};
RuntimeException thrown = assertThrows(RuntimeException.class,
() -> strategy.deliver(job, new AssistantMessage("hi"), run));
assertSame(oops, thrown, "exception must propagate verbatim so the listener can audit it");
verify(runMapper, times(2)).update(any(), any(Wrapper.class)); // claim + markNotDelivered
}
@Test
void claimRun_concurrentInvocations_onlyOneSucceeds() throws Exception {
// Simulates the cluster scenario: the SQL CAS guarantees exactly one
// listener instance wins. Mock the mapper so the FIRST update() call
// returns 1, all subsequent return 0 matches DB semantics.
Set<Integer> winnerThreadIds = java.util.Collections.synchronizedSet(new HashSet<>());
AtomicReference<Boolean> firstClaim = new AtomicReference<>(true);
when(runMapper.update(any(), any(Wrapper.class))).thenAnswer(inv -> {
// First caller wins, others lose
return firstClaim.compareAndSet(true, false) ? 1 : 0;
});
AbstractCronResultDelivery strategy = new AbstractCronResultDelivery(runMapper) {
@Override public boolean supports(CronJobEntity j) { return true; }
@Override
protected DeliveryOutcome doDeliver(CronJobEntity j, AssistantMessage r, CronJobRunEntity run) {
winnerThreadIds.add((int) Thread.currentThread().threadId());
return DeliveryOutcome.delivered("winner");
}
};
int threadCount = 8;
CountDownLatch start = new CountDownLatch(1);
var pool = Executors.newFixedThreadPool(threadCount);
try {
var futures = IntStream.range(0, threadCount).mapToObj(i -> pool.submit(() -> {
start.await();
return strategy.deliver(job, new AssistantMessage("hi"), run);
})).toList();
start.countDown();
int delivered = 0;
int skipped = 0;
for (var f : futures) {
try {
DeliveryOutcome o = f.get();
if (o.status() == DeliveryOutcome.Status.DELIVERED) delivered++;
else skipped++;
} catch (ExecutionException ignored) {
// doDeliver throws are OK; counted as not-delivered
}
}
assertEquals(1, delivered,
"Exactly one winner under concurrent claim — RFC-063r §2.6.1 invariant");
assertEquals(threadCount - 1, skipped, "All others must observe SKIPPED");
assertEquals(1, winnerThreadIds.size(),
"doDeliver must execute on exactly one thread");
} finally {
pool.shutdownNow();
}
}
}

View File

@ -0,0 +1,117 @@
package vip.mate.cron.delivery;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.messages.AssistantMessage;
import vip.mate.channel.ChannelManager;
import vip.mate.channel.DeliveryOptions;
import vip.mate.cron.model.CronJobEntity;
import vip.mate.cron.model.DeliveryConfig;
import vip.mate.dashboard.model.CronJobRunEntity;
import vip.mate.dashboard.repository.CronJobRunMapper;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;
/**
* RFC-063r §2.6: ChannelCronResultDelivery dispatch contract.
*/
class ChannelCronResultDeliveryTest {
private CronJobRunMapper runMapper;
private ChannelManager channelManager;
private ChannelCronResultDelivery strategy;
@BeforeAll
static void initMpLambdaCache() {
MybatisConfiguration cfg = new MybatisConfiguration();
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(cfg, ""), CronJobRunEntity.class);
}
@BeforeEach
void setUp() {
runMapper = mock(CronJobRunMapper.class);
channelManager = mock(ChannelManager.class);
when(runMapper.update(any(), any(Wrapper.class))).thenReturn(1);
strategy = new ChannelCronResultDelivery(runMapper, channelManager);
}
@Test
void supports_channelIdNull_returnsFalse() {
CronJobEntity job = new CronJobEntity();
job.setChannelId(null);
job.setDeliveryConfig(new DeliveryConfig("u", null, null));
assertFalse(strategy.supports(job),
"web-origin runs (no channelId) must not match the channel strategy");
}
@Test
void supports_targetIdNull_returnsFalse() {
CronJobEntity job = new CronJobEntity();
job.setChannelId(9L);
job.setDeliveryConfig(new DeliveryConfig(null, "thread-1", null));
assertFalse(strategy.supports(job),
"channel binding without targetId must not deliver");
}
@Test
void supports_targetIdBlank_returnsFalse() {
CronJobEntity job = new CronJobEntity();
job.setChannelId(9L);
job.setDeliveryConfig(new DeliveryConfig(" ", null, null));
assertFalse(strategy.supports(job),
"blank targetId must be treated as missing");
}
@Test
void supports_channelAndTargetSet_returnsTrue() {
CronJobEntity job = new CronJobEntity();
job.setChannelId(9L);
job.setDeliveryConfig(new DeliveryConfig("user-7", null, null));
assertTrue(strategy.supports(job));
}
@Test
void doDeliver_callsChannelManagerWithDeliveryOptions() {
CronJobEntity job = new CronJobEntity();
job.setChannelId(9L);
job.setDeliveryConfig(new DeliveryConfig("user-7", "thread-abc", "bot-001"));
CronJobRunEntity run = new CronJobRunEntity();
run.setId(42L);
DeliveryOutcome outcome = strategy.deliver(job, new AssistantMessage("Daily summary"), run);
assertEquals(DeliveryOutcome.Status.DELIVERED, outcome.status());
assertEquals("user-7", outcome.target());
verify(channelManager).sendToChannel(eq(9L), eq("user-7"), any(String.class),
argThat(opts -> "thread-abc".equals(opts.threadId())
&& "bot-001".equals(opts.accountId())));
}
@Test
void doDeliver_adapterDisabled_propagatesIllegalStateAndMarksNotDelivered() {
CronJobEntity job = new CronJobEntity();
job.setChannelId(9L);
job.setDeliveryConfig(new DeliveryConfig("user-7", null, null));
CronJobRunEntity run = new CronJobRunEntity();
run.setId(42L);
// Simulate channel adapter unavailable ChannelManager throws.
IllegalStateException disabled = new IllegalStateException("Channel not active: 9");
doThrow(disabled).when(channelManager)
.sendToChannel(eq(9L), eq("user-7"), any(String.class), any(DeliveryOptions.class));
IllegalStateException thrown = assertThrows(IllegalStateException.class,
() -> strategy.deliver(job, new AssistantMessage("hi"), run));
assertSame(disabled, thrown);
// Two updates: claim + markNotDelivered
verify(runMapper, times(2)).update(any(), any(Wrapper.class));
}
}

View File

@ -0,0 +1,96 @@
package vip.mate.cron.model;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import vip.mate.agent.context.ChannelTarget;
import static org.junit.jupiter.api.Assertions.*;
/**
* RFC-063r §2.9: DeliveryConfig must round-trip through Jackson cleanly so
* MyBatis Plus JacksonTypeHandler can persist + restore it on
* {@code mate_cron_job.delivery_config}.
*/
class DeliveryConfigTest {
@Test
void from_nullChannelTarget_returnsNull() {
assertNull(DeliveryConfig.from(null));
}
@Test
void roundTripThroughChannelTarget() {
ChannelTarget t = new ChannelTarget("user-1", "thread-a", "bot-x");
DeliveryConfig dc = DeliveryConfig.from(t);
assertEquals(t, dc.toChannelTarget());
}
@Test
void jsonRoundTrip_preservesAllFields() throws Exception {
ObjectMapper om = new ObjectMapper();
DeliveryConfig original = new DeliveryConfig("user-1", "thread-a", "bot-x");
String json = om.writeValueAsString(original);
DeliveryConfig restored = om.readValue(json, DeliveryConfig.class);
assertEquals(original, restored);
}
@Test
void jsonDeserialize_unknownFieldsAreIgnored() throws Exception {
ObjectMapper om = new ObjectMapper();
String json = "{\"targetId\":\"u\",\"threadId\":null,\"accountId\":null,\"newFieldFromFuture\":\"y\"}";
DeliveryConfig dc = om.readValue(json, DeliveryConfig.class);
assertEquals("u", dc.targetId());
}
// RFC-03 Lane C1: suppressAgentReply
@Test
void suppressAgentReply_defaultsToFalse_legacyCtor3arg() {
// Pre-RFC-03 callsite no suppress arg means historical behavior.
DeliveryConfig dc = new DeliveryConfig("u", null, null);
assertFalse(dc.isAgentReplySuppressed());
assertNull(dc.suppressAgentReply());
}
@Test
void suppressAgentReply_defaultsToFalse_legacyCtor4arg() {
// 4-arg legacy ctor (post-userId, pre-suppress).
DeliveryConfig dc = new DeliveryConfig("u", null, null, "sender");
assertFalse(dc.isAgentReplySuppressed());
assertNull(dc.suppressAgentReply());
}
@Test
void suppressAgentReply_explicitFalseStillDelivers() {
DeliveryConfig dc = new DeliveryConfig("u", null, null, null, Boolean.FALSE);
assertFalse(dc.isAgentReplySuppressed(),
"explicit FALSE must be treated identically to null — both deliver");
}
@Test
void suppressAgentReply_trueShortCircuits() {
DeliveryConfig dc = new DeliveryConfig("u", null, null, null, Boolean.TRUE);
assertTrue(dc.isAgentReplySuppressed());
}
@Test
void suppressAgentReply_jsonRoundTrip() throws Exception {
ObjectMapper om = new ObjectMapper();
DeliveryConfig original = new DeliveryConfig("u", "t", "a", "sender", Boolean.TRUE);
String json = om.writeValueAsString(original);
DeliveryConfig restored = om.readValue(json, DeliveryConfig.class);
assertEquals(original, restored);
assertTrue(restored.isAgentReplySuppressed());
}
@Test
void suppressAgentReply_preV75JsonRow_treatedAsFalse() throws Exception {
// Rows persisted before V75 don't have suppressAgentReply at all
// round-trip must surface as null and isAgentReplySuppressed=false.
ObjectMapper om = new ObjectMapper();
String legacyJson = "{\"targetId\":\"u\",\"threadId\":\"t\",\"accountId\":\"a\",\"userId\":\"sender\"}";
DeliveryConfig dc = om.readValue(legacyJson, DeliveryConfig.class);
assertNull(dc.suppressAgentReply());
assertFalse(dc.isAgentReplySuppressed());
}
}

View File

@ -0,0 +1,45 @@
package vip.mate.cron.service;
import org.junit.jupiter.api.Test;
import vip.mate.agent.context.ChannelTarget;
import vip.mate.agent.context.ChatOrigin;
import static org.junit.jupiter.api.Assertions.*;
/**
* RFC-063r §2.13 (Issue #25 second symptom):
* {@link CronJobRunner#wrapWithDeliveryGuard} must prepend a system note
* for channel-bound cron runs and pass through web-origin runs unchanged.
*/
class CronJobRunnerDeliveryGuardTest {
@Test
void channelBoundCron_prependsDeliveryGuard() {
ChatOrigin channelOrigin = new ChatOrigin(
/* agentId */ 7L, "cron_7", "system", 1L, null,
/* channelId */ 9L, new ChannelTarget("group-a", null, null));
String input = "提醒我喝水并发到微信";
String wrapped = CronJobRunner.wrapWithDeliveryGuard(input, channelOrigin);
assertTrue(wrapped.contains("[系统说明]"),
"Channel-bound cron must include system note (RFC-063r §2.13)");
assertTrue(wrapped.contains("不要尝试调用 CLI"),
"system note must explicitly forbid CLI hallucination");
assertTrue(wrapped.endsWith(input),
"user message must be appended after the system note");
}
@Test
void webOriginCron_passesThroughUnchanged() {
ChatOrigin webOrigin = ChatOrigin.web("cron_1", "system", 1L, null);
String input = "Daily wiki update";
assertEquals(input, CronJobRunner.wrapWithDeliveryGuard(input, webOrigin),
"web-origin cron must keep pre-RFC behavior");
}
@Test
void emptyOrigin_passesThroughUnchanged() {
assertEquals("hello", CronJobRunner.wrapWithDeliveryGuard("hello", ChatOrigin.EMPTY));
assertEquals("hello", CronJobRunner.wrapWithDeliveryGuard("hello", null));
}
}

View File

@ -0,0 +1,108 @@
package vip.mate.hook.action;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.web.client.RestClient;
import java.net.URI;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* RFC-03 Lane H1 covers {@link HttpAction#hmacSign(String)} and the
* default-header convention used to deliver outbound webhook signatures.
*
* <p>Validating the signature on the receiver side requires the digest to be:
* <ol>
* <li>computed over the exact bytes that were sent (no JSON re-encode),</li>
* <li>formatted as {@code "sha256=<lowercase-hex>"} so off-the-shelf
* GitHub-style validators work without changes,</li>
* <li>deterministic same secret + same body always yields the same
* digest (no timestamp / nonce mixed in here).</li>
* </ol>
*
* <p>The reference vector is from RFC 4231 §4.7 (HMAC-SHA-256 with the
* canonical "Test 7" inputs) so any divergence from the standard surfaces
* here, not in production.
*/
class HttpActionHmacTest {
/** Build an HttpAction with the given secret; restClient is a no-op stub
* because hmacSign() doesn't touch it. */
private static HttpAction action(String secret) {
return new HttpAction(
RestClient.builder().build(),
"POST",
URI.create("https://hooks.example.com/test"),
null,
List.of("hooks.example.com"),
3000L,
secret,
null);
}
@Test
@DisplayName("hmacSign produces lowercase-hex 'sha256=<digest>' format")
void formatIsGitHubCompatible() {
String sig = action("secret").hmacSign("hello");
assertTrue(sig.startsWith("sha256="),
"header value must be sha256-prefixed for GitHub-compatible validators");
// SHA-256 hex digest is 64 lowercase chars, no separators.
String hex = sig.substring("sha256=".length());
assertEquals(64, hex.length());
assertTrue(hex.matches("[0-9a-f]+"),
"digest must be lowercase hex; got: " + hex);
}
@Test
@DisplayName("Wikipedia reference vector — known input → known digest")
void referenceVector() {
// From the canonical HMAC-SHA-256 worked example
// (Wikipedia "HMAC" article same input/output as Bruce Schneier's
// applied-cryptography vector). Hardcoding the expected digest catches
// any divergence from the JCA reference impl e.g. if someone later
// swaps in a third-party Mac or a Bouncy Castle provider that returns
// a different byte order.
String sig = action("key").hmacSign("The quick brown fox jumps over the lazy dog");
assertEquals(
"sha256=f7bc83f430538424b13298e6aa6fb143ef4d59a14946175997479dbc2d1a3cd8",
sig);
}
@Test
@DisplayName("same secret + same body → identical digest (deterministic)")
void deterministic() {
HttpAction a = action("shared-secret-123");
String first = a.hmacSign("{\"event\":\"agent.completed\"}");
String second = a.hmacSign("{\"event\":\"agent.completed\"}");
assertEquals(first, second);
}
@Test
@DisplayName("different secrets → different digests")
void secretMattersForDigest() {
String body = "{\"event\":\"x\"}";
String s1 = action("secret-A").hmacSign(body);
String s2 = action("secret-B").hmacSign(body);
assertTrue(!s1.equals(s2),
"swapping the secret must change the digest — otherwise signing is theatre");
}
@Test
@DisplayName("different body bytes → different digests")
void bodyMattersForDigest() {
HttpAction a = action("secret");
String s1 = a.hmacSign("{\"a\":1}");
String s2 = a.hmacSign("{\"a\":2}");
assertTrue(!s1.equals(s2),
"swapping a byte must change the digest — otherwise tampering goes undetected");
}
@Test
@DisplayName("default signature header constant matches MateClaw convention")
void defaultHeaderName() {
assertEquals("X-MateClaw-Signature", HttpAction.DEFAULT_SIGNATURE_HEADER);
}
}

View File

@ -0,0 +1,80 @@
package vip.mate.i18n;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.model.ToolContext;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.definition.ToolDefinition;
import org.springframework.ai.tool.metadata.ToolMetadata;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
/**
* RFC-063r §2.3 (P0): regression guard {@link LocaleAwareToolCallback} must
* forward both the input string and the ToolContext to the wrapped callback.
* Pre-fix this class only overrode {@code call(String)}, silently dropping the
* context (and thus ChatOrigin) for every builtin tool.
*/
class LocaleAwareToolCallbackToolContextTest {
@Test
void callWithToolContext_forwardsToDelegate() {
RecordingDelegate delegate = new RecordingDelegate();
LocaleAwareToolCallback decorator =
new LocaleAwareToolCallback(delegate, "本地化描述");
ToolContext ctx = new ToolContext(Map.of("k", "v"));
String out = decorator.call("{\"x\":1}", ctx);
assertEquals("ok", out);
assertEquals("{\"x\":1}", delegate.lastInput);
assertSame(ctx, delegate.lastContext,
"ToolContext must reach the underlying tool unchanged");
}
@Test
void getToolMetadata_isForwardedSoReturnDirectIsPreserved() {
ToolMetadata directMetadata = ToolMetadata.builder().returnDirect(true).build();
RecordingDelegate delegate = new RecordingDelegate();
delegate.metadata = directMetadata;
LocaleAwareToolCallback decorator = new LocaleAwareToolCallback(delegate, "本地化描述");
assertSame(directMetadata, decorator.getToolMetadata(),
"decorator must not flip returnDirect by inheriting the framework default");
}
private static final class RecordingDelegate implements ToolCallback {
String lastInput;
ToolContext lastContext;
ToolMetadata metadata = ToolMetadata.builder().build();
@Override
public ToolDefinition getToolDefinition() {
return ToolDefinition.builder()
.name("recording-tool")
.description("...")
.inputSchema("{}")
.build();
}
@Override
public ToolMetadata getToolMetadata() {
return metadata;
}
@Override
public String call(String toolInput) {
this.lastInput = toolInput;
this.lastContext = null;
return "ok";
}
@Override
public String call(String toolInput, ToolContext toolContext) {
this.lastInput = toolInput;
this.lastContext = toolContext;
return "ok";
}
}
}

View File

@ -0,0 +1,79 @@
package vip.mate.llm.anthropic.oauth;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Header-construction guarantees for OAuth-authenticated Anthropic requests.
*
* <p>The two non-negotiable invariants Anthropic's edge enforces:
* <ol>
* <li>{@code anthropic-beta} must contain both {@code claude-code-20250219}
* AND {@code oauth-2025-04-20}, comma-joined (no spaces).</li>
* <li>{@code User-Agent} must be the bare {@code claude-cli/<ver>}
* NOT {@code claude-cli/<ver> (external, cli)}. The {@code (external, cli)}
* suffix is what hermes-agent and other third-party clients append, and
* Anthropic uses it as a fingerprint to rate-limit the anti-abuse path.
* Real Claude Code emits the bare form via the official JS SDK.</li>
* </ol>
*/
class ClaudeCodeApiHeadersTest {
private ClaudeCodeApiHeaders headers;
@BeforeEach
void setUp() {
// Stub detector returns a stable version string so assertions stay deterministic.
ClaudeCodeVersionDetector stub = new ClaudeCodeVersionDetector() {
@Override
public String get() { return "2.1.114"; }
};
headers = new ClaudeCodeApiHeaders(stub);
}
@Test
@DisplayName("allBetas: common betas appear before OAuth-only betas (matches hermes-agent ordering)")
void allBetas_orderedCommonFirst() {
String result = headers.allBetas();
int oauthIdx = result.indexOf("oauth-2025-04-20");
int interleavedIdx = result.indexOf("interleaved-thinking-2025-05-14");
assertTrue(oauthIdx >= 0, "oauth beta missing");
assertTrue(interleavedIdx >= 0, "interleaved-thinking beta missing");
assertTrue(interleavedIdx < oauthIdx, "common betas must precede OAuth-only betas");
}
@Test
@DisplayName("allBetas: comma-joined with no whitespace")
void allBetas_commaJoined() {
String result = headers.allBetas();
// Anthropic's edge is strict a stray space breaks the header parser.
assertTrue(result.contains("claude-code-20250219"));
assertTrue(result.contains("oauth-2025-04-20"));
assertTrue(result.contains(","));
assertEquals(-1, result.indexOf(", "));
assertEquals(-1, result.indexOf(" ,"));
}
@Test
@DisplayName("userAgent: bare claude-cli/<ver> (no suffix — anti-abuse fingerprint)")
void userAgent_format() {
// Critical: must NOT contain "(external, cli)" see class javadoc.
assertEquals("claude-cli/2.1.114", headers.userAgent());
}
@Test
@DisplayName("xApp: returns the literal cli identifier")
void xApp() {
assertEquals("cli", headers.xApp());
}
@Test
@DisplayName("bearerAuth: prepends Bearer prefix exactly once")
void bearerAuth() {
assertEquals("Bearer abc123", headers.bearerAuth("abc123"));
}
}

View File

@ -0,0 +1,145 @@
package vip.mate.llm.anthropic.oauth;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Covers the JSON parsing path of {@link ClaudeCodeCredentialsReader}, which
* is the only path exercised on Linux/Windows servers. Keychain reading is
* a macOS-only ProcessBuilder integration left to manual / live testing.
*/
class ClaudeCodeCredentialsReaderTest {
private ClaudeCodeCredentialsReader reader;
@BeforeEach
void setUp() {
reader = new ClaudeCodeCredentialsReader(new ObjectMapper());
}
@Test
@DisplayName("parseCredentials extracts all fields from the canonical envelope")
void parseCredentials_fullPayload() {
String json = """
{
"claudeAiOauth": {
"accessToken": "sk-ant-oat01-test",
"refreshToken": "sk-ant-ort01-test",
"expiresAt": 1735689600000,
"scopes": ["user:inference", "user:profile"]
}
}
""";
Optional<ClaudeCodeCredentials> result =
reader.parseCredentials(json, ClaudeCodeCredentials.Source.CREDENTIALS_FILE);
assertTrue(result.isPresent());
ClaudeCodeCredentials c = result.get();
assertEquals("sk-ant-oat01-test", c.accessToken());
assertEquals("sk-ant-ort01-test", c.refreshToken());
assertEquals(1735689600000L, c.expiresAtMs());
assertEquals(ClaudeCodeCredentials.Source.CREDENTIALS_FILE, c.source());
}
@Test
@DisplayName("parseCredentials returns empty when claudeAiOauth missing")
void parseCredentials_missingEnvelope() {
// Some users have only {primaryApiKey: "..."} in ~/.claude.json that's
// an Anthropic console managed key, not OAuth, so we must NOT pretend
// it's a Claude Code credential.
Optional<ClaudeCodeCredentials> result = reader.parseCredentials(
"{\"primaryApiKey\":\"sk-ant-test\"}",
ClaudeCodeCredentials.Source.CREDENTIALS_FILE);
assertFalse(result.isPresent());
}
@Test
@DisplayName("parseCredentials returns empty when accessToken blank")
void parseCredentials_blankToken() {
String json = """
{ "claudeAiOauth": { "accessToken": "", "refreshToken": "rt" } }
""";
Optional<ClaudeCodeCredentials> result =
reader.parseCredentials(json, ClaudeCodeCredentials.Source.CREDENTIALS_FILE);
assertFalse(result.isPresent());
}
@Test
@DisplayName("parseCredentials handles missing refreshToken gracefully")
void parseCredentials_missingRefreshToken() {
// Older Claude Code versions wrote the access token without a refresh
// token. Reader must still surface those refresh just won't be possible.
String json = """
{ "claudeAiOauth": { "accessToken": "at-only", "expiresAt": 0 } }
""";
Optional<ClaudeCodeCredentials> result =
reader.parseCredentials(json, ClaudeCodeCredentials.Source.CREDENTIALS_FILE);
assertTrue(result.isPresent());
assertEquals("at-only", result.get().accessToken());
assertFalse(result.get().canRefresh());
}
@Test
@DisplayName("parseCredentials rejects malformed JSON without throwing")
void parseCredentials_badJson() {
Optional<ClaudeCodeCredentials> result = reader.parseCredentials(
"{not json", ClaudeCodeCredentials.Source.CREDENTIALS_FILE);
assertFalse(result.isPresent());
}
@Test
@DisplayName("parseCredentials returns empty for null/blank input")
void parseCredentials_blankInput() {
assertFalse(reader.parseCredentials(null, ClaudeCodeCredentials.Source.CREDENTIALS_FILE).isPresent());
assertFalse(reader.parseCredentials("", ClaudeCodeCredentials.Source.CREDENTIALS_FILE).isPresent());
assertFalse(reader.parseCredentials(" ", ClaudeCodeCredentials.Source.CREDENTIALS_FILE).isPresent());
}
@Test
@DisplayName("readFromJsonFile returns empty for missing path")
void readFromJsonFile_missing(@TempDir Path tmp) {
Path absent = tmp.resolve("nonexistent.json");
assertFalse(reader.readFromJsonFile(absent).isPresent());
}
@Test
@DisplayName("readFromJsonFile reads + parses an existing file")
void readFromJsonFile_present(@TempDir Path tmp) throws IOException {
Path file = tmp.resolve(".credentials.json");
Files.writeString(file, """
{ "claudeAiOauth": {
"accessToken": "from-file",
"refreshToken": "rt-from-file",
"expiresAt": 0
} }
""", StandardCharsets.UTF_8);
Optional<ClaudeCodeCredentials> result = reader.readFromJsonFile(file);
assertTrue(result.isPresent());
assertEquals("from-file", result.get().accessToken());
assertEquals(ClaudeCodeCredentials.Source.CREDENTIALS_FILE, result.get().source());
}
@Test
@DisplayName("readFromKeychain returns empty on non-macOS hosts")
void readFromKeychain_nonMacOs() {
// Override isMacOs() to false so the test passes regardless of CI host.
ClaudeCodeCredentialsReader linux = new ClaudeCodeCredentialsReader(new ObjectMapper()) {
@Override
boolean isMacOs() { return false; }
};
assertFalse(linux.readFromKeychain().isPresent());
}
}

View File

@ -0,0 +1,57 @@
package vip.mate.llm.anthropic.oauth;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Validates the pure-data invariants of {@link ClaudeCodeCredentials}
* specifically the {@code isValid(buffer)} expiry math and {@code canRefresh}
* predicate. Exercising these here means downstream services can rely on the
* record without re-implementing the same checks.
*/
class ClaudeCodeCredentialsTest {
@Test
@DisplayName("isValid: blank access token always invalid")
void isValid_blankToken_false() {
assertFalse(creds("", "rt", System.currentTimeMillis() + 60_000).isValid(0L));
assertFalse(creds(null, "rt", System.currentTimeMillis() + 60_000).isValid(0L));
}
@Test
@DisplayName("isValid: expiresAt=0 means no expiry — always valid when token present")
void isValid_zeroExpiry_alwaysValid() {
assertTrue(creds("at", "rt", 0L).isValid(60_000L));
}
@Test
@DisplayName("isValid: returns false within buffer window")
void isValid_withinBuffer_false() {
long now = System.currentTimeMillis();
// Token expires in 30s; buffer is 60s invalid (must refresh before expiry).
assertFalse(creds("at", "rt", now + 30_000L).isValid(60_000L));
}
@Test
@DisplayName("isValid: returns true outside buffer window")
void isValid_outsideBuffer_true() {
long now = System.currentTimeMillis();
// Token expires in 5 minutes; 60s buffer still valid.
assertTrue(creds("at", "rt", now + 300_000L).isValid(60_000L));
}
@Test
@DisplayName("canRefresh: requires non-blank refresh token")
void canRefresh() {
assertTrue(creds("at", "rt", 0L).canRefresh());
assertFalse(creds("at", "", 0L).canRefresh());
assertFalse(creds("at", null, 0L).canRefresh());
}
private static ClaudeCodeCredentials creds(String at, String rt, long expiresAt) {
return new ClaudeCodeCredentials(at, rt, expiresAt, ClaudeCodeCredentials.Source.CREDENTIALS_FILE);
}
}

View File

@ -0,0 +1,182 @@
package vip.mate.llm.anthropic.oauth;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Validates the JSON-file write path of {@link ClaudeCodeCredentialsWriter},
* with focus on the two correctness-critical behaviors:
*
* <ol>
* <li>Concurrent-write defence: when Claude Code itself rewrites the file
* while MateClaw is mid-refresh, the writer must NOT clobber.</li>
* <li>Scope preservation: the writer must keep the {@code scopes} array
* (Claude Code &gt;= 2.1.81 needs {@code user:inference} or it shows
* the user as logged-out).</li>
* </ol>
*/
class ClaudeCodeCredentialsWriterTest {
private ObjectMapper mapper;
private ClaudeCodeCredentialsWriter writer;
@BeforeEach
void setUp() {
mapper = new ObjectMapper();
writer = new ClaudeCodeCredentialsWriter(mapper);
}
@Test
@DisplayName("writeJsonFile creates a new file when none exists")
void writeJsonFile_createsNew(@TempDir Path tmp) throws IOException {
Path target = tmp.resolve(".credentials.json");
ClaudeCodeCredentials fresh = new ClaudeCodeCredentials(
"new-access", "new-refresh", 9_999_999_999L,
ClaudeCodeCredentials.Source.CREDENTIALS_FILE);
boolean ok = writer.writeJsonFile(target, null, fresh);
assertTrue(ok);
assertTrue(Files.exists(target));
JsonNode root = mapper.readTree(Files.readString(target, StandardCharsets.UTF_8));
JsonNode oauth = root.path("claudeAiOauth");
assertEquals("new-access", oauth.path("accessToken").asText());
assertEquals("new-refresh", oauth.path("refreshToken").asText());
assertEquals(9_999_999_999L, oauth.path("expiresAt").asLong());
// Default scope must be present so Claude Code 2.1.81+ keeps recognising
// the credential after MateClaw writes to it.
assertTrue(oauth.path("scopes").isArray());
assertEquals("user:inference", oauth.path("scopes").get(0).asText());
}
@Test
@DisplayName("writeJsonFile preserves existing scopes")
void writeJsonFile_preservesScopes(@TempDir Path tmp) throws IOException {
Path target = tmp.resolve(".credentials.json");
Files.writeString(target, """
{ "claudeAiOauth": {
"accessToken": "old-token",
"refreshToken": "old-refresh",
"expiresAt": 1,
"scopes": ["user:inference", "user:profile", "extra:scope"]
} }
""", StandardCharsets.UTF_8);
ClaudeCodeCredentials fresh = new ClaudeCodeCredentials(
"new-access", "new-refresh", 9_999_999_999L,
ClaudeCodeCredentials.Source.CREDENTIALS_FILE);
boolean ok = writer.writeJsonFile(target, "old-token", fresh);
assertTrue(ok);
JsonNode oauth = mapper.readTree(Files.readString(target, StandardCharsets.UTF_8))
.path("claudeAiOauth");
assertEquals("new-access", oauth.path("accessToken").asText());
// All three original scopes survive the writer mutates only the
// fields it owns (access/refresh/expiresAt).
assertEquals(3, oauth.path("scopes").size());
assertEquals("user:profile", oauth.path("scopes").get(1).asText());
assertEquals("extra:scope", oauth.path("scopes").get(2).asText());
}
@Test
@DisplayName("writeJsonFile preserves unknown top-level fields")
void writeJsonFile_preservesUnknownFields(@TempDir Path tmp) throws IOException {
// Defends against future Claude Code releases that add new fields:
// we must not strip them on rewrite.
Path target = tmp.resolve(".credentials.json");
Files.writeString(target, """
{
"claudeAiOauth": { "accessToken": "x", "expiresAt": 1 },
"futureField": { "foo": "bar" }
}
""", StandardCharsets.UTF_8);
ClaudeCodeCredentials fresh = new ClaudeCodeCredentials(
"new-access", "new-refresh", 100L,
ClaudeCodeCredentials.Source.CREDENTIALS_FILE);
writer.writeJsonFile(target, "x", fresh);
JsonNode root = mapper.readTree(Files.readString(target, StandardCharsets.UTF_8));
assertEquals("bar", root.path("futureField").path("foo").asText());
}
@Test
@DisplayName("writeJsonFile bails out when on-disk token already changed")
void writeJsonFile_concurrentWriteDetected(@TempDir Path tmp) throws IOException {
// Simulate: MateClaw started a refresh from token "T1", Claude Code
// beat us to it and wrote "T2". MateClaw must NOT overwrite.
Path target = tmp.resolve(".credentials.json");
Files.writeString(target, """
{ "claudeAiOauth": {
"accessToken": "T2",
"refreshToken": "rt2",
"expiresAt": 99,
"scopes": ["user:inference"]
} }
""", StandardCharsets.UTF_8);
ClaudeCodeCredentials fresh = new ClaudeCodeCredentials(
"T3", "rt3", 100L,
ClaudeCodeCredentials.Source.CREDENTIALS_FILE);
boolean ok = writer.writeJsonFile(target, "T1", fresh);
assertFalse(ok, "writer must refuse to overwrite a concurrently-updated file");
// Disk contents unchanged
JsonNode oauth = mapper.readTree(Files.readString(target, StandardCharsets.UTF_8))
.path("claudeAiOauth");
assertEquals("T2", oauth.path("accessToken").asText());
}
@Test
@DisplayName("writeJsonFile proceeds when previousAccessToken is null (first-time write)")
void writeJsonFile_nullPrevious_proceeds(@TempDir Path tmp) throws IOException {
Path target = tmp.resolve(".credentials.json");
Files.writeString(target, """
{ "claudeAiOauth": { "accessToken": "existing", "scopes": ["user:inference"] } }
""", StandardCharsets.UTF_8);
ClaudeCodeCredentials fresh = new ClaudeCodeCredentials(
"fresh-token", "fresh-refresh", 0L,
ClaudeCodeCredentials.Source.CREDENTIALS_FILE);
// Null previous caller doesn't have a baseline (e.g. first import)
// skip concurrency check and just write.
assertTrue(writer.writeJsonFile(target, null, fresh));
JsonNode oauth = mapper.readTree(Files.readString(target, StandardCharsets.UTF_8))
.path("claudeAiOauth");
assertEquals("fresh-token", oauth.path("accessToken").asText());
}
@Test
@DisplayName("write rejects blank access tokens")
void write_rejectsBlankToken() {
ClaudeCodeCredentials blank = new ClaudeCodeCredentials(
" ", "rt", 0L, ClaudeCodeCredentials.Source.CREDENTIALS_FILE);
assertFalse(writer.write(null, blank));
}
@Test
@DisplayName("writeKeychain returns false on non-macOS hosts")
void writeKeychain_nonMacOs() {
ClaudeCodeCredentialsWriter linux = new ClaudeCodeCredentialsWriter(mapper) {
@Override
boolean isMacOs() { return false; }
};
ClaudeCodeCredentials creds = new ClaudeCodeCredentials(
"at", "rt", 0L, ClaudeCodeCredentials.Source.MACOS_KEYCHAIN);
assertFalse(linux.writeKeychain(null, creds));
}
}

View File

@ -0,0 +1,225 @@
package vip.mate.llm.anthropic.oauth;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import vip.mate.exception.MateClawException;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Tests the orchestration logic of {@link ClaudeCodeOAuthService} the
* decision tree for "return cached token" / "refresh + persist" / "fail with
* actionable error". Uses test-double subclasses for Reader / Refresher /
* Writer to avoid hitting the filesystem or network.
*/
class ClaudeCodeOAuthServiceTest {
private ObjectMapper mapper;
@BeforeEach
void setUp() {
mapper = new ObjectMapper();
}
@Test
@DisplayName("getValidToken returns existing token when still valid")
void getValidToken_cached() {
ClaudeCodeCredentials valid = new ClaudeCodeCredentials(
"still-good", "rt", System.currentTimeMillis() + 600_000L,
ClaudeCodeCredentials.Source.CREDENTIALS_FILE);
ClaudeCodeOAuthService svc = serviceWith(valid, /* refreshShouldBeCalled */ false);
assertEquals("still-good", svc.getValidToken());
}
@Test
@DisplayName("getValidToken refreshes when within buffer window")
void getValidToken_refreshesNearExpiry() {
// Token expires in 30s; buffer is 60s must refresh.
ClaudeCodeCredentials nearExpiry = new ClaudeCodeCredentials(
"old-token", "rt", System.currentTimeMillis() + 30_000L,
ClaudeCodeCredentials.Source.CREDENTIALS_FILE);
AtomicReference<String> capturedPreviousToken = new AtomicReference<>();
AtomicReference<ClaudeCodeCredentials> capturedWritten = new AtomicReference<>();
ClaudeCodeCredentialsReader reader = stubReader(nearExpiry);
ClaudeCodeTokenRefresher refresher = stubRefresher(rt -> new ClaudeCodeCredentials(
"fresh-token", "fresh-rt", System.currentTimeMillis() + 3_600_000L,
ClaudeCodeCredentials.Source.REFRESH_RESPONSE));
ClaudeCodeCredentialsWriter writer = stubWriter((prev, creds) -> {
capturedPreviousToken.set(prev);
capturedWritten.set(creds);
return true;
});
ClaudeCodeOAuthService svc = new ClaudeCodeOAuthService(reader, refresher, writer);
assertEquals("fresh-token", svc.getValidToken());
// Writer must receive the prior access token (for concurrency check)
// AND the credential pinned to the original source not REFRESH_RESPONSE.
assertEquals("old-token", capturedPreviousToken.get());
assertNotNull(capturedWritten.get());
assertEquals("fresh-token", capturedWritten.get().accessToken());
assertEquals(ClaudeCodeCredentials.Source.CREDENTIALS_FILE, capturedWritten.get().source(),
"write must target the source the credential was originally read from");
}
@Test
@DisplayName("getValidToken throws actionable error when no credentials on disk")
void getValidToken_noCredentials() {
ClaudeCodeOAuthService svc = new ClaudeCodeOAuthService(
stubReader(null),
stubRefresher(rt -> { throw new IllegalStateException("should not be called"); }),
stubWriter((prev, creds) -> { throw new IllegalStateException("should not be called"); }));
MateClawException ex = assertThrows(MateClawException.class, svc::getValidToken);
assertEquals("err.anthropic.no_claude_code", ex.getMsgKey());
}
@Test
@DisplayName("getValidToken throws when token expired and no refresh available")
void getValidToken_expiredNoRefresh() {
ClaudeCodeCredentials expired = new ClaudeCodeCredentials(
"expired", "", System.currentTimeMillis() - 60_000L,
ClaudeCodeCredentials.Source.CREDENTIALS_FILE);
ClaudeCodeOAuthService svc = serviceWith(expired, false);
MateClawException ex = assertThrows(MateClawException.class, svc::getValidToken);
assertEquals("err.anthropic.token_expired_no_refresh", ex.getMsgKey());
}
@Test
@DisplayName("getValidToken still returns fresh token when persistence fails")
void getValidToken_writeFailureNonFatal() {
// Writer returning false (e.g. concurrent-write detected) must NOT
// turn into a request failure the in-memory token is still good.
ClaudeCodeCredentials nearExpiry = new ClaudeCodeCredentials(
"stale", "rt", System.currentTimeMillis() - 60_000L,
ClaudeCodeCredentials.Source.CREDENTIALS_FILE);
ClaudeCodeOAuthService svc = new ClaudeCodeOAuthService(
stubReader(nearExpiry),
stubRefresher(rt -> new ClaudeCodeCredentials(
"refreshed", "rt2", System.currentTimeMillis() + 600_000L,
ClaudeCodeCredentials.Source.REFRESH_RESPONSE)),
stubWriter((prev, creds) -> false));
assertEquals("refreshed", svc.getValidToken());
}
@Test
@DisplayName("isLoggedIn reflects on-disk state without triggering refresh")
void isLoggedIn() {
ClaudeCodeCredentials valid = new ClaudeCodeCredentials(
"tok", "rt", System.currentTimeMillis() + 600_000L,
ClaudeCodeCredentials.Source.CREDENTIALS_FILE);
assertTrue(serviceWith(valid, false).isLoggedIn());
// Expired token not logged in (we don't auto-refresh from a status check).
ClaudeCodeCredentials expired = new ClaudeCodeCredentials(
"tok", "rt", System.currentTimeMillis() - 60_000L,
ClaudeCodeCredentials.Source.CREDENTIALS_FILE);
assertFalse(serviceWith(expired, false).isLoggedIn());
// No file not logged in.
ClaudeCodeOAuthService noCreds = new ClaudeCodeOAuthService(
stubReader(null),
stubRefresher(rt -> { throw new IllegalStateException(); }),
stubWriter((p, c) -> { throw new IllegalStateException(); }));
assertFalse(noCreds.isLoggedIn());
}
@Test
@DisplayName("getStatus surfaces source + expiry without exposing the token")
void getStatus_disconnected() {
ClaudeCodeOAuthService svc = new ClaudeCodeOAuthService(
stubReader(null),
stubRefresher(rt -> { throw new IllegalStateException(); }),
stubWriter((p, c) -> { throw new IllegalStateException(); }));
ClaudeCodeOAuthService.OAuthStatus status = svc.getStatus();
assertFalse(status.connected());
assertFalse(status.expired());
}
@Test
@DisplayName("getStatus reports expired flag correctly")
void getStatus_expired() {
ClaudeCodeCredentials expired = new ClaudeCodeCredentials(
"tok", "rt", System.currentTimeMillis() - 1_000L,
ClaudeCodeCredentials.Source.MACOS_KEYCHAIN);
ClaudeCodeOAuthService svc = serviceWith(expired, false);
ClaudeCodeOAuthService.OAuthStatus status = svc.getStatus();
assertTrue(status.connected());
assertTrue(status.expired());
assertEquals(ClaudeCodeCredentials.Source.MACOS_KEYCHAIN, status.source());
}
/* ---------- Test-double helpers ---------- */
/** Build a service whose reader returns the given credentials and whose refresher/writer fail loudly if invoked. */
private ClaudeCodeOAuthService serviceWith(ClaudeCodeCredentials creds, boolean expectRefresh) {
return new ClaudeCodeOAuthService(
stubReader(creds),
stubRefresher(rt -> {
if (!expectRefresh) {
throw new IllegalStateException("refresher should not have been called");
}
return new ClaudeCodeCredentials("refreshed", "rt2",
System.currentTimeMillis() + 3_600_000L,
ClaudeCodeCredentials.Source.REFRESH_RESPONSE);
}),
stubWriter((prev, c) -> {
if (!expectRefresh) {
throw new IllegalStateException("writer should not have been called");
}
return true;
}));
}
private ClaudeCodeCredentialsReader stubReader(ClaudeCodeCredentials toReturn) {
return new ClaudeCodeCredentialsReader(mapper) {
@Override
public Optional<ClaudeCodeCredentials> read() {
return Optional.ofNullable(toReturn);
}
};
}
@FunctionalInterface
private interface RefreshFn {
ClaudeCodeCredentials apply(String refreshToken);
}
private ClaudeCodeTokenRefresher stubRefresher(RefreshFn fn) {
ClaudeCodeVersionDetector ver = new ClaudeCodeVersionDetector() {
@Override
public String get() { return "2.1.114"; }
};
return new ClaudeCodeTokenRefresher(mapper, ver) {
@Override
public ClaudeCodeCredentials refresh(String refreshToken) {
return fn.apply(refreshToken);
}
};
}
@FunctionalInterface
private interface WriteFn {
boolean apply(String previousAccessToken, ClaudeCodeCredentials creds);
}
private ClaudeCodeCredentialsWriter stubWriter(WriteFn fn) {
return new ClaudeCodeCredentialsWriter(mapper) {
@Override
public boolean write(String previousAccessToken, ClaudeCodeCredentials refreshed) {
return fn.apply(previousAccessToken, refreshed);
}
};
}
}

View File

@ -0,0 +1,115 @@
package vip.mate.llm.anthropic.oauth;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import vip.mate.exception.MateClawException;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Validates the response-parsing logic of {@link ClaudeCodeTokenRefresher}.
* Network-bound paths (the actual POST to platform.claude.com) require either
* a wiremock or live fixtures and are out of scope for unit tests.
*/
class ClaudeCodeTokenRefresherTest {
private ClaudeCodeTokenRefresher refresher;
@BeforeEach
void setUp() {
ClaudeCodeVersionDetector versionStub = new ClaudeCodeVersionDetector() {
@Override
public String get() { return "2.1.114"; }
};
refresher = new ClaudeCodeTokenRefresher(new ObjectMapper(), versionStub);
}
@Test
@DisplayName("parseTokenResponse handles standard expires_in seconds")
void parseTokenResponse_expiresIn() {
long before = System.currentTimeMillis();
String body = """
{ "access_token": "fresh-at", "refresh_token": "fresh-rt", "expires_in": 3600 }
""";
ClaudeCodeCredentials c = refresher.parseTokenResponse(body, "old-rt");
assertEquals("fresh-at", c.accessToken());
assertEquals("fresh-rt", c.refreshToken());
// expires_in=3600 expiresAt should be ~1h from now.
long expectedMin = before + 3_590_000L;
long expectedMax = System.currentTimeMillis() + 3_610_000L;
assertTrue(c.expiresAtMs() >= expectedMin && c.expiresAtMs() <= expectedMax,
"expiresAtMs " + c.expiresAtMs() + " out of expected range");
assertEquals(ClaudeCodeCredentials.Source.REFRESH_RESPONSE, c.source());
}
@Test
@DisplayName("parseTokenResponse uses absolute expires_at when provided")
void parseTokenResponse_expiresAtMs() {
// Some Anthropic deployments return expires_at as an absolute ms value.
String body = """
{ "access_token": "at2", "expires_at": 1234567890000 }
""";
ClaudeCodeCredentials c = refresher.parseTokenResponse(body, "old-rt");
assertEquals(1234567890000L, c.expiresAtMs());
}
@Test
@DisplayName("parseTokenResponse falls back to old refresh_token when response omits one")
void parseTokenResponse_keepsOldRefreshToken() {
// Anthropic docs say refresh_token may be omitted on rotation-disabled
// grants. We must NOT lose the original; otherwise the next refresh fails.
String body = """
{ "access_token": "at3", "expires_in": 3600 }
""";
ClaudeCodeCredentials c = refresher.parseTokenResponse(body, "preserved-rt");
assertEquals("preserved-rt", c.refreshToken());
}
@Test
@DisplayName("parseTokenResponse rejects blank access_token")
void parseTokenResponse_blankToken_throws() {
// Edge case where Anthropic returns 200 with empty access_token
// surface as a domain error rather than persisting garbage.
String body = """
{ "access_token": "", "expires_in": 3600 }
""";
MateClawException ex = assertThrows(MateClawException.class,
() -> refresher.parseTokenResponse(body, "rt"));
assertEquals("err.anthropic.refresh_failed", ex.getMsgKey());
}
@Test
@DisplayName("parseTokenResponse wraps malformed JSON")
void parseTokenResponse_badJson_throws() {
MateClawException ex = assertThrows(MateClawException.class,
() -> refresher.parseTokenResponse("not-json", "rt"));
assertEquals("err.anthropic.refresh_failed", ex.getMsgKey());
}
@Test
@DisplayName("refresh rejects blank refresh_token without making a network call")
void refresh_blankInput_throws() {
MateClawException ex = assertThrows(MateClawException.class,
() -> refresher.refresh(""));
// No network call made the failure mode here is "no refresh available",
// not "refresh attempt failed".
assertNotEquals("err.anthropic.refresh_failed", ex.getMsgKey());
assertEquals("err.anthropic.token_expired_no_refresh", ex.getMsgKey());
}
@Test
@DisplayName("ENDPOINTS includes both platform.claude.com and console.anthropic.com")
void endpoints_haveBothHosts() {
// Constants pinned by RFC-062. If Anthropic deprecates one, change here
// AND in the RFC; do not silently drop a fallback.
assertTrue(ClaudeCodeTokenRefresher.ENDPOINTS.stream()
.anyMatch(s -> s.contains("platform.claude.com")));
assertTrue(ClaudeCodeTokenRefresher.ENDPOINTS.stream()
.anyMatch(s -> s.contains("console.anthropic.com")));
}
}

View File

@ -0,0 +1,59 @@
package vip.mate.llm.anthropic.oauth;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
/**
* Static-helper coverage for {@link ClaudeCodeVersionDetector#parseVersion}.
*
* <p>The {@code claude --version} output format has shifted between Claude Code
* releases (early builds prefixed with the binary name; recent ones print just
* the number). The regex must match both so MateClaw stays in sync without
* manual config when users upgrade.
*/
class ClaudeCodeVersionDetectorTest {
@Test
@DisplayName("parseVersion accepts the modern bare-number format")
void parseVersion_modern() {
assertEquals("2.1.114", ClaudeCodeVersionDetector.parseVersion("2.1.114"));
assertEquals("2.1.74", ClaudeCodeVersionDetector.parseVersion("2.1.74\n"));
}
@Test
@DisplayName("parseVersion ignores trailing whitespace and extra suffix")
void parseVersion_withSuffix() {
assertEquals("2.1.114", ClaudeCodeVersionDetector.parseVersion("2.1.114 (Claude Code)"));
assertEquals("2.1.114", ClaudeCodeVersionDetector.parseVersion(" 2.1.114 "));
}
@Test
@DisplayName("parseVersion accepts a two-segment version")
void parseVersion_twoSegments() {
// Some legacy --version outputs printed only major.minor.
assertEquals("2.1", ClaudeCodeVersionDetector.parseVersion("2.1"));
}
@Test
@DisplayName("parseVersion rejects non-numeric prefixes")
void parseVersion_rejectsNonNumeric() {
assertNull(ClaudeCodeVersionDetector.parseVersion("claude-code v2.1.114"));
assertNull(ClaudeCodeVersionDetector.parseVersion(""));
assertNull(ClaudeCodeVersionDetector.parseVersion(null));
assertNull(ClaudeCodeVersionDetector.parseVersion("not a version"));
}
@Test
@DisplayName("FALLBACK_VERSION constant is a real semver-shape string")
void fallbackVersion_isSemver() {
// Sanity-check the static fallback so a bad edit (e.g. typo) is caught
// before it ships in a User-Agent header.
String parsed = ClaudeCodeVersionDetector.parseVersion(ClaudeCodeVersionDetector.FALLBACK_VERSION);
assertNotNull(parsed);
assertEquals(ClaudeCodeVersionDetector.FALLBACK_VERSION, parsed);
}
}

View File

@ -0,0 +1,71 @@
package vip.mate.llm.chatmodel;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.time.Duration;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* RFC-03 Lane B1 covers {@link HttpTimeouts#resolveReadTimeout(Integer)},
* the central resolver that backs {@code mate_model_config.request_timeout_seconds}.
*
* <p>Behavioral contract under test:
* <ul>
* <li>null / non-positive 180s (the historical hardcoded default; preserves
* behavior for every existing row before V75 ran).</li>
* <li>positive integer that many seconds, no clamp (caller decides
* reasonable upper bound at the model-config level we don't want to
* silently rewrite a user's deliberate 30-min override).</li>
* <li>connect timeout stays at 10s and is never overridable long-tail
* latency manifests on the read path, not on connect.</li>
* </ul>
*/
class HttpTimeoutsTest {
@Test
@DisplayName("null override → default 180s read timeout")
void nullFallsBack() {
assertEquals(Duration.ofSeconds(180),
HttpTimeouts.resolveReadTimeout(null));
}
@Test
@DisplayName("zero → default 180s (treated as unset)")
void zeroFallsBack() {
assertEquals(Duration.ofSeconds(180),
HttpTimeouts.resolveReadTimeout(0));
}
@Test
@DisplayName("negative → default 180s (defensively treats nonsense values as unset)")
void negativeFallsBack() {
assertEquals(Duration.ofSeconds(180),
HttpTimeouts.resolveReadTimeout(-30));
}
@Test
@DisplayName("positive integer → exact seconds, no clamp on either side")
void positiveHonored() {
assertEquals(Duration.ofSeconds(30),
HttpTimeouts.resolveReadTimeout(30));
assertEquals(Duration.ofSeconds(600),
HttpTimeouts.resolveReadTimeout(600));
// o1-pro / claude opus extended-thinking can legitimately need 30 min.
assertEquals(Duration.ofSeconds(1800),
HttpTimeouts.resolveReadTimeout(1800));
}
@Test
@DisplayName("connect timeout is the canonical 10s")
void connectTimeoutIsCanonical() {
assertEquals(Duration.ofSeconds(10), HttpTimeouts.CONNECT_TIMEOUT);
}
@Test
@DisplayName("default read timeout matches the legacy hardcoded 180s")
void defaultMatchesLegacy() {
assertEquals(Duration.ofSeconds(180), HttpTimeouts.DEFAULT_READ_TIMEOUT);
}
}

View File

@ -235,6 +235,9 @@ class ProviderInitProbeTest {
p.setChatModel(protocol.getChatModelClass());
p.setApiKey("sk-test");
p.setBaseUrl("https://example.com");
// RFC-074: probe filters out enabled=false rows. The pre-RFC-074 default
// for these test fixtures was "everything participates" preserve that.
p.setEnabled(true);
return p;
}

View File

@ -0,0 +1,179 @@
package vip.mate.llm.failover;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import vip.mate.llm.model.ModelProviderEntity;
import static org.junit.jupiter.api.Assertions.*;
/**
* Issue #81: row-based required-fields decision. Replaces the v1 protocol-keyed
* lookup, which couldn't tell OpenAI cloud (needs api_key) apart from llama.cpp
* local (needs base_url) because both ride the OPENAI_COMPATIBLE protocol enum.
*
* <p>Each test is one cell of the truth table in the RFC §2.2 / §2.3.
*/
class ProviderRequirementsTest {
@Test
@DisplayName("OpenAI cloud: needs api key, no base url, no hint")
void openaiCloud() {
ProviderRequirements.Required r = ProviderRequirements.of(cloud("openai", true));
assertTrue(r.needsApiKey());
assertFalse(r.needsBaseUrl());
assertNull(r.hintKey());
}
@Test
@DisplayName("Kimi cloud: same shape as OpenAI")
void kimiCloud() {
ProviderRequirements.Required r = ProviderRequirements.of(cloud("kimi", true));
assertTrue(r.needsApiKey());
assertFalse(r.needsBaseUrl());
}
@Test
@DisplayName("DeepSeek cloud: same shape")
void deepseekCloud() {
ProviderRequirements.Required r = ProviderRequirements.of(cloud("deepseek", true));
assertTrue(r.needsApiKey());
assertFalse(r.needsBaseUrl());
}
@Test
@DisplayName("llama.cpp local: no api key, needs base url, llamacpp hint")
void llamacppLocal() {
ProviderRequirements.Required r = ProviderRequirements.of(local("llamacpp"));
assertFalse(r.needsApiKey());
assertTrue(r.needsBaseUrl());
assertEquals("provider.hint.llamacppBaseUrlExample", r.hintKey());
assertEquals("http://127.0.0.1:8080/v1", r.hintArgs().get("example"));
}
@Test
@DisplayName("Ollama local: ollama-specific hint")
void ollamaLocal() {
ProviderRequirements.Required r = ProviderRequirements.of(local("ollama"));
assertFalse(r.needsApiKey());
assertTrue(r.needsBaseUrl());
assertEquals("provider.hint.ollamaBaseUrlExample", r.hintKey());
assertEquals("http://127.0.0.1:11434", r.hintArgs().get("example"));
}
@Test
@DisplayName("LM Studio local: lmstudio-specific hint, also matches lm-studio / lm_studio")
void lmstudioLocal() {
ProviderRequirements.Required r = ProviderRequirements.of(local("lmstudio"));
assertEquals("provider.hint.lmstudioBaseUrlExample", r.hintKey());
assertEquals("provider.hint.lmstudioBaseUrlExample",
ProviderRequirements.of(local("lm-studio")).hintKey());
assertEquals("provider.hint.lmstudioBaseUrlExample",
ProviderRequirements.of(local("lm_studio")).hintKey());
}
@Test
@DisplayName("vLLM local: vllm-specific hint")
void vllmLocal() {
ProviderRequirements.Required r = ProviderRequirements.of(local("vllm"));
assertEquals("provider.hint.vllmBaseUrlExample", r.hintKey());
assertEquals("http://127.0.0.1:8000/v1", r.hintArgs().get("example"));
}
@Test
@DisplayName("Custom OpenAI-compat needing API key: needs both, generic hint")
void customOpenAiCompatNeedingKey() {
ModelProviderEntity p = new ModelProviderEntity();
p.setProviderId("my-llm-server");
p.setIsCustom(true);
p.setIsLocal(false);
p.setRequireApiKey(true);
ProviderRequirements.Required r = ProviderRequirements.of(p);
assertTrue(r.needsApiKey());
assertTrue(r.needsBaseUrl());
assertEquals("provider.hint.openaiCompatBaseUrlExample", r.hintKey());
}
@Test
@DisplayName("Custom OpenAI-compat without API key: only base url + generic hint")
void customOpenAiCompatNoKey() {
ModelProviderEntity p = new ModelProviderEntity();
p.setProviderId("my-llm-server");
p.setIsCustom(true);
p.setRequireApiKey(false);
ProviderRequirements.Required r = ProviderRequirements.of(p);
assertFalse(r.needsApiKey());
assertTrue(r.needsBaseUrl());
assertEquals("provider.hint.openaiCompatBaseUrlExample", r.hintKey());
}
@Test
@DisplayName("OAuth provider: no api key, no base url, no hint")
void oauthProvider() {
ModelProviderEntity p = new ModelProviderEntity();
p.setProviderId("anthropic-claude-code");
p.setAuthType("oauth");
p.setRequireApiKey(true); // ignored under oauth
p.setIsLocal(true); // ignored under oauth
ProviderRequirements.Required r = ProviderRequirements.of(p);
assertFalse(r.needsApiKey());
assertFalse(r.needsBaseUrl());
assertNull(r.hintKey());
}
@Test
@DisplayName("Generic OAuth (non-Claude-Code): same shape")
void genericOauth() {
ModelProviderEntity p = new ModelProviderEntity();
p.setProviderId("some-oauth-provider");
p.setAuthType("oauth");
ProviderRequirements.Required r = ProviderRequirements.of(p);
assertFalse(r.needsApiKey());
assertFalse(r.needsBaseUrl());
}
@Test
@DisplayName("Null provider: safe defaults")
void nullProvider() {
ProviderRequirements.Required r = ProviderRequirements.of(null);
assertFalse(r.needsApiKey());
assertFalse(r.needsBaseUrl());
assertNull(r.hintKey());
assertNotNull(r.hintArgs());
}
@Test
@DisplayName("isCustom=true with empty providerId: still needs base url, generic hint")
void customEmptyProviderId() {
ModelProviderEntity p = new ModelProviderEntity();
p.setIsCustom(true);
p.setRequireApiKey(false);
ProviderRequirements.Required r = ProviderRequirements.of(p);
assertTrue(r.needsBaseUrl());
assertEquals("provider.hint.openaiCompatBaseUrlExample", r.hintKey());
}
// ===== helpers =====
private static ModelProviderEntity cloud(String id, boolean requireApiKey) {
ModelProviderEntity p = new ModelProviderEntity();
p.setProviderId(id);
p.setIsLocal(false);
p.setIsCustom(false);
p.setRequireApiKey(requireApiKey);
return p;
}
private static ModelProviderEntity local(String id) {
ModelProviderEntity p = new ModelProviderEntity();
p.setProviderId(id);
p.setIsLocal(true);
p.setIsCustom(false);
p.setRequireApiKey(false);
return p;
}
}

View File

@ -0,0 +1,68 @@
package vip.mate.llm.model;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Pinpoint regression tests for {@link ModelFamily#detect(String)}.
*
* <p>Each new model family added here should pin its detect rule so accidental
* code-style cleanups (e.g. reordering branches in {@code detect()}) can't
* silently route a thinking model to {@link ModelFamily#STANDARD} and break
* reasoning_effort propagation.
*/
class ModelFamilyTest {
@Test
@DisplayName("DeepSeek V4 (flash + pro) → DEEPSEEK_V4_REASONING with reasoning_effort enabled")
void deepSeekV4_reasoning() {
// Critical assertion: V4 differs from v3.2 deepseek-reasoner V4 ACCEPTS
// the reasoning_effort field, while v3.2 doesn't (DeepSeek API rejects it).
// Routing V4 to DEEPSEEK_REASONER would suppress the field and forfeit
// openclaw's documented thinking control.
assertEquals(ModelFamily.DEEPSEEK_V4_REASONING, ModelFamily.detect("deepseek-v4-flash"));
assertEquals(ModelFamily.DEEPSEEK_V4_REASONING, ModelFamily.detect("deepseek-v4-pro"));
assertTrue(ModelFamily.DEEPSEEK_V4_REASONING.supportsReasoningEffort(),
"V4 must accept reasoning_effort (key differentiator from v3.2 reasoner)");
assertTrue(ModelFamily.DEEPSEEK_V4_REASONING.isThinking(),
"V4 is a thinking family — DeepSeekV4ThinkingDecorator gates on this");
assertFalse(ModelFamily.DEEPSEEK_V4_REASONING.fixedTemperatureOne(),
"V4 allows configurable temperature (unlike v3.2 reasoner)");
}
@Test
@DisplayName("Legacy deepseek-reasoner stays in DEEPSEEK_REASONER family (does not catch V4 rule)")
void deepSeekReasoner_unchanged() {
// Defensive: if the V4 detect rule were too broad (e.g. startsWith "deepseek-")
// it would catch deepseek-reasoner too and break that model's working config.
assertEquals(ModelFamily.DEEPSEEK_REASONER, ModelFamily.detect("deepseek-reasoner"));
assertFalse(ModelFamily.DEEPSEEK_REASONER.supportsReasoningEffort(),
"v3.2 reasoner must NOT advertise reasoning_effort support");
}
@Test
@DisplayName("deepseek-chat stays STANDARD")
void deepSeekChat_standard() {
// Smoke check: non-reasoning DeepSeek model unaffected.
assertEquals(ModelFamily.STANDARD, ModelFamily.detect("deepseek-chat"));
}
@Test
@DisplayName("Case + whitespace tolerance — uppercased / padded model name routes the same")
void detect_caseInsensitive() {
assertEquals(ModelFamily.DEEPSEEK_V4_REASONING, ModelFamily.detect("DeepSeek-V4-Flash"));
assertEquals(ModelFamily.DEEPSEEK_V4_REASONING, ModelFamily.detect(" deepseek-v4-pro "));
}
@Test
@DisplayName("Null / blank model name → STANDARD (no NPE)")
void detect_nullSafe() {
assertEquals(ModelFamily.STANDARD, ModelFamily.detect(null));
assertEquals(ModelFamily.STANDARD, ModelFamily.detect(""));
assertEquals(ModelFamily.STANDARD, ModelFamily.detect(" "));
}
}

View File

@ -0,0 +1,325 @@
package vip.mate.llm.oauth;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.test.web.client.MockRestServiceServer;
import org.springframework.web.client.RestClient;
import vip.mate.exception.MateClawException;
import vip.mate.llm.oauth.OpenAIDeviceCodeService.DeviceCodePollResult;
import vip.mate.llm.oauth.OpenAIDeviceCodeService.DeviceCodeStartResult;
import java.lang.reflect.Field;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.content;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.header;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.jsonPath;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withStatus;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
/**
* Unit tests for the device authorization grant flow.
*
* <p>{@link OpenAIDeviceCodeService} is exercised through a mocked OpenAI endpoint
* (via {@link MockRestServiceServer}). The token exchange path
* ({@code OpenAIOAuthService#exchangeTokenWithVerifier}) is mocked so we never
* touch the database we only verify it is invoked with the correct args.
*/
class OpenAIDeviceCodeServiceTest {
private OpenAIOAuthService oauthService;
private OpenAIDeviceCodeService deviceCodeService;
private MockRestServiceServer mockServer;
@BeforeEach
void setUp() throws Exception {
oauthService = mock(OpenAIOAuthService.class);
deviceCodeService = new OpenAIDeviceCodeService(oauthService, new ObjectMapper());
// Tighten config knobs so tests don't sleep
setField(deviceCodeService, "pollMinIntervalMs", 0L);
setField(deviceCodeService, "defaultSessionTtlSeconds", 900L);
setField(deviceCodeService, "userAgent", "test-agent/0.0");
RestClient.Builder builder = RestClient.builder();
mockServer = MockRestServiceServer.bindTo(builder).build();
deviceCodeService.setRestClient(builder.build());
}
private static void setField(Object target, String name, Object value) throws Exception {
Field f = OpenAIDeviceCodeService.class.getDeclaredField(name);
f.setAccessible(true);
f.set(target, value);
}
// ---------------------------------------------------------------------
// start()
// ---------------------------------------------------------------------
@Test
@DisplayName("start sends JSON body with client_id and parses all response fields")
void start_parsesAllFields() {
mockServer.expect(requestTo(OpenAIDeviceCodeService.DEVICE_USERCODE_URL))
.andExpect(header(org.springframework.http.HttpHeaders.CONTENT_TYPE,
MediaType.APPLICATION_JSON_VALUE))
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("$.client_id").value(OpenAIDeviceCodeService.CLIENT_ID))
.andRespond(withSuccess(
"{\"device_auth_id\":\"dev-abc-123\","
+ "\"user_code\":\"WXYZ-1234\","
+ "\"interval\":7,"
+ "\"expires_in\":600,"
+ "\"verification_uri\":\"https://auth.openai.com/codex/device\","
+ "\"verification_uri_complete\":\"https://auth.openai.com/codex/device?user_code=WXYZ-1234\"}",
MediaType.APPLICATION_JSON));
DeviceCodeStartResult result = deviceCodeService.start();
assertEquals("dev-abc-123", result.deviceAuthId());
assertEquals("WXYZ-1234", result.userCode());
assertEquals(7, result.intervalSeconds());
assertEquals(600, result.expiresInSeconds());
assertEquals("https://auth.openai.com/codex/device", result.verificationUrl());
assertEquals("https://auth.openai.com/codex/device?user_code=WXYZ-1234",
result.verificationUrlComplete());
assertEquals(1, deviceCodeService.activeSessionCount());
mockServer.verify();
}
@Test
@DisplayName("start defaults verification URL when not returned by OpenAI")
void start_defaultsVerificationUrl() {
mockServer.expect(requestTo(OpenAIDeviceCodeService.DEVICE_USERCODE_URL))
.andRespond(withSuccess(
"{\"device_auth_id\":\"d1\",\"user_code\":\"AB-CD\","
+ "\"interval\":5,\"expires_in\":300}",
MediaType.APPLICATION_JSON));
DeviceCodeStartResult result = deviceCodeService.start();
assertEquals(OpenAIDeviceCodeService.DEFAULT_VERIFICATION_URL, result.verificationUrl());
}
@Test
@DisplayName("start throws MateClawException on transport failure")
void start_propagatesTransportFailures() {
mockServer.expect(requestTo(OpenAIDeviceCodeService.DEVICE_USERCODE_URL))
.andRespond(withStatus(HttpStatus.SERVICE_UNAVAILABLE));
MateClawException ex = assertThrows(MateClawException.class,
() -> deviceCodeService.start());
assertEquals("err.llm.device_code_start_failed", ex.getMsgKey());
}
@Test
@DisplayName("start throws when response is missing required fields")
void start_rejectsIncompleteResponse() {
mockServer.expect(requestTo(OpenAIDeviceCodeService.DEVICE_USERCODE_URL))
.andRespond(withSuccess("{\"interval\":5}", MediaType.APPLICATION_JSON));
MateClawException ex = assertThrows(MateClawException.class,
() -> deviceCodeService.start());
assertEquals("err.llm.device_code_start_failed", ex.getMsgKey());
}
// ---------------------------------------------------------------------
// poll()
// ---------------------------------------------------------------------
@Test
@DisplayName("poll returns EXPIRED for unknown session")
void poll_unknownSessionExpired() {
assertEquals(DeviceCodePollResult.Status.EXPIRED,
deviceCodeService.poll("not-a-real-session").status());
assertEquals(DeviceCodePollResult.Status.EXPIRED,
deviceCodeService.poll(null).status());
assertEquals(DeviceCodePollResult.Status.EXPIRED,
deviceCodeService.poll("").status());
}
@Test
@DisplayName("poll sends JSON body and returns PENDING for HTTP 403 (user has not finished yet)")
void poll_403MapsToPending() {
expectStart("dev-1", "USER-1");
mockServer.expect(requestTo(OpenAIDeviceCodeService.DEVICE_TOKEN_URL))
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("$.device_auth_id").value("dev-1"))
.andExpect(jsonPath("$.user_code").value("USER-1"))
.andRespond(withStatus(HttpStatus.FORBIDDEN));
deviceCodeService.start();
assertEquals(DeviceCodePollResult.Status.PENDING,
deviceCodeService.poll("dev-1").status());
verifyNoInteractions(oauthService);
}
@Test
@DisplayName("poll returns PENDING for HTTP 404 (per OpenAI deviceauth contract)")
void poll_404MapsToPending() {
expectStart("dev-1b", "USER-1B");
mockServer.expect(requestTo(OpenAIDeviceCodeService.DEVICE_TOKEN_URL))
.andRespond(withStatus(HttpStatus.NOT_FOUND));
deviceCodeService.start();
assertEquals(DeviceCodePollResult.Status.PENDING,
deviceCodeService.poll("dev-1b").status());
}
@Test
@DisplayName("poll still maps RFC 8628 400+authorization_pending to PENDING for forward-compat")
void poll_rfcAuthorizationPendingMapsToPending() {
expectStart("dev-1c", "USER-1C");
mockServer.expect(requestTo(OpenAIDeviceCodeService.DEVICE_TOKEN_URL))
.andRespond(withStatus(HttpStatus.BAD_REQUEST)
.contentType(MediaType.APPLICATION_JSON)
.body("{\"error\":\"authorization_pending\"}"));
deviceCodeService.start();
assertEquals(DeviceCodePollResult.Status.PENDING,
deviceCodeService.poll("dev-1c").status());
verifyNoInteractions(oauthService);
}
@Test
@DisplayName("poll returns PENDING when OpenAI replies 400 slow_down")
void poll_slowDownMapsToPending() {
expectStart("dev-2", "USER-2");
mockServer.expect(requestTo(OpenAIDeviceCodeService.DEVICE_TOKEN_URL))
.andRespond(withStatus(HttpStatus.BAD_REQUEST)
.contentType(MediaType.APPLICATION_JSON)
.body("{\"error\":\"slow_down\"}"));
deviceCodeService.start();
assertEquals(DeviceCodePollResult.Status.PENDING,
deviceCodeService.poll("dev-2").status());
}
@Test
@DisplayName("poll returns EXPIRED + drops session when OpenAI replies 400 expired_token")
void poll_expiredTokenDropsSession() {
expectStart("dev-3", "USER-3");
mockServer.expect(requestTo(OpenAIDeviceCodeService.DEVICE_TOKEN_URL))
.andRespond(withStatus(HttpStatus.BAD_REQUEST)
.contentType(MediaType.APPLICATION_JSON)
.body("{\"error\":\"expired_token\"}"));
deviceCodeService.start();
assertEquals(DeviceCodePollResult.Status.EXPIRED,
deviceCodeService.poll("dev-3").status());
// session was removed next poll returns EXPIRED without hitting the network
assertEquals(DeviceCodePollResult.Status.EXPIRED,
deviceCodeService.poll("dev-3").status());
}
@Test
@DisplayName("poll returns EXPIRED when user denies access")
void poll_accessDeniedDropsSession() {
expectStart("dev-4", "USER-4");
mockServer.expect(requestTo(OpenAIDeviceCodeService.DEVICE_TOKEN_URL))
.andRespond(withStatus(HttpStatus.BAD_REQUEST)
.contentType(MediaType.APPLICATION_JSON)
.body("{\"error\":\"access_denied\"}"));
deviceCodeService.start();
assertEquals(DeviceCodePollResult.Status.EXPIRED,
deviceCodeService.poll("dev-4").status());
}
@Test
@DisplayName("poll returns COMPLETED + invokes token exchange when authorization_code arrives")
void poll_completedExchangesToken() {
expectStart("dev-5", "USER-5");
mockServer.expect(requestTo(OpenAIDeviceCodeService.DEVICE_TOKEN_URL))
.andRespond(withSuccess(
"{\"authorization_code\":\"auth-code-xyz\","
+ "\"code_verifier\":\"verifier-xyz\"}",
MediaType.APPLICATION_JSON));
deviceCodeService.start();
DeviceCodePollResult result = deviceCodeService.poll("dev-5");
assertEquals(DeviceCodePollResult.Status.COMPLETED, result.status());
verify(oauthService).exchangeTokenWithVerifier(
eq("auth-code-xyz"),
eq("verifier-xyz"),
eq(OpenAIDeviceCodeService.DEVICE_REDIRECT_URI));
assertEquals(0, deviceCodeService.activeSessionCount());
}
@Test
@DisplayName("poll keeps session and returns PENDING when 200 body has no authorization_code")
void poll_inlinePendingErrorMapsToPending() {
expectStart("dev-6", "USER-6");
// Some flavours of the endpoint reply 200 with {error: authorization_pending}
mockServer.expect(requestTo(OpenAIDeviceCodeService.DEVICE_TOKEN_URL))
.andRespond(withSuccess(
"{\"error\":\"authorization_pending\"}",
MediaType.APPLICATION_JSON));
deviceCodeService.start();
assertEquals(DeviceCodePollResult.Status.PENDING,
deviceCodeService.poll("dev-6").status());
assertEquals(1, deviceCodeService.activeSessionCount());
}
@Test
@DisplayName("poll returns EXPIRED when authorization_code present but code_verifier missing")
void poll_missingCodeVerifierDropsSession() {
expectStart("dev-7", "USER-7");
mockServer.expect(requestTo(OpenAIDeviceCodeService.DEVICE_TOKEN_URL))
.andRespond(withSuccess(
"{\"authorization_code\":\"only-code\"}",
MediaType.APPLICATION_JSON));
deviceCodeService.start();
assertEquals(DeviceCodePollResult.Status.EXPIRED,
deviceCodeService.poll("dev-7").status());
verifyNoInteractions(oauthService);
}
// ---------------------------------------------------------------------
// cancel()
// ---------------------------------------------------------------------
@Test
@DisplayName("cancel removes the session so subsequent poll returns EXPIRED")
void cancel_dropsSession() {
expectStart("dev-cancel", "USER-CANCEL");
deviceCodeService.start();
assertEquals(1, deviceCodeService.activeSessionCount());
deviceCodeService.cancel("dev-cancel");
assertEquals(0, deviceCodeService.activeSessionCount());
assertEquals(DeviceCodePollResult.Status.EXPIRED,
deviceCodeService.poll("dev-cancel").status());
}
@Test
@DisplayName("cancel handles null/missing IDs without throwing")
void cancel_nullSafe() {
assertDoesNotThrow(() -> deviceCodeService.cancel(null));
assertDoesNotThrow(() -> deviceCodeService.cancel("never-existed"));
}
// ---------------------------------------------------------------------
// helpers
// ---------------------------------------------------------------------
/** Register the usercode-endpoint expectation; caller must invoke start() afterwards. */
private void expectStart(String deviceAuthId, String userCode) {
mockServer.expect(requestTo(OpenAIDeviceCodeService.DEVICE_USERCODE_URL))
.andRespond(withSuccess(
"{\"device_auth_id\":\"" + deviceAuthId + "\","
+ "\"user_code\":\"" + userCode + "\","
+ "\"interval\":5,\"expires_in\":900}",
MediaType.APPLICATION_JSON));
}
}

View File

@ -0,0 +1,180 @@
package vip.mate.llm.oauth;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import vip.mate.exception.MateClawException;
import vip.mate.llm.oauth.OpenAIOAuthService.OAuthFlowMode;
import java.lang.reflect.Method;
import static org.junit.jupiter.api.Assertions.*;
/**
* Issue: OAuth callback fails on Linux server deployment because the
* redirect_uri is hardcoded to http://localhost:1455/auth/callback. When the
* user's browser hits this URL it tries to reach the user's own machine, not
* the remote MateClaw server, so the auth code never reaches the server.
*
* <p>Tests focus on the deployment-mode resolution logic (Host header heuristic
* + config override + paste-URL parser). Network-bound paths (token exchange,
* Keychain reads) are out of scope here they need either a wiremock or live
* fixtures.
*/
class OpenAIOAuthServiceFlowModeTest {
private OpenAIOAuthService service;
@BeforeEach
void setUp() {
// null collaborators OK because the helpers we exercise (resolveFlowMode,
// completeFromPastedUrl up to state validation) don't touch them. The
// compile-time @RequiredArgsConstructor accepts nulls.
service = new OpenAIOAuthService(null, new ObjectMapper(), null);
}
@AfterEach
void clearOverride() {
System.clearProperty("mateclaw.oauth.openai.deployment-mode");
}
// ============== resolveFlowMode (private accessed via reflection) ===
private OAuthFlowMode invokeResolve(String host) throws Exception {
Method m = OpenAIOAuthService.class.getDeclaredMethod("resolveFlowMode", String.class);
m.setAccessible(true);
return (OAuthFlowMode) m.invoke(service, host);
}
@Test
@DisplayName("localhost variants resolve to LOCAL mode")
void localhostHosts_resolveToLocal() throws Exception {
assertEquals(OAuthFlowMode.LOCAL, invokeResolve("localhost"));
assertEquals(OAuthFlowMode.LOCAL, invokeResolve("localhost:18088"));
assertEquals(OAuthFlowMode.LOCAL, invokeResolve("127.0.0.1"));
assertEquals(OAuthFlowMode.LOCAL, invokeResolve("127.0.0.1:18088"));
assertEquals(OAuthFlowMode.LOCAL, invokeResolve("LocalHost")); // case-insensitive
}
@Test
@DisplayName("public hosts resolve to DEVICE_CODE (browser-agnostic, no callback server needed)")
void publicHosts_resolveToDeviceCode() throws Exception {
assertEquals(OAuthFlowMode.DEVICE_CODE, invokeResolve("mateclaw.example.com"));
assertEquals(OAuthFlowMode.DEVICE_CODE, invokeResolve("api.mate.vip"));
assertEquals(OAuthFlowMode.DEVICE_CODE, invokeResolve("api.mate.vip:443"));
assertEquals(OAuthFlowMode.DEVICE_CODE, invokeResolve("192.168.1.10"),
"private LAN IP — not localhost, browser still won't reach server's localhost");
assertEquals(OAuthFlowMode.DEVICE_CODE, invokeResolve("10.0.0.5:8080"));
}
@Test
@DisplayName("null/blank host falls back to LOCAL (legacy behaviour preservation)")
void nullOrBlankHost_legacyLocal() throws Exception {
assertEquals(OAuthFlowMode.LOCAL, invokeResolve(null));
assertEquals(OAuthFlowMode.LOCAL, invokeResolve(""));
assertEquals(OAuthFlowMode.LOCAL, invokeResolve(" "));
}
@Test
@DisplayName("config override mateclaw.oauth.openai.deployment-mode=local forces LOCAL even on remote host")
void configOverride_forcesLocal() throws Exception {
System.setProperty("mateclaw.oauth.openai.deployment-mode", "local");
assertEquals(OAuthFlowMode.LOCAL, invokeResolve("mateclaw.example.com"));
}
@Test
@DisplayName("config override =device_code forces DEVICE_CODE even on localhost")
void configOverride_forcesDeviceCode() throws Exception {
System.setProperty("mateclaw.oauth.openai.deployment-mode", "device_code");
assertEquals(OAuthFlowMode.DEVICE_CODE, invokeResolve("localhost"));
// 'server' kept as alias for backwards compatibility (now points to DEVICE_CODE)
System.setProperty("mateclaw.oauth.openai.deployment-mode", "server");
assertEquals(OAuthFlowMode.DEVICE_CODE, invokeResolve("localhost"));
}
@Test
@DisplayName("config override =manual_paste forces MANUAL_PASTE")
void configOverride_forcesManualPaste() throws Exception {
System.setProperty("mateclaw.oauth.openai.deployment-mode", "manual_paste");
assertEquals(OAuthFlowMode.MANUAL_PASTE, invokeResolve("localhost"));
assertEquals(OAuthFlowMode.MANUAL_PASTE, invokeResolve("api.mate.vip"));
}
@Test
@DisplayName("config override 'auto' or unknown falls back to heuristic")
void configOverride_autoFallsThrough() throws Exception {
System.setProperty("mateclaw.oauth.openai.deployment-mode", "auto");
assertEquals(OAuthFlowMode.LOCAL, invokeResolve("localhost"));
assertEquals(OAuthFlowMode.DEVICE_CODE, invokeResolve("api.mate.vip"));
System.setProperty("mateclaw.oauth.openai.deployment-mode", "garbage");
assertEquals(OAuthFlowMode.LOCAL, invokeResolve("localhost"));
}
// ============== completeFromPastedUrl ================================
@Test
@DisplayName("completeFromPastedUrl rejects empty / null input")
void pastedUrl_emptyRejected() {
assertThrows(MateClawException.class, () -> service.completeFromPastedUrl(null));
assertThrows(MateClawException.class, () -> service.completeFromPastedUrl(""));
assertThrows(MateClawException.class, () -> service.completeFromPastedUrl(" "));
}
@Test
@DisplayName("completeFromPastedUrl rejects URL without query string")
void pastedUrl_noQueryRejected() {
MateClawException ex = assertThrows(MateClawException.class,
() -> service.completeFromPastedUrl("http://localhost:1455/auth/callback"));
assertTrue(ex.getMessage().contains("查询参数"));
}
@Test
@DisplayName("completeFromPastedUrl rejects URL missing code")
void pastedUrl_missingCode() {
MateClawException ex = assertThrows(MateClawException.class,
() -> service.completeFromPastedUrl(
"http://localhost:1455/auth/callback?state=xyz"));
assertTrue(ex.getMessage().contains("code"));
}
@Test
@DisplayName("completeFromPastedUrl rejects URL missing state")
void pastedUrl_missingState() {
MateClawException ex = assertThrows(MateClawException.class,
() -> service.completeFromPastedUrl(
"http://localhost:1455/auth/callback?code=abc"));
assertTrue(ex.getMessage().contains("state"));
}
@Test
@DisplayName("completeFromPastedUrl strips fragment after #")
void pastedUrl_stripsFragment() {
// Should successfully extract code and state, but throw because
// state isn't in pendingStates map (no real authorize was called).
// We're verifying the parser gets past the parsing stage.
MateClawException ex = assertThrows(MateClawException.class,
() -> service.completeFromPastedUrl(
"http://localhost:1455/auth/callback?code=abc&state=xyz#fragment"));
// The error must be from exchangeToken (state not in pendingStates),
// not from a parsing failure.
assertTrue(ex.getMsgKey() != null && ex.getMsgKey().contains("oauth_state_invalid"),
"Expected state validation failure (parser succeeded), got: " + ex.getMessage());
}
@Test
@DisplayName("completeFromPastedUrl handles URL-encoded code values")
void pastedUrl_handlesEncodedValues() {
// The exchangeToken stage will fail, but parser must have decoded
// the percent-encoded characters before getting there.
MateClawException ex = assertThrows(MateClawException.class,
() -> service.completeFromPastedUrl(
"http://localhost:1455/auth/callback?code=abc%2B123&state=test%3Dvalue"));
// Should fail at state validation, not parsing
assertTrue(ex.getMsgKey() != null && ex.getMsgKey().contains("oauth_state_invalid"),
"Parser should accept percent-encoded values; got: " + ex.getMessage());
}
}

View File

@ -0,0 +1,207 @@
package vip.mate.llm.routing;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import vip.mate.llm.model.ModelConfigEntity;
import vip.mate.llm.routing.model.MultimodalRoutingDecision;
import vip.mate.llm.service.ModelCapabilityService;
import vip.mate.llm.service.ModelCapabilityService.Modality;
import vip.mate.llm.service.ModelConfigService;
import vip.mate.system.model.SystemSettingsDTO;
import vip.mate.system.service.SystemSettingService;
import vip.mate.workspace.conversation.model.MessageContentPart;
import java.util.EnumSet;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class MultimodalRouterTest {
@Mock
private SystemSettingService systemSettingService;
@Mock
private ModelConfigService modelConfigService;
@Mock
private ModelCapabilityService capabilityService;
@InjectMocks
private MultimodalRouter router;
private SystemSettingsDTO settings;
@BeforeEach
void setUp() {
settings = new SystemSettingsDTO();
lenient().when(systemSettingService.getSettings()).thenReturn(settings);
}
@Test
@DisplayName("No attachments → strategy NONE, no reads to settings")
void noAttachmentsReturnsNone() {
// No capabilityService stubbing here the router must short-circuit before
// touching capabilities when no attachments are present.
MultimodalRoutingDecision decision = router.route(
List.of(), chatModel("deepseek", "deepseek-chat", null));
assertEquals(MultimodalRoutingDecision.Strategy.NONE, decision.strategy());
assertTrue(decision.skipped().isEmpty());
assertNull(decision.sidecarModel());
}
@Test
@DisplayName("Primary already supports vision → strategy NONE")
void primaryCoversVisionReturnsNone() {
ModelConfigEntity primary = chatModel("zhipu", "glm-4v", "[\"vision\"]");
when(capabilityService.resolve("glm-4v", "[\"vision\"]"))
.thenReturn(EnumSet.of(Modality.VISION));
MultimodalRoutingDecision decision = router.route(List.of(imagePart("a.png")), primary);
assertEquals(MultimodalRoutingDecision.Strategy.NONE, decision.strategy());
}
@Test
@DisplayName("Image attachment + text-only primary + configured vision sidecar → SIDECAR")
void textPrimaryImageWithSidecarConfigured() {
ModelConfigEntity primary = chatModel("deepseek", "deepseek-chat", null);
ModelConfigEntity vision = chatModel("zhipu", "glm-4v", "[\"vision\"]");
vision.setId(42L);
vision.setEnabled(true);
when(capabilityService.resolve("deepseek-chat", null))
.thenReturn(EnumSet.of(Modality.TEXT));
settings.setDefaultVisionModelId(42L);
when(modelConfigService.getModel(42L)).thenReturn(vision);
when(capabilityService.supports(eq("glm-4v"), eq("[\"vision\"]"), eq(Modality.VISION)))
.thenReturn(true);
MultimodalRoutingDecision decision = router.route(List.of(imagePart("a.png")), primary);
assertEquals(MultimodalRoutingDecision.Strategy.SIDECAR, decision.strategy());
assertNotNull(decision.sidecarModel());
assertEquals(42L, decision.sidecarModel().getId());
assertTrue(decision.skipped().isEmpty());
}
@Test
@DisplayName("Image + text-only primary + sidecar NOT configured → NONE with skipped reason")
void textPrimaryImageNoSidecar() {
ModelConfigEntity primary = chatModel("deepseek", "deepseek-chat", null);
when(capabilityService.resolve("deepseek-chat", null))
.thenReturn(EnumSet.of(Modality.TEXT));
settings.setDefaultVisionModelId(null);
MultimodalRoutingDecision decision = router.route(List.of(imagePart("a.png")), primary);
assertEquals(MultimodalRoutingDecision.Strategy.NONE, decision.strategy());
assertEquals(1, decision.skipped().size());
assertEquals("vision_model_not_configured", decision.skipped().get(0).reason());
}
@Test
@DisplayName("Image + sidecar configured but model disabled → NONE with vision_model_unavailable")
void textPrimaryImageSidecarDisabled() {
ModelConfigEntity primary = chatModel("deepseek", "deepseek-chat", null);
ModelConfigEntity vision = chatModel("zhipu", "glm-4v", "[\"vision\"]");
vision.setId(42L);
vision.setEnabled(false);
when(capabilityService.resolve("deepseek-chat", null))
.thenReturn(EnumSet.of(Modality.TEXT));
settings.setDefaultVisionModelId(42L);
when(modelConfigService.getModel(42L)).thenReturn(vision);
MultimodalRoutingDecision decision = router.route(List.of(imagePart("a.png")), primary);
assertEquals(MultimodalRoutingDecision.Strategy.NONE, decision.strategy());
assertEquals("vision_model_unavailable", decision.skipped().get(0).reason());
}
@Test
@DisplayName("Video attachment never sidecarred in v1 → NONE with reserved reason")
void videoAttachmentSkippedInV1() {
ModelConfigEntity primary = chatModel("deepseek", "deepseek-chat", null);
when(capabilityService.resolve("deepseek-chat", null))
.thenReturn(EnumSet.of(Modality.TEXT));
MultimodalRoutingDecision decision = router.route(List.of(videoPart("b.mp4")), primary);
assertEquals(MultimodalRoutingDecision.Strategy.NONE, decision.strategy());
assertEquals(1, decision.skipped().size());
assertEquals("video_sidecar_not_supported_in_v1", decision.skipped().get(0).reason());
}
@Test
@DisplayName("Configured sidecar that does not actually support VISION → fallback to NONE")
void sidecarLacksClaimedCapability() {
ModelConfigEntity primary = chatModel("deepseek", "deepseek-chat", null);
ModelConfigEntity vision = chatModel("acme", "acme-chat", "[]");
vision.setId(42L);
vision.setEnabled(true);
when(capabilityService.resolve("deepseek-chat", null))
.thenReturn(EnumSet.of(Modality.TEXT));
settings.setDefaultVisionModelId(42L);
when(modelConfigService.getModel(42L)).thenReturn(vision);
when(capabilityService.supports(anyString(), anyString(), eq(Modality.VISION)))
.thenReturn(false);
MultimodalRoutingDecision decision = router.route(List.of(imagePart("a.png")), primary);
assertEquals(MultimodalRoutingDecision.Strategy.NONE, decision.strategy());
assertEquals("vision_model_unavailable", decision.skipped().get(0).reason());
}
@Test
@DisplayName("Null primary → routing returns SIDECAR if vision configured, else NONE")
void nullPrimaryHonorsSidecarConfig() {
ModelConfigEntity vision = chatModel("zhipu", "glm-4v", "[\"vision\"]");
vision.setId(42L);
vision.setEnabled(true);
settings.setDefaultVisionModelId(42L);
when(modelConfigService.getModel(42L)).thenReturn(vision);
when(capabilityService.supports(anyString(), anyString(), eq(Modality.VISION))).thenReturn(true);
MultimodalRoutingDecision decision = router.route(List.of(imagePart("a.png")), null);
assertEquals(MultimodalRoutingDecision.Strategy.SIDECAR, decision.strategy());
}
private static MessageContentPart imagePart(String fileName) {
MessageContentPart part = new MessageContentPart();
part.setType("image");
part.setContentType("image/png");
part.setFileName(fileName);
return part;
}
private static MessageContentPart videoPart(String fileName) {
MessageContentPart part = new MessageContentPart();
part.setType("video");
part.setContentType("video/mp4");
part.setFileName(fileName);
return part;
}
private static ModelConfigEntity chatModel(String provider, String modelName, String modalitiesJson) {
ModelConfigEntity m = new ModelConfigEntity();
m.setProvider(provider);
m.setModelName(modelName);
m.setModalities(modalitiesJson);
m.setEnabled(true);
return m;
}
}

View File

@ -0,0 +1,261 @@
package vip.mate.llm.service;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import vip.mate.llm.service.ModelCapabilityService.Modality;
import java.util.EnumSet;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Pinpoint regression tests for {@link ModelCapabilityService}.
*
* <p>Per-model granularity is the whole point the prior hardcoded
* {@code n.contains("glm") && n.contains("v")} matcher (issue #44) collapsed
* {@code glm-4v} and {@code glm-4v-plus} into the same bucket even though only
* the latter accepts video. The cases below pin that boundary so a future
* "let's just add another contains() rule" cleanup can't bring the bug back.
*/
class ModelCapabilityServiceTest {
private final ModelCapabilityService service = new ModelCapabilityService();
// ---------- Heuristic table: per-model granularity ----------
@Test
@DisplayName("glm-4v-plus → VIDEO; glm-4v → no VIDEO (issue #44 root cause)")
void glm4v_videoCapabilityDiffers() {
assertTrue(service.supports("glm-4v-plus", null, Modality.VIDEO),
"glm-4v-plus is multimodal incl. video");
assertFalse(service.supports("glm-4v", null, Modality.VIDEO),
"plain glm-4v is image-only — must NOT pass through video Media");
assertFalse(service.supports("glm-4v-flash", null, Modality.VIDEO),
"glm-4v-flash is image-only");
// All three still support vision
assertTrue(service.supports("glm-4v-plus", null, Modality.VISION));
assertTrue(service.supports("glm-4v", null, Modality.VISION));
assertTrue(service.supports("glm-4v-flash", null, Modality.VISION));
}
@Test
@DisplayName("glm-5v-turbo / glm-4.5v / glm-4.1v lines all support VIDEO")
void glmNewGenerations_supportVideo() {
// glm-5v-turbo regression: original heuristic table only had glm-4v lineage,
// so a user uploading a video to glm-5v-turbo got a "model unsupported" notice
// even though Zhipu's 5V line is built for video understanding.
assertTrue(service.supports("glm-5v-turbo", null, Modality.VIDEO),
"glm-5v-turbo is Zhipu's video-understanding model — must accept video");
assertTrue(service.supports("glm-5v-flash", null, Modality.VIDEO));
assertTrue(service.supports("glm-5v", null, Modality.VIDEO));
assertTrue(service.supports("glm-4.5v", null, Modality.VIDEO));
assertTrue(service.supports("glm-4.1v-thinking-flashx", null, Modality.VIDEO));
}
@Test
@DisplayName("Longest-prefix-wins: glm-4v-plus does NOT degrade to glm-4v entry")
void longestPrefixWins() {
// If matcher used shortest-or-first, "glm-4v-plus" might match the "glm-4v" entry
// first and lose its VIDEO modality. Pin the iteration order independence.
EnumSet<Modality> caps = service.resolve("glm-4v-plus", null);
assertTrue(caps.contains(Modality.VIDEO), "longest prefix glm-4v-plus must win");
}
@Test
@DisplayName("Qwen-VL family: max → VIDEO, plus → image-only")
void qwenVl_familyDiffers() {
assertTrue(service.supports("qwen-vl-max", null, Modality.VIDEO));
assertFalse(service.supports("qwen-vl-plus", null, Modality.VIDEO));
assertTrue(service.supports("qwen-vl-plus", null, Modality.VISION));
}
@Test
@DisplayName("Qwen omni line accepts vision + video + audio")
void qwenOmni_fullyMultimodal() {
EnumSet<Modality> caps = service.resolve("qwen3-omni", null);
assertTrue(caps.contains(Modality.VISION));
assertTrue(caps.contains(Modality.VIDEO));
assertTrue(caps.contains(Modality.AUDIO));
}
@Test
@DisplayName("OpenAI: vision yes across the line, but native video NO (API limitation)")
void openai_neverNativeVideo() {
// The Chat Completions / Responses APIs do not accept video files for any
// OpenAI model as of 2026-04. Granting VIDEO would cause patchVideoMediaContent
// to send video_url, and OpenAI would 400. Pin this so a future "marketing-led"
// table edit can't silently re-introduce the failure mode.
assertTrue(service.supports("gpt-5", null, Modality.VISION));
assertTrue(service.supports("gpt-4.1", null, Modality.VISION));
assertTrue(service.supports("gpt-4o", null, Modality.VISION));
assertTrue(service.supports("gpt-4o-mini", null, Modality.VISION));
assertFalse(service.supports("gpt-5", null, Modality.VIDEO));
assertFalse(service.supports("gpt-4.1", null, Modality.VIDEO));
assertFalse(service.supports("gpt-4o", null, Modality.VIDEO));
assertFalse(service.supports("gpt-4o-mini", null, Modality.VIDEO));
}
@Test
@DisplayName("DeepSeek V4 / V4-Pro → VIDEO; V3 (text-only) gets nothing")
void deepseekV4_supportsVideo() {
// DeepSeek V4 (Apr 2026) introduced native multimodal incl. video to the line.
// V3 and earlier remain text-only and must NOT match the V4 entry.
assertTrue(service.supports("deepseek-v4", null, Modality.VIDEO));
assertTrue(service.supports("deepseek-v4-pro", null, Modality.VIDEO));
assertTrue(service.supports("deepseek-v4-flash", null, Modality.VIDEO));
assertFalse(service.supports("deepseek-v3", null, Modality.VIDEO),
"V3 must NOT inherit V4 capabilities — text-only base differs from V4 entirely");
assertFalse(service.supports("deepseek-v3.2", null, Modality.VIDEO));
assertFalse(service.supports("deepseek-r1", null, Modality.VIDEO));
}
@Test
@DisplayName("Qwen3-VL (all sizes) and Qwen3.5-Omni support VIDEO")
void qwen3Generation_supportsVideo() {
assertTrue(service.supports("qwen3-vl-8b-instruct", null, Modality.VIDEO));
assertTrue(service.supports("qwen3-vl-235b-a22b", null, Modality.VIDEO));
assertTrue(service.supports("qwen3.5-omni", null, Modality.VIDEO));
assertTrue(service.supports("qwen3.5-omni", null, Modality.AUDIO));
}
@Test
@DisplayName("Moonshot Kimi K2.6 → VIDEO; K2.5 → image only")
void kimiK26_supportsVideo() {
assertTrue(service.supports("kimi-k2.6", null, Modality.VIDEO));
assertFalse(service.supports("kimi-k2.5", null, Modality.VIDEO));
assertTrue(service.supports("kimi-k2.5", null, Modality.VISION));
}
@Test
@DisplayName("ByteDance Doubao Seed 2.0 supports VIDEO")
void doubaoSeed2_supportsVideo() {
assertTrue(service.supports("doubao-seed-2.0-pro", null, Modality.VIDEO));
assertTrue(service.supports("doubao-seed-2.0", null, Modality.VIDEO));
}
@Test
@DisplayName("Gemini 2.5 (pro/flash/flash-lite) is fully multimodal")
void gemini25_fullyMultimodal() {
assertTrue(service.supports("gemini-2.5-pro", null, Modality.VIDEO));
assertTrue(service.supports("gemini-2.5-flash", null, Modality.VIDEO));
assertTrue(service.supports("gemini-2.5-flash-lite", null, Modality.VIDEO));
assertTrue(service.supports("gemini-2.5-flash", null, Modality.AUDIO));
}
@Test
@DisplayName("Claude family: vision yes, native video no")
void claude_visionOnly() {
assertTrue(service.supports("claude-3.7-sonnet", null, Modality.VISION));
assertTrue(service.supports("claude-opus-4-5", null, Modality.VISION));
assertFalse(service.supports("claude-3.7-sonnet", null, Modality.VIDEO),
"Claude does not natively ingest video frames");
}
@Test
@DisplayName("Unknown model name: only TEXT, no vision/video/audio")
void unknownModel_textOnly() {
EnumSet<Modality> caps = service.resolve("totally-made-up-model-9000", null);
assertEquals(EnumSet.of(Modality.TEXT), caps,
"unknown model must default to text-only — failsafe for issue #44 silent skip");
}
@Test
@DisplayName("Null/blank model name resolves cleanly to TEXT only")
void nullModelName_safe() {
assertEquals(EnumSet.of(Modality.TEXT), service.resolve(null, null));
assertEquals(EnumSet.of(Modality.TEXT), service.resolve("", null));
assertEquals(EnumSet.of(Modality.TEXT), service.resolve(" ", null));
}
@Test
@DisplayName("Case-insensitive model name match")
void caseInsensitiveMatch() {
assertTrue(service.supports("GLM-4V-PLUS", null, Modality.VIDEO));
assertTrue(service.supports("Gpt-4o", null, Modality.VISION),
"case-insensitive match still resolves the entry; OpenAI grants vision (not video)");
}
@Test
@DisplayName("Llama 4 Scout / Maverick support VIDEO; Llama 3 does not")
void llama4_supportsVideo() {
assertTrue(service.supports("llama-4-scout", null, Modality.VIDEO));
assertTrue(service.supports("llama-4-maverick", null, Modality.VIDEO));
assertFalse(service.supports("llama-3.3-70b", null, Modality.VIDEO));
}
@Test
@DisplayName("Mistral / Pixtral / Grok / Hunyuan vision: image yes, video no")
void imageOnlyVendors() {
assertTrue(service.supports("pixtral-12b", null, Modality.VISION));
assertFalse(service.supports("pixtral-12b", null, Modality.VIDEO));
assertTrue(service.supports("mistral-small-4", null, Modality.VISION));
assertFalse(service.supports("mistral-small-4", null, Modality.VIDEO));
assertTrue(service.supports("grok-3", null, Modality.VISION));
assertFalse(service.supports("grok-3", null, Modality.VIDEO),
"Grok Imagine is video generation, not input — pin this to prevent confusion");
assertTrue(service.supports("hunyuan-vision", null, Modality.VISION));
assertTrue(service.supports("hunyuan-large-vision", null, Modality.VISION));
}
@Test
@DisplayName("MiniMax-VL is vision-only (Hailuo / video-01 are generation, not input)")
void minimaxVl_visionOnly() {
assertTrue(service.supports("minimax-vl-01", null, Modality.VISION));
assertFalse(service.supports("minimax-vl-01", null, Modality.VIDEO),
"MiniMax video models generate video, they don't ingest it");
}
// ---------- DB modalities override (user opt-in) ----------
@Test
@DisplayName("DB modalities JSON overrides heuristics — user can grant video to image-only model")
void dbOverride_grantsCapability() {
// User declares glm-4v supports video (e.g. they tested a custom endpoint that does).
// Override wins. TEXT always implicit.
EnumSet<Modality> caps = service.resolve("glm-4v", "[\"vision\",\"video\"]");
assertTrue(caps.contains(Modality.VIDEO),
"DB override must take precedence — heuristic alone says no video");
}
@Test
@DisplayName("DB modalities JSON overrides heuristics — user can revoke capability")
void dbOverride_revokesCapability() {
// User declares gpt-4o as vision-only (e.g. their proxy strips video).
EnumSet<Modality> caps = service.resolve("gpt-4o", "[\"vision\"]");
assertFalse(caps.contains(Modality.VIDEO),
"Empty modalities array means user explicitly opted out of video for this model");
}
@Test
@DisplayName("DB JSON case-insensitive on modality names")
void dbOverride_caseInsensitive() {
assertTrue(service.supports("anything", "[\"VIDEO\",\"Vision\"]", Modality.VIDEO));
assertTrue(service.supports("anything", "[\"VIDEO\",\"Vision\"]", Modality.VISION));
}
@Test
@DisplayName("Invalid JSON falls back to heuristics, does not throw")
void dbOverride_invalidJson_fallsBack() {
EnumSet<Modality> caps = service.resolve("glm-4v-plus", "this is not json");
assertTrue(caps.contains(Modality.VIDEO),
"When DB JSON is malformed, fall back to heuristics so service stays available");
}
@Test
@DisplayName("Unknown modality string in JSON is logged and ignored, others still apply")
void dbOverride_unknownModalityIgnored() {
EnumSet<Modality> caps = service.resolve("anything", "[\"vision\",\"telepathy\"]");
assertTrue(caps.contains(Modality.VISION));
// unknown one silently skipped, no exception
}
@Test
@DisplayName("TEXT is always implicit, even with empty DB declaration")
void textAlwaysImplicit() {
assertTrue(service.resolve("anything", "[]").contains(Modality.TEXT));
assertTrue(service.resolve("anything", null).contains(Modality.TEXT));
assertTrue(service.resolve(null, null).contains(Modality.TEXT));
}
}

View File

@ -0,0 +1,147 @@
package vip.mate.llm.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.test.util.ReflectionTestUtils;
import vip.mate.exception.MateClawException;
import vip.mate.llm.model.ModelConfigEntity;
import vip.mate.llm.repository.ModelConfigMapper;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
/**
* Regression tests for ModelConfigService.getDefaultModel() provider-availability filtering.
*
* Scenario: system has a default chat model but its provider is unconfigured (e.g. DashScope
* marked as default but no API key). The method must skip it and return the first chat model
* whose provider IS configured instead of blindly returning the unconfigured default.
*/
@ExtendWith(MockitoExtension.class)
class ModelConfigServiceDefaultModelTest {
@Mock
private ModelConfigMapper modelConfigMapper;
@Mock
private ApplicationEventPublisher eventPublisher;
@Mock
private ModelProviderService modelProviderService;
@InjectMocks
private ModelConfigService service;
@BeforeEach
void injectLazyDep() {
// Simulate the @Lazy @Autowired field injection Spring does at runtime.
ReflectionTestUtils.setField(service, "modelProviderService", modelProviderService);
}
private static ModelConfigEntity chatModel(String provider, String modelName, boolean isDefault) {
ModelConfigEntity m = new ModelConfigEntity();
m.setProvider(provider);
m.setModelName(modelName);
m.setIsDefault(isDefault);
m.setEnabled(true);
m.setModelType("chat");
return m;
}
// Scenario 1: default model available
@Test
@DisplayName("configured default model is returned directly")
void defaultModelConfigured_returnsIt() {
ModelConfigEntity dashscopeDefault = chatModel("dashscope", "qwen-plus", true);
when(modelConfigMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(dashscopeDefault);
when(modelProviderService.isProviderConfigured("dashscope")).thenReturn(true);
ModelConfigEntity result = service.getDefaultModel();
assertEquals("dashscope", result.getProvider());
assertEquals("qwen-plus", result.getModelName());
// Should not proceed to the full-scan fallback path.
verify(modelConfigMapper, times(1)).selectOne(any());
verify(modelConfigMapper, never()).selectList(any());
}
// Scenario 2: default model provider unavailable fallback
@Test
@DisplayName("default model provider unconfigured: falls back to first configured alternative")
void defaultModelProviderUnconfigured_returnsFallback() {
ModelConfigEntity dashscopeDefault = chatModel("dashscope", "qwen-plus", true);
ModelConfigEntity zhipuModel = chatModel("zhipu", "glm-4", false);
// First selectOne the is_default=true model
when(modelConfigMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(dashscopeDefault);
// dashscope is NOT configured, zhipu IS
when(modelProviderService.isProviderConfigured("dashscope")).thenReturn(false);
when(modelProviderService.isProviderConfigured("zhipu")).thenReturn(true);
// Full-scan returns both; zhipu comes second but dashscope is skipped
when(modelConfigMapper.selectList(any(LambdaQueryWrapper.class)))
.thenReturn(List.of(dashscopeDefault, zhipuModel));
ModelConfigEntity result = service.getDefaultModel();
assertEquals("zhipu", result.getProvider());
assertEquals("glm-4", result.getModelName());
}
// Scenario 3: no configured provider at all
@Test
@DisplayName("all enabled chat model providers unconfigured: throws with clear message")
void allProvidersUnconfigured_throws() {
ModelConfigEntity dashscopeDefault = chatModel("dashscope", "qwen-plus", true);
ModelConfigEntity zhipuModel = chatModel("zhipu", "glm-4", false);
when(modelConfigMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(dashscopeDefault);
when(modelProviderService.isProviderConfigured(any())).thenReturn(false);
when(modelConfigMapper.selectList(any(LambdaQueryWrapper.class)))
.thenReturn(List.of(dashscopeDefault, zhipuModel));
MateClawException ex = assertThrows(MateClawException.class, () -> service.getDefaultModel());
assertEquals("err.llm.no_configured_provider", ex.getMsgKey());
}
// Scenario 4: no enabled model at all
@Test
@DisplayName("no enabled chat model at all: throws no_available_model")
void noEnabledModel_throws() {
when(modelConfigMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(null);
when(modelConfigMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of());
MateClawException ex = assertThrows(MateClawException.class, () -> service.getDefaultModel());
assertEquals("err.llm.no_available_model", ex.getMsgKey());
}
// Scenario 5: modelProviderService unavailable (bootstrap)
@Test
@DisplayName("modelProviderService null (bootstrap): default model returned without filtering")
void providerServiceNull_returnsDefaultWithoutFilter() {
ReflectionTestUtils.setField(service, "modelProviderService", null);
ModelConfigEntity dashscopeDefault = chatModel("dashscope", "qwen-plus", true);
when(modelConfigMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(dashscopeDefault);
// With null providerService, isProviderConfigured returns true (lenient bootstrap)
ModelConfigEntity result = service.getDefaultModel();
assertEquals("dashscope", result.getProvider());
}
}

View File

@ -0,0 +1,138 @@
package vip.mate.llm.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.test.util.ReflectionTestUtils;
import vip.mate.llm.model.ModelConfigEntity;
import vip.mate.llm.repository.ModelConfigMapper;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* Tests for {@link ModelConfigService#resolveModel(String)} the lookup
* path used by {@code AgentGraphBuilder} to honor a per-Agent model override
* (RFC-03 Lane G1).
*
* <p>Contract:
* <ul>
* <li>Blank / null name fall back to {@link ModelConfigService#getDefaultModel()}</li>
* <li>Name matches an enabled model return that entity</li>
* <li>Name does not match (deleted / disabled / typo) fall back to default</li>
* </ul>
*/
@ExtendWith(MockitoExtension.class)
class ModelConfigServiceResolveModelTest {
@Mock
private ModelConfigMapper modelConfigMapper;
@Mock
private ApplicationEventPublisher eventPublisher;
@Mock
private ModelProviderService modelProviderService;
@InjectMocks
private ModelConfigService service;
@BeforeEach
void injectLazyDep() {
// Simulate the @Lazy @Autowired field injection Spring does at runtime.
ReflectionTestUtils.setField(service, "modelProviderService", modelProviderService);
}
private static ModelConfigEntity chatModel(String provider, String modelName, boolean isDefault) {
ModelConfigEntity m = new ModelConfigEntity();
m.setProvider(provider);
m.setModelName(modelName);
m.setIsDefault(isDefault);
m.setEnabled(true);
m.setModelType("chat");
return m;
}
// Blank input fall back to default
@Test
@DisplayName("null name falls back to global default")
void nullNameFallsBack() {
ModelConfigEntity defaultModel = chatModel("dashscope", "qwen-plus", true);
// resolveModel skips its own selectOne for null/blank input, then calls getDefaultModel(),
// which itself runs one selectOne lookup for the default flag.
when(modelConfigMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(defaultModel);
when(modelProviderService.isProviderConfigured("dashscope")).thenReturn(true);
ModelConfigEntity result = service.resolveModel(null);
assertNotNull(result);
assertEquals("qwen-plus", result.getModelName());
// Exactly one lookup the default-model query inside getDefaultModel().
verify(modelConfigMapper, times(1)).selectOne(any());
}
@Test
@DisplayName("blank/whitespace name falls back to global default")
void blankNameFallsBack() {
ModelConfigEntity defaultModel = chatModel("dashscope", "qwen-plus", true);
when(modelConfigMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(defaultModel);
when(modelProviderService.isProviderConfigured("dashscope")).thenReturn(true);
ModelConfigEntity result = service.resolveModel(" ");
assertNotNull(result);
assertEquals("qwen-plus", result.getModelName());
verify(modelConfigMapper, times(1)).selectOne(any());
}
// Match return named model
@Test
@DisplayName("named model match returns the entity (no default fallback)")
void namedMatchReturnsEntity() {
ModelConfigEntity claude = chatModel("anthropic", "claude-3-5-sonnet", false);
// resolveModel's first selectOne (lookup by name) hits.
when(modelConfigMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(claude);
ModelConfigEntity result = service.resolveModel("claude-3-5-sonnet");
assertNotNull(result);
assertEquals("anthropic", result.getProvider());
assertEquals("claude-3-5-sonnet", result.getModelName());
// Exactly one lookup getDefaultModel must NOT be called.
verify(modelConfigMapper, times(1)).selectOne(any());
verify(modelProviderService, never()).isProviderConfigured(any());
}
// Unmatched fall back to default
@Test
@DisplayName("named model not found (typo / deleted) falls back to default")
void unmatchedNameFallsBack() {
ModelConfigEntity defaultModel = chatModel("dashscope", "qwen-plus", true);
// First call (lookup by name) returns null; second call (default) returns the default.
when(modelConfigMapper.selectOne(any(LambdaQueryWrapper.class)))
.thenReturn(null) // 1st: name lookup misses
.thenReturn(defaultModel); // 2nd: default flag lookup
when(modelProviderService.isProviderConfigured("dashscope")).thenReturn(true);
ModelConfigEntity result = service.resolveModel("ghost-model");
assertNotNull(result);
assertEquals("qwen-plus", result.getModelName());
// Two queries one miss, then the default fallback.
verify(modelConfigMapper, times(2)).selectOne(any());
}
}

View File

@ -0,0 +1,191 @@
package vip.mate.llm.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.test.web.client.MockRestServiceServer;
import org.springframework.web.client.RestClient;
import vip.mate.exception.MateClawException;
import vip.mate.llm.model.ModelInfoDTO;
import vip.mate.llm.model.ModelProviderEntity;
import vip.mate.llm.oauth.OpenAIOAuthService;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.header;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withStatus;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
/**
* Unit tests for ChatGPT OAuth model discovery the only protocol where we
* call a separate endpoint with the user's OAuth bearer token instead of an
* API key. Lower-protocol behaviour (filter, probe, dedupe) is exercised by
* the rest of {@link ModelDiscoveryService} indirectly and out of scope here.
*/
class ModelDiscoveryServiceChatGPTOAuthTest {
private ModelDiscoveryService service;
private OpenAIOAuthService oauthService;
private MockRestServiceServer mockServer;
@BeforeEach
void setUp() {
ModelProviderService providerService = mock(ModelProviderService.class);
ModelConfigService configService = mock(ModelConfigService.class);
oauthService = mock(OpenAIOAuthService.class);
when(oauthService.ensureValidAccessToken()).thenReturn("test-access-token");
when(configService.listModelsByProvider(any())).thenReturn(List.of());
ModelProviderEntity provider = new ModelProviderEntity();
provider.setProviderId("openai-chatgpt");
provider.setChatModel("ChatGPTChatModel");
provider.setSupportModelDiscovery(true);
when(providerService.getProviderConfig("openai-chatgpt")).thenReturn(provider);
service = new ModelDiscoveryService(providerService, configService,
new ObjectMapper(), oauthService);
RestClient.Builder builder = RestClient.builder();
mockServer = MockRestServiceServer.bindTo(builder).build();
service.setChatgptCodexClient(builder.build());
}
// ---------------------------------------------------------------------
// parseChatGPTCodexModelsResponse pure parsing tests
// ---------------------------------------------------------------------
@Test
@DisplayName("parser drops supported_in_api=false and visibility=hide entries")
void parser_dropsHiddenAndUnsupported() {
String body = "{\"models\":["
+ "{\"slug\":\"gpt-5.4\",\"supported_in_api\":true,\"visibility\":\"shown\",\"priority\":10},"
+ "{\"slug\":\"gpt-internal\",\"supported_in_api\":false,\"priority\":5},"
+ "{\"slug\":\"gpt-research\",\"supported_in_api\":true,\"visibility\":\"hide\",\"priority\":1},"
+ "{\"slug\":\"gpt-5.4-mini\",\"supported_in_api\":true,\"visibility\":\"shown\",\"priority\":20}"
+ "]}";
List<ModelInfoDTO> models = service.parseChatGPTCodexModelsResponse(body);
List<String> ids = models.stream().map(ModelInfoDTO::getId).toList();
assertEquals(List.of("gpt-5.4", "gpt-5.4-mini"), ids);
}
@Test
@DisplayName("parser sorts by priority ascending")
void parser_sortsByPriority() {
String body = "{\"models\":["
+ "{\"slug\":\"third\",\"supported_in_api\":true,\"priority\":30},"
+ "{\"slug\":\"first\",\"supported_in_api\":true,\"priority\":1},"
+ "{\"slug\":\"second\",\"supported_in_api\":true,\"priority\":15}"
+ "]}";
List<String> ids = service.parseChatGPTCodexModelsResponse(body)
.stream().map(ModelInfoDTO::getId).toList();
assertEquals(List.of("first", "second", "third"), ids);
}
@Test
@DisplayName("parser tolerates missing or non-list bodies")
void parser_tolerantOfBadInput() {
assertTrue(service.parseChatGPTCodexModelsResponse(null).isEmpty());
assertTrue(service.parseChatGPTCodexModelsResponse("").isEmpty());
assertTrue(service.parseChatGPTCodexModelsResponse("{}").isEmpty());
assertTrue(service.parseChatGPTCodexModelsResponse("{\"models\": \"not-a-list\"}").isEmpty());
assertTrue(service.parseChatGPTCodexModelsResponse("not-json").isEmpty());
}
// ---------------------------------------------------------------------
// addChatGPTForwardCompatModels the synthesis layer
// ---------------------------------------------------------------------
@Test
@DisplayName("forward-compat synthesizes gpt-5.5 when only gpt-5.4 is exposed")
void forwardCompat_synthesizesGpt55FromGpt54() {
List<ModelInfoDTO> input = List.of(new ModelInfoDTO("gpt-5.4", "gpt-5.4"));
List<String> out = ModelDiscoveryService.addChatGPTForwardCompatModels(input)
.stream().map(ModelInfoDTO::getId).toList();
assertTrue(out.contains("gpt-5.5"), "Expected gpt-5.5 to be appended; got " + out);
assertTrue(out.contains("gpt-5.4"));
}
@Test
@DisplayName("forward-compat does not duplicate slugs already in the input")
void forwardCompat_noDuplicates() {
List<ModelInfoDTO> input = List.of(
new ModelInfoDTO("gpt-5.5", "gpt-5.5"),
new ModelInfoDTO("gpt-5.4", "gpt-5.4"));
List<String> out = ModelDiscoveryService.addChatGPTForwardCompatModels(input)
.stream().map(ModelInfoDTO::getId).toList();
assertEquals(1, out.stream().filter("gpt-5.5"::equals).count());
assertEquals(1, out.stream().filter("gpt-5.4"::equals).count());
}
@Test
@DisplayName("forward-compat is a no-op when no template ancestor is present")
void forwardCompat_noOpOnEmptyOrUnrelated() {
List<String> empty = ModelDiscoveryService.addChatGPTForwardCompatModels(List.of())
.stream().map(ModelInfoDTO::getId).toList();
assertTrue(empty.isEmpty());
List<String> unrelated = ModelDiscoveryService.addChatGPTForwardCompatModels(
List.of(new ModelInfoDTO("gpt-3.5", "gpt-3.5")))
.stream().map(ModelInfoDTO::getId).toList();
assertEquals(List.of("gpt-3.5"), unrelated);
}
// ---------------------------------------------------------------------
// discoverModels end-to-end through the OAuth path
// ---------------------------------------------------------------------
@Test
@DisplayName("discoverModels sends Bearer token and returns sorted+forward-compat catalog")
void discoverModels_endToEnd() {
mockServer.expect(requestTo(ModelDiscoveryService.CHATGPT_CODEX_MODELS_URL))
.andExpect(header(HttpHeaders.AUTHORIZATION, "Bearer test-access-token"))
.andRespond(withSuccess(
"{\"models\":["
+ "{\"slug\":\"gpt-5.4\",\"supported_in_api\":true,\"priority\":10},"
+ "{\"slug\":\"gpt-5.4-mini\",\"supported_in_api\":true,\"priority\":20},"
+ "{\"slug\":\"gpt-internal\",\"supported_in_api\":false,\"priority\":5}"
+ "]}",
MediaType.APPLICATION_JSON));
var result = service.discoverModels("openai-chatgpt");
List<String> all = result.getDiscoveredModels().stream().map(ModelInfoDTO::getId).toList();
// priority-sorted real models, plus gpt-5.5 synthesised by forward-compat
assertEquals(List.of("gpt-5.4", "gpt-5.4-mini", "gpt-5.5"), all);
verify(oauthService).ensureValidAccessToken();
mockServer.verify();
}
@Test
@DisplayName("discoverModels surfaces fetch failures as err.llm.chatgpt_models_fetch_failed")
void discoverModels_surfacesFetchFailure() {
mockServer.expect(requestTo(ModelDiscoveryService.CHATGPT_CODEX_MODELS_URL))
.andRespond(withStatus(HttpStatus.UNAUTHORIZED));
MateClawException ex = assertThrows(MateClawException.class,
() -> service.discoverModels("openai-chatgpt"));
assertEquals("err.llm.chatgpt_models_fetch_failed", ex.getMsgKey());
}
@Test
@DisplayName("discoverModels propagates oauth_not_connected from OpenAIOAuthService unchanged")
void discoverModels_propagatesOauthNotConnected() {
when(oauthService.ensureValidAccessToken())
.thenThrow(new MateClawException("err.llm.oauth_not_connected", "未连接"));
MateClawException ex = assertThrows(MateClawException.class,
() -> service.discoverModels("openai-chatgpt"));
assertEquals("err.llm.oauth_not_connected", ex.getMsgKey());
}
}

View File

@ -0,0 +1,325 @@
package vip.mate.llm.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.context.ApplicationEventPublisher;
import vip.mate.llm.anthropic.oauth.ClaudeCodeOAuthService;
import vip.mate.llm.failover.AvailableProviderPool;
import vip.mate.llm.failover.ProviderHealthProperties;
import vip.mate.llm.failover.ProviderHealthTracker;
import vip.mate.llm.failover.ProviderInitProbe;
import vip.mate.llm.model.Liveness;
import vip.mate.llm.model.ModelConfigEntity;
import vip.mate.llm.model.ModelProviderEntity;
import vip.mate.llm.model.ProviderInfoDTO;
import vip.mate.llm.repository.ModelProviderMapper;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
/**
* Issue #81: row-based isProviderConfigured + applySuggestedAction. Each test is
* one row of the truth table in RFC §2.3 (behavior diff vs. v1) and §7
* (suggestedAction decision tree).
*/
class ModelProviderServiceConfiguredTest {
private ModelProviderMapper providerMapper;
private ModelConfigService modelConfigService;
private ApplicationEventPublisher eventPublisher;
private ObjectProvider<ClaudeCodeOAuthService> claudeCodeOAuthProvider;
private ClaudeCodeOAuthService claudeCodeOAuthService;
private AvailableProviderPool pool;
private ProviderHealthTracker healthTracker;
private ProviderInitProbe initProbe;
private ObjectProvider<ProviderInitProbe> initProbeProvider;
private ModelProviderService service;
@BeforeEach
@SuppressWarnings("unchecked")
void setUp() {
providerMapper = mock(ModelProviderMapper.class);
modelConfigService = mock(ModelConfigService.class);
eventPublisher = mock(ApplicationEventPublisher.class);
claudeCodeOAuthProvider = mock(ObjectProvider.class);
claudeCodeOAuthService = mock(ClaudeCodeOAuthService.class);
when(claudeCodeOAuthProvider.getIfAvailable()).thenReturn(null);
pool = new AvailableProviderPool();
ProviderHealthProperties props = new ProviderHealthProperties();
props.setFailureThreshold(1);
healthTracker = new ProviderHealthTracker(props);
initProbe = mock(ProviderInitProbe.class);
initProbeProvider = mock(ObjectProvider.class);
when(initProbeProvider.getIfAvailable()).thenReturn(initProbe);
// Default: every provider has been probed so liveness is computed normally.
when(initProbe.hasBeenProbed(any())).thenReturn(true);
service = new ModelProviderService(providerMapper, modelConfigService, eventPublisher,
claudeCodeOAuthProvider, pool, healthTracker, initProbeProvider);
}
@Test
@DisplayName("Issue #81: llama.cpp local + empty Base URL → UNCONFIGURED + fill_base_url + hint")
void llamacppEmptyBaseUrl() {
ModelProviderEntity p = local("llamacpp");
p.setBaseUrl("");
seedProviderRow(p, false);
ProviderInfoDTO dto = singleResult();
assertFalse(dto.getConfigured(), "empty Base URL must NOT be considered configured");
assertEquals(Liveness.UNCONFIGURED, dto.getLiveness());
assertEquals("fill_base_url", dto.getSuggestedAction());
assertEquals("provider.hint.llamacppBaseUrlExample", dto.getSuggestedActionHintKey());
assertEquals("http://127.0.0.1:8080/v1", dto.getSuggestedActionHintArgs().get("example"));
assertEquals("baseUrl", dto.getMissingFields());
assertEquals("NOT_REQUIRED", dto.getAuthStatus());
assertFalse(dto.getBaseUrlComplete());
}
@Test
@DisplayName("llama.cpp local + Base URL filled but pool REMOVED → REMOVED + reprobe")
void llamacppBaseUrlFilledButRemoved() {
ModelProviderEntity p = local("llamacpp");
p.setBaseUrl("http://127.0.0.1:8080/v1");
seedProviderRow(p, true);
pool.remove("llamacpp", AvailableProviderPool.RemovalSource.INIT_PROBE,
"init probe failed: connection refused");
ProviderInfoDTO dto = singleResult();
assertTrue(dto.getConfigured());
assertEquals(Liveness.REMOVED, dto.getLiveness());
assertEquals("reprobe", dto.getSuggestedAction());
assertNull(dto.getSuggestedActionHintKey(), "REMOVED state should not carry a hint key");
}
@Test
@DisplayName("Ollama local + LIVE + 0 models + supportModelDiscovery=true → pull_model")
void ollamaLiveNoModels() {
ModelProviderEntity p = local("ollama");
p.setBaseUrl("http://127.0.0.1:11434");
p.setSupportModelDiscovery(true);
when(providerMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(p));
when(modelConfigService.listModels()).thenReturn(List.of()); // no models registered
pool.add("ollama");
ProviderInfoDTO dto = singleResult();
assertEquals(Liveness.LIVE, dto.getLiveness());
assertEquals("pull_model", dto.getSuggestedAction());
}
@Test
@DisplayName("OpenAI cloud + apiKey empty → UNCONFIGURED + fill_api_key + no hint")
void openaiCloudEmptyApiKey() {
ModelProviderEntity p = cloud("openai", true);
p.setApiKey("");
seedProviderRow(p, false);
ProviderInfoDTO dto = singleResult();
assertFalse(dto.getConfigured());
assertEquals(Liveness.UNCONFIGURED, dto.getLiveness());
assertEquals("fill_api_key", dto.getSuggestedAction());
assertNull(dto.getSuggestedActionHintKey(), "cloud providers don't need a base-url hint");
assertEquals("MISSING", dto.getAuthStatus());
assertEquals("apiKey", dto.getMissingFields());
assertNull(dto.getBaseUrlComplete(), "cloud provider's baseUrlComplete should be null (n/a)");
}
@Test
@DisplayName("OpenAI cloud + apiKey filled + LIVE → none + CONFIGURED")
void openaiCloudHealthy() {
ModelProviderEntity p = cloud("openai", true);
p.setApiKey("sk-test-1234567890");
seedProviderRow(p, true);
pool.add("openai");
ProviderInfoDTO dto = singleResult();
assertTrue(dto.getConfigured());
assertEquals(Liveness.LIVE, dto.getLiveness());
assertEquals("none", dto.getSuggestedAction());
assertEquals("CONFIGURED", dto.getAuthStatus());
assertEquals("", dto.getMissingFields());
}
@Test
@DisplayName("Kimi cloud + apiKey empty → fill_api_key (same shape as OpenAI)")
void kimiCloudEmptyApiKey() {
ModelProviderEntity p = cloud("kimi", true);
p.setApiKey("");
seedProviderRow(p, false);
ProviderInfoDTO dto = singleResult();
assertFalse(dto.getConfigured());
assertEquals("fill_api_key", dto.getSuggestedAction());
}
@Test
@DisplayName("Custom OpenAI-compat + baseUrl empty + apiKey filled + requireApiKey=true → fill_base_url")
void customOpenAiCompatEmptyBaseUrl() {
ModelProviderEntity p = custom("my-server");
p.setRequireApiKey(true);
p.setBaseUrl("");
p.setApiKey("sk-test-1234567890");
seedProviderRow(p, false);
ProviderInfoDTO dto = singleResult();
assertFalse(dto.getConfigured());
assertEquals("fill_base_url", dto.getSuggestedAction());
assertEquals("baseUrl", dto.getMissingFields());
}
@Test
@DisplayName("Custom OpenAI-compat + baseUrl filled + apiKey empty + requireApiKey=true → fill_api_key")
void customOpenAiCompatEmptyApiKey() {
ModelProviderEntity p = custom("my-server");
p.setRequireApiKey(true);
p.setBaseUrl("http://x.example.com/v1");
p.setApiKey("");
seedProviderRow(p, false);
ProviderInfoDTO dto = singleResult();
assertFalse(dto.getConfigured());
assertEquals("fill_api_key", dto.getSuggestedAction());
assertEquals("apiKey", dto.getMissingFields());
}
@Test
@DisplayName("Custom OpenAI-compat + both empty + requireApiKey=true → configure_required_fields + both missing")
void customOpenAiCompatBothEmpty() {
ModelProviderEntity p = custom("my-server");
p.setRequireApiKey(true);
p.setBaseUrl("");
p.setApiKey("");
seedProviderRow(p, false);
ProviderInfoDTO dto = singleResult();
assertFalse(dto.getConfigured());
assertEquals("configure_required_fields", dto.getSuggestedAction());
assertEquals("apiKey,baseUrl", dto.getMissingFields());
// hint emitted because action is configure_required_fields
assertEquals("provider.hint.openaiCompatBaseUrlExample", dto.getSuggestedActionHintKey());
}
@Test
@DisplayName("Custom OpenAI-compat + both filled + LIVE → none")
void customOpenAiCompatHealthy() {
ModelProviderEntity p = custom("my-server");
p.setRequireApiKey(true);
p.setBaseUrl("http://x.example.com/v1");
p.setApiKey("sk-test-1234567890");
seedProviderRow(p, true);
pool.add("my-server");
ProviderInfoDTO dto = singleResult();
assertTrue(dto.getConfigured());
assertEquals(Liveness.LIVE, dto.getLiveness());
assertEquals("none", dto.getSuggestedAction());
}
@Test
@DisplayName("OAuth provider not connected → UNCONFIGURED + start_oauth + OAUTH_PENDING")
void oauthNotConnected() {
ModelProviderEntity p = new ModelProviderEntity();
p.setProviderId("some-oauth");
p.setName("Some OAuth");
p.setAuthType("oauth");
// No oauthAccessToken not configured.
seedProviderRow(p, false);
ProviderInfoDTO dto = singleResult();
assertFalse(dto.getConfigured());
assertEquals(Liveness.UNCONFIGURED, dto.getLiveness());
assertEquals("start_oauth", dto.getSuggestedAction());
assertEquals("OAUTH_PENDING", dto.getAuthStatus());
}
@Test
@DisplayName("OAuth provider connected → LIVE + CONFIGURED")
void oauthConnected() {
ModelProviderEntity p = new ModelProviderEntity();
p.setProviderId("some-oauth");
p.setName("Some OAuth");
p.setAuthType("oauth");
p.setOauthAccessToken("ya29.test");
seedProviderRow(p, true);
pool.add("some-oauth");
ProviderInfoDTO dto = singleResult();
assertTrue(dto.getConfigured());
assertEquals(Liveness.LIVE, dto.getLiveness());
assertEquals("CONFIGURED", dto.getAuthStatus());
}
@Test
@DisplayName("Default 'enabled' filter: providers without enabled=true are excluded")
void defaultProviderRespectsEnabledFlag() {
// Sanity: the existing infrastructure still gates on enabled when listProviders
// is called. seedProviderRow sets enabled=true so this is just defensive.
ModelProviderEntity p = local("ollama");
p.setEnabled(true);
seedProviderRow(p, true);
pool.add("ollama");
assertEquals(1, service.listProviders().size());
}
// ============================================================
// Helpers
// ============================================================
private void seedProviderRow(ModelProviderEntity p, boolean withModel) {
if (p.getEnabled() == null) p.setEnabled(true);
when(providerMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(p));
if (withModel) {
ModelConfigEntity m = new ModelConfigEntity();
m.setProvider(p.getProviderId());
m.setModelName(p.getProviderId() + "-model");
m.setName(p.getProviderId() + "-model");
m.setBuiltin(true);
when(modelConfigService.listModels()).thenReturn(List.of(m));
} else {
when(modelConfigService.listModels()).thenReturn(List.of());
}
}
private static ModelProviderEntity cloud(String id, boolean requireApiKey) {
ModelProviderEntity p = new ModelProviderEntity();
p.setProviderId(id);
p.setName(id);
p.setIsLocal(false);
p.setIsCustom(false);
p.setRequireApiKey(requireApiKey);
return p;
}
private static ModelProviderEntity local(String id) {
ModelProviderEntity p = new ModelProviderEntity();
p.setProviderId(id);
p.setName(id);
p.setIsLocal(true);
p.setIsCustom(false);
p.setRequireApiKey(false);
p.setBaseUrl("http://127.0.0.1:11434"); // overridden per test as needed
return p;
}
private static ModelProviderEntity custom(String id) {
ModelProviderEntity p = new ModelProviderEntity();
p.setProviderId(id);
p.setName(id);
p.setIsLocal(false);
p.setIsCustom(true);
return p;
}
private ProviderInfoDTO singleResult() {
List<ProviderInfoDTO> list = service.listProviders();
assertEquals(1, list.size());
return list.get(0);
}
}

View File

@ -0,0 +1,259 @@
package vip.mate.llm.service;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.context.ApplicationEventPublisher;
import vip.mate.exception.MateClawException;
import vip.mate.llm.anthropic.oauth.ClaudeCodeOAuthService;
import vip.mate.llm.failover.AvailableProviderPool;
import vip.mate.llm.failover.ProviderHealthProperties;
import vip.mate.llm.failover.ProviderHealthTracker;
import vip.mate.llm.failover.ProviderInitProbe;
import vip.mate.llm.model.CreateCustomProviderRequest;
import vip.mate.llm.model.ModelProviderEntity;
import vip.mate.llm.model.ProviderConfigRequest;
import vip.mate.llm.repository.ModelProviderMapper;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
import org.mockito.ArgumentCaptor;
/**
* Issue #39 regression: provider id ends up as a single path segment in
* {@code /custom-providers/{providerId}}, so any unsafe character (slash,
* space, {@code #}, {@code ?}) makes Spring's PathPatternParser miss the
* controller and fall through to the static-resource handler symptom is
* a {@code NoResourceFoundException} on the DELETE the user reported.
*
* <p>These tests pin the two layers of the fix:</p>
* <ul>
* <li>{@code createCustomProvider} rejects unsafe ids server-side, so a
* non-UI client (curl / Electron / 3rd-party) cannot bypass the
* front-end regex and persist a row that's later undeletable.</li>
* <li>{@code deleteCustomProvider} itself doesn't care about the shape
* of the id it deletes by primary key. Anything that <em>did</em>
* slip into the DB before the create-side guard existed can still be
* cleaned up via the query-param controller variant.</li>
* </ul>
*/
class ModelProviderServiceCustomProviderTest {
private ModelProviderMapper providerMapper;
private ModelConfigService modelConfigService;
private ApplicationEventPublisher eventPublisher;
private ObjectProvider<ClaudeCodeOAuthService> claudeCodeOAuthProvider;
private AvailableProviderPool pool;
private ProviderHealthTracker healthTracker;
private ProviderInitProbe initProbe;
private ObjectProvider<ProviderInitProbe> initProbeProvider;
private ModelProviderService service;
@BeforeEach
@SuppressWarnings("unchecked")
void setUp() {
providerMapper = mock(ModelProviderMapper.class);
modelConfigService = mock(ModelConfigService.class);
eventPublisher = mock(ApplicationEventPublisher.class);
claudeCodeOAuthProvider = mock(ObjectProvider.class);
when(claudeCodeOAuthProvider.getIfAvailable()).thenReturn(null);
pool = new AvailableProviderPool();
healthTracker = new ProviderHealthTracker(new ProviderHealthProperties());
initProbe = mock(ProviderInitProbe.class);
initProbeProvider = mock(ObjectProvider.class);
when(initProbeProvider.getIfAvailable()).thenReturn(initProbe);
service = new ModelProviderService(providerMapper, modelConfigService, eventPublisher,
claudeCodeOAuthProvider, pool, healthTracker, initProbeProvider);
}
// ==================== create-side guard ====================
@Test
@DisplayName("createCustomProvider rejects ids containing '/' (issue #39 root cause)")
void rejectsSlashInId() {
CreateCustomProviderRequest req = req("google/gemma-4-e4b", "Local Gemma");
MateClawException ex = assertThrows(MateClawException.class,
() -> service.createCustomProvider(req));
assertEquals("err.llm.provider_id_invalid", ex.getMsgKey());
verify(providerMapper, never()).insert(any(ModelProviderEntity.class));
verify(eventPublisher, never()).publishEvent(any());
}
@Test
@DisplayName("createCustomProvider rejects ids containing whitespace")
void rejectsSpaceInId() {
CreateCustomProviderRequest req = req("my provider", "Local Gemma");
MateClawException ex = assertThrows(MateClawException.class,
() -> service.createCustomProvider(req));
assertEquals("err.llm.provider_id_invalid", ex.getMsgKey());
verify(providerMapper, never()).insert(any(ModelProviderEntity.class));
}
@Test
@DisplayName("createCustomProvider rejects ids starting with '-' (regex requires alnum first char)")
void rejectsLeadingHyphen() {
CreateCustomProviderRequest req = req("-foo", "Local Gemma");
MateClawException ex = assertThrows(MateClawException.class,
() -> service.createCustomProvider(req));
assertEquals("err.llm.provider_id_invalid", ex.getMsgKey());
}
@Test
@DisplayName("createCustomProvider rejects ids longer than 64 characters")
void rejectsOverlongId() {
// 65 chars: 'a' followed by 64 'b's.
String tooLong = "a" + "b".repeat(64);
CreateCustomProviderRequest req = req(tooLong, "Local Gemma");
MateClawException ex = assertThrows(MateClawException.class,
() -> service.createCustomProvider(req));
assertEquals("err.llm.provider_id_invalid", ex.getMsgKey());
}
@Test
@DisplayName("createCustomProvider accepts a normal id (e.g. 'local-gemma') and persists")
void acceptsNormalId() {
CreateCustomProviderRequest req = req("local-gemma", "Local Gemma");
when(providerMapper.selectById("local-gemma")).thenReturn(null);
service.createCustomProvider(req);
verify(providerMapper).insert(any(ModelProviderEntity.class));
}
@Test
@DisplayName("createCustomProvider accepts ids with dot/underscore/hyphen and digits")
void acceptsRichButSafeChars() {
CreateCustomProviderRequest req = req("My_Local-Gemma.v2", "Local Gemma");
when(providerMapper.selectById("My_Local-Gemma.v2")).thenReturn(null);
service.createCustomProvider(req);
verify(providerMapper).insert(any(ModelProviderEntity.class));
}
@Test
@DisplayName("createCustomProvider persists requireApiKey=false for keyless internal OpenAI-compatible endpoints")
void createCustomProviderCanDisableApiKeyRequirement() {
CreateCustomProviderRequest req = req("internal-llm", "Internal LLM");
req.setDefaultBaseUrl("http://llm.internal/v1");
req.setRequireApiKey(false);
when(providerMapper.selectById("internal-llm")).thenReturn(null);
service.createCustomProvider(req);
ArgumentCaptor<ModelProviderEntity> captor = ArgumentCaptor.forClass(ModelProviderEntity.class);
verify(providerMapper).insert(captor.capture());
assertFalse(captor.getValue().getRequireApiKey());
}
@Test
@DisplayName("Empty id still produces 'fields_required' (existing guard, not the new regex)")
void emptyIdStillReportsFieldsRequired() {
CreateCustomProviderRequest req = req("", "Local Gemma");
MateClawException ex = assertThrows(MateClawException.class,
() -> service.createCustomProvider(req));
assertEquals("err.llm.provider_fields_required", ex.getMsgKey());
}
// ==================== delete-side: dirty data rescue ====================
@Test
@DisplayName("deleteCustomProvider works for an id with '/' once it reaches the service "
+ "(query-param controller variant is the URL bridge)")
void deletesIdContainingSlash() {
String dirtyId = "google/gemma-4-e4b";
ModelProviderEntity dirty = customProvider(dirtyId);
when(providerMapper.selectById(dirtyId)).thenReturn(dirty);
service.deleteCustomProvider(dirtyId);
verify(modelConfigService).deleteModelsByProvider(dirtyId);
verify(providerMapper).deleteById(dirtyId);
}
@Test
@DisplayName("deleteCustomProvider on a normal id (path-variant happy path) still works")
void deletesNormalId() {
String id = "local-gemma";
ModelProviderEntity p = customProvider(id);
when(providerMapper.selectById(id)).thenReturn(p);
service.deleteCustomProvider(id);
verify(modelConfigService).deleteModelsByProvider(id);
verify(providerMapper).deleteById(id);
}
@Test
@DisplayName("deleteCustomProvider refuses to delete a built-in (non-custom) provider")
void refusesToDeleteBuiltin() {
String id = "openai";
ModelProviderEntity builtin = customProvider(id);
builtin.setIsCustom(false);
when(providerMapper.selectById(id)).thenReturn(builtin);
MateClawException ex = assertThrows(MateClawException.class,
() -> service.deleteCustomProvider(id));
assertEquals("err.llm.provider_builtin_readonly", ex.getMsgKey());
verify(providerMapper, never()).deleteById(any(String.class));
verify(modelConfigService, never()).deleteModelsByProvider(any());
}
@Test
@DisplayName("updateProviderConfig can switch an existing custom provider to keyless mode")
void updateProviderConfigCanDisableApiKeyRequirement() {
String id = "internal-llm";
ModelProviderEntity existing = customProvider(id);
existing.setBaseUrl("http://llm.internal/v1");
existing.setRequireApiKey(true);
when(providerMapper.selectById(id)).thenReturn(existing);
when(modelConfigService.listModelsByProvider(id)).thenReturn(java.util.List.of());
ProviderConfigRequest req = new ProviderConfigRequest();
req.setBaseUrl("http://llm.internal/v1");
req.setProtocol("openai-compatible");
req.setChatModel("OpenAIChatModel");
req.setRequireApiKey(false);
service.updateProviderConfig(id, req);
assertFalse(existing.getRequireApiKey());
verify(providerMapper).updateById(existing);
}
// ==================== fixtures ====================
private static CreateCustomProviderRequest req(String id, String name) {
CreateCustomProviderRequest r = new CreateCustomProviderRequest();
r.setId(id);
r.setName(name);
r.setProtocol("openai-compatible");
r.setChatModel("OpenAIChatModel");
return r;
}
private static ModelProviderEntity customProvider(String id) {
ModelProviderEntity p = new ModelProviderEntity();
p.setProviderId(id);
p.setName(id);
p.setIsCustom(true);
p.setIsLocal(false);
p.setEnabled(true);
return p;
}
}

Some files were not shown because too many files have changed in this diff Show More