diff --git a/mateclaw-server/pom.xml b/mateclaw-server/pom.xml
index 8b476be9..a2dd0b4c 100644
--- a/mateclaw-server/pom.xml
+++ b/mateclaw-server/pom.xml
@@ -645,28 +645,5 @@
-
-
- skip-baseline-drift
-
-
-
- org.apache.maven.plugins
- maven-compiler-plugin
-
-
- vip/mate/llm/oauth/OpenAIOAuthServiceFlowModeTest.java
- vip/mate/wiki/service/WikiEmbeddingCircuitBreakerTest.java
-
-
-
-
-
-
diff --git a/mateclaw-server/src/test/java/vip/mate/acp/client/AcpStdioClientTest.java b/mateclaw-server/src/test/java/vip/mate/acp/client/AcpStdioClientTest.java
new file mode 100644
index 00000000..ec94b2a5
--- /dev/null
+++ b/mateclaw-server/src/test/java/vip/mate/acp/client/AcpStdioClientTest.java
@@ -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}.
+ *
+ *
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:
+ *
+ * spawn → request → response → close all happen cleanly,
+ * protocolVersion is parsed from the result,
+ * the reader thread doesn't leak past close.
+ *
+ *
+ * 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;
+ }
+}
diff --git a/mateclaw-server/src/test/java/vip/mate/agent/AgentServiceUniquenessTest.java b/mateclaw-server/src/test/java/vip/mate/agent/AgentServiceUniquenessTest.java
new file mode 100644
index 00000000..1e5957c7
--- /dev/null
+++ b/mateclaw-server/src/test/java/vip/mate/agent/AgentServiceUniquenessTest.java
@@ -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.
+ *
+ *
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());
+ }
+}
diff --git a/mateclaw-server/src/test/java/vip/mate/agent/AgentToolSetTest.java b/mateclaw-server/src/test/java/vip/mate/agent/AgentToolSetTest.java
new file mode 100644
index 00000000..091ac479
--- /dev/null
+++ b/mateclaw-server/src/test/java/vip/mate/agent/AgentToolSetTest.java
@@ -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 issue #24 .
+ *
+ * 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}).
+ *
+ * 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 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 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 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());
+ }
+}
diff --git a/mateclaw-server/src/test/java/vip/mate/agent/AssistantThinkingRelayTest.java b/mateclaw-server/src/test/java/vip/mate/agent/AssistantThinkingRelayTest.java
new file mode 100644
index 00000000..63f858fe
--- /dev/null
+++ b/mateclaw-server/src/test/java/vip/mate/agent/AssistantThinkingRelayTest.java
@@ -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 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 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"));
+ }
+}
diff --git a/mateclaw-server/src/test/java/vip/mate/agent/BaseAgentApprovalSanitizationTest.java b/mateclaw-server/src/test/java/vip/mate/agent/BaseAgentApprovalSanitizationTest.java
new file mode 100644
index 00000000..37de2e4e
--- /dev/null
+++ b/mateclaw-server/src/test/java/vip/mate/agent/BaseAgentApprovalSanitizationTest.java
@@ -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.
+ *
+ * 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.
+ *
+ * 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;
+ }
+}
diff --git a/mateclaw-server/src/test/java/vip/mate/agent/BaseAgentDirectToolHistoryScrubTest.java b/mateclaw-server/src/test/java/vip/mate/agent/BaseAgentDirectToolHistoryScrubTest.java
new file mode 100644
index 00000000..2dd03993
--- /dev/null
+++ b/mateclaw-server/src/test/java/vip/mate/agent/BaseAgentDirectToolHistoryScrubTest.java
@@ -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.
+ *
+ *
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"));
+ }
+}
diff --git a/mateclaw-server/src/test/java/vip/mate/agent/BaseAgentMultimodalSkipNoticeTest.java b/mateclaw-server/src/test/java/vip/mate/agent/BaseAgentMultimodalSkipNoticeTest.java
new file mode 100644
index 00000000..e5a94d8b
--- /dev/null
+++ b/mateclaw-server/src/test/java/vip/mate/agent/BaseAgentMultimodalSkipNoticeTest.java
@@ -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.
+ *
+ *
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.
+ *
+ *
These tests pin the contract that the skip path mutates the prompt text,
+ * not just a log line.
+ *
+ *
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 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 chatStream(String userMessage, String conversationId) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public String execute(String goal, String conversationId) {
+ throw new UnsupportedOperationException();
+ }
+ }
+}
diff --git a/mateclaw-server/src/test/java/vip/mate/agent/GraphEventPublisherIterationTest.java b/mateclaw-server/src/test/java/vip/mate/agent/GraphEventPublisherIterationTest.java
new file mode 100644
index 00000000..7b96b8b5
--- /dev/null
+++ b/mateclaw-server/src/test/java/vip/mate/agent/GraphEventPublisherIterationTest.java
@@ -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 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 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 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 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 data = event.data();
+ assertEquals("parent", data.get("scope"));
+ assertFalse(data.containsKey("subagentId"),
+ "Empty subagentId must be omitted");
+ }
+}
diff --git a/mateclaw-server/src/test/java/vip/mate/agent/PatchReasoningContentTest.java b/mateclaw-server/src/test/java/vip/mate/agent/PatchReasoningContentTest.java
new file mode 100644
index 00000000..b28bc1b7
--- /dev/null
+++ b/mateclaw-server/src/test/java/vip/mate/agent/PatchReasoningContentTest.java
@@ -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)}.
+ *
+ * Covers four orthogonal dimensions:
+ *
+ * FallbackPolicy — DEEPSEEK (null + warn + patchNonToolCall) vs KIMI / OPENAI /
+ * DEFAULT (" " + no-warn + tool-call-only)
+ * {@code lastUserIdx} scope — assistants at {@code i <= lastUserIdx} never patched;
+ * iterator still advances for alignment
+ * sanitizedUser — restored from {@code RelayEntry.originalUser}; relay token
+ * never egresses
+ * relay presence — iterator consumed in order; missing relay triggers policy
+ * fallback only for in-turn messages
+ *
+ */
+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 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 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 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 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 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 thinkings = List.of("");
+ String token = AssistantThinkingRelay.stash(thinkings, null);
+
+ List 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 thinkings = List.of("");
+ String token = AssistantThinkingRelay.stash(thinkings, null);
+
+ // Use a model that triggers requiresReasoningContentPatch so thinking mode is active
+ List 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 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 thinkings = List.of("would-not-be-used");
+ String token = AssistantThinkingRelay.stash(thinkings, null);
+
+ List 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 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 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 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 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");
+ }
+}
diff --git a/mateclaw-server/src/test/java/vip/mate/agent/ReasoningEffortSanitizerTest.java b/mateclaw-server/src/test/java/vip/mate/agent/ReasoningEffortSanitizerTest.java
new file mode 100644
index 00000000..762b15e2
--- /dev/null
+++ b/mateclaw-server/src/test/java/vip/mate/agent/ReasoningEffortSanitizerTest.java
@@ -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.
+ *
+ * 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());
+ }
+}
diff --git a/mateclaw-server/src/test/java/vip/mate/agent/binding/AgentBindingServiceTest.java b/mateclaw-server/src/test/java/vip/mate/agent/binding/AgentBindingServiceTest.java
index c94a951b..2a47afb5 100644
--- a/mateclaw-server/src/test/java/vip/mate/agent/binding/AgentBindingServiceTest.java
+++ b/mateclaw-server/src/test/java/vip/mate/agent/binding/AgentBindingServiceTest.java
@@ -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 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 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 虚拟 skill(McpServerEntity 无 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 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 ids = bindingService.getBoundSkillIds(agentId);
+ assertNotNull(ids);
+ assertTrue(ids.contains(virtualAcpId), "ACP virtual binding 应当落到 mate_agent_skill");
+ }
+
+ @Test
+ @DisplayName("bindSkill 拒绝跨 workspace 的 ACP 虚拟 skill(endpoint 的 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 remaining = bindingService.getBoundSkillIds(agentId);
+ assertNotNull(remaining);
+ assertTrue(remaining.contains(goodSkill),
+ "validation 必须在 delete 旧绑定之前完成,否则会留下空绑定状态");
+ }
+
@Test
@DisplayName("unbindTool 后 DB 里真的没行(物理 delete,不是软删留 deleted=1)")
void unbindPhysicallyRemovesRow() {
diff --git a/mateclaw-server/src/test/java/vip/mate/agent/binding/AgentBindingServiceValidationTest.java b/mateclaw-server/src/test/java/vip/mate/agent/binding/AgentBindingServiceValidationTest.java
new file mode 100644
index 00000000..dbc9a34e
--- /dev/null
+++ b/mateclaw-server/src/test/java/vip/mate/agent/binding/AgentBindingServiceValidationTest.java
@@ -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 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();
+ }
+}
diff --git a/mateclaw-server/src/test/java/vip/mate/agent/chatmodel/AgentAnthropicChatModelBuilderClaude47Test.java b/mateclaw-server/src/test/java/vip/mate/agent/chatmodel/AgentAnthropicChatModelBuilderClaude47Test.java
new file mode 100644
index 00000000..d8e90124
--- /dev/null
+++ b/mateclaw-server/src/test/java/vip/mate/agent/chatmodel/AgentAnthropicChatModelBuilderClaude47Test.java
@@ -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.
+ *
+ * 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");
+ }
+}
diff --git a/mateclaw-server/src/test/java/vip/mate/agent/chatmodel/AgentClaudeCodeChatModelBuilderTest.java b/mateclaw-server/src/test/java/vip/mate/agent/chatmodel/AgentClaudeCodeChatModelBuilderTest.java
new file mode 100644
index 00000000..a5bd62fc
--- /dev/null
+++ b/mateclaw-server/src/test/java/vip/mate/agent/chatmodel/AgentClaudeCodeChatModelBuilderTest.java
@@ -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 ObjectProvider providerOf(Supplier supplier) {
+ ObjectProvider 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 fallback = inv.getArgument(0);
+ T v = supplier.get();
+ return v != null ? v : fallback.get();
+ });
+ return mock;
+ }
+}
diff --git a/mateclaw-server/src/test/java/vip/mate/agent/chatmodel/ClaudeCodeIdentityChatModelDecoratorTest.java b/mateclaw-server/src/test/java/vip/mate/agent/chatmodel/ClaudeCodeIdentityChatModelDecoratorTest.java
new file mode 100644
index 00000000..3356f1a1
--- /dev/null
+++ b/mateclaw-server/src/test/java/vip/mate/agent/chatmodel/ClaudeCodeIdentityChatModelDecoratorTest.java
@@ -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 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_")
+ 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 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 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 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 stream(Prompt prompt) { return Flux.empty(); }
+ }
+}
diff --git a/mateclaw-server/src/test/java/vip/mate/agent/chatmodel/DeepSeekV4ThinkingDecoratorTest.java b/mateclaw-server/src/test/java/vip/mate/agent/chatmodel/DeepSeekV4ThinkingDecoratorTest.java
new file mode 100644
index 00000000..73e8b1b2
--- /dev/null
+++ b/mateclaw-server/src/test/java/vip/mate/agent/chatmodel/DeepSeekV4ThinkingDecoratorTest.java
@@ -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.
+ *
+ * Two independent invariants are tested separately because they're each
+ * easy to silently break:
+ *
+ * {@code extraBody.thinking} + {@code reasoning_effort} on the options.
+ * {@code reasoning_content} on prior assistant tool-call messages
+ * (ensure-when-enabled / strip-when-disabled).
+ *
+ */
+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 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 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 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 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 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 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 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 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 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 stream(Prompt prompt) { return Flux.empty(); }
+ }
+}
diff --git a/mateclaw-server/src/test/java/vip/mate/agent/context/ChatOriginTest.java b/mateclaw-server/src/test/java/vip/mate/agent/context/ChatOriginTest.java
new file mode 100644
index 00000000..d84fde7b
--- /dev/null
+++ b/mateclaw-server/src/test/java/vip/mate/agent/context/ChatOriginTest.java
@@ -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);
+ }
+}
diff --git a/mateclaw-server/src/test/java/vip/mate/agent/context/ConversationWindowManagerToolPruningTest.java b/mateclaw-server/src/test/java/vip/mate/agent/context/ConversationWindowManagerToolPruningTest.java
new file mode 100644
index 00000000..a2712cd2
--- /dev/null
+++ b/mateclaw-server/src/test/java/vip/mate/agent/context/ConversationWindowManagerToolPruningTest.java
@@ -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 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 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 messages = List.of(
+ toolMessage("old-1", "read_file", repeated),
+ toolMessage("new-1", "read_file", repeated)
+ );
+
+ List 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();
+ }
+}
diff --git a/mateclaw-server/src/test/java/vip/mate/agent/context/TokenEstimatorToolsTest.java b/mateclaw-server/src/test/java/vip/mate/agent/context/TokenEstimatorToolsTest.java
new file mode 100644
index 00000000..29e25994
--- /dev/null
+++ b/mateclaw-server/src/test/java/vip/mate/agent/context/TokenEstimatorToolsTest.java
@@ -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);
+ }
+}
diff --git a/mateclaw-server/src/test/java/vip/mate/agent/delegation/SubagentControllerTest.java b/mateclaw-server/src/test/java/vip/mate/agent/delegation/SubagentControllerTest.java
new file mode 100644
index 00000000..8e894326
--- /dev/null
+++ b/mateclaw-server/src/test/java/vip/mate/agent/delegation/SubagentControllerTest.java
@@ -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> 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 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 body = new HashMap<>();
+ body.put("parentConversationId", "parent-1");
+ body.put("paused", true);
+
+ R> 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 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> resp = controller.listActive("parent-1", ownerAuth);
+
+ @SuppressWarnings("unchecked")
+ List> subagents = (List>) 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);
+ }
+}
diff --git a/mateclaw-server/src/test/java/vip/mate/agent/delegation/SubagentHeartbeatTest.java b/mateclaw-server/src/test/java/vip/mate/agent/delegation/SubagentHeartbeatTest.java
new file mode 100644
index 00000000..2a7a7c72
--- /dev/null
+++ b/mateclaw-server/src/test/java/vip/mate/agent/delegation/SubagentHeartbeatTest.java
@@ -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 captor = ArgumentCaptor.forClass(Object.class);
+ verify(streamTracker).broadcastObject(eq("parent-5"), eq("subagent_stale"), captor.capture());
+ @SuppressWarnings("unchecked")
+ Map payload = (Map) captor.getValue();
+ assertThat(payload).containsKeys("subagentId", "cycles", "lastTool", "elapsedMs");
+ assertThat(payload.get("subagentId")).isEqualTo(id);
+ }
+}
diff --git a/mateclaw-server/src/test/java/vip/mate/agent/delegation/SubagentRegistryTest.java b/mateclaw-server/src/test/java/vip/mate/agent/delegation/SubagentRegistryTest.java
new file mode 100644
index 00000000..80c3b75a
--- /dev/null
+++ b/mateclaw-server/src/test/java/vip/mate/agent/delegation/SubagentRegistryTest.java
@@ -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--<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 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);
+ }
+}
diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/ContentRepetitionGuardTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/ContentRepetitionGuardTest.java
new file mode 100644
index 00000000..95eb4432
--- /dev/null
+++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/ContentRepetitionGuardTest.java
@@ -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.
+ *
+ * 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.
+ *
+ *
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));
+ }
+}
diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/ErrorClassificationTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/ErrorClassificationTest.java
index 5227d185..66c2e2f7 100644
--- a/mateclaw-server/src/test/java/vip/mate/agent/graph/ErrorClassificationTest.java
+++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/ErrorClassificationTest.java
@@ -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)")));
+ }
}
diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/LaneDPerformanceFixesTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/LaneDPerformanceFixesTest.java
new file mode 100644
index 00000000..5b749dc4
--- /dev/null
+++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/LaneDPerformanceFixesTest.java
@@ -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).
+ *
+ *
+ * D-1: Backoff sleep responds to Stop signal within 100ms
+ * D-2: RATE_LIMIT/SERVER_ERROR retries capped at 2 (was 5)
+ *
+ */
+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());
+ }
+ }
+}
diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/NodeStreamingChatHelperThinkingCapTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/NodeStreamingChatHelperThinkingCapTest.java
new file mode 100644
index 00000000..7c88026e
--- /dev/null
+++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/NodeStreamingChatHelperThinkingCapTest.java
@@ -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.
+ *
+ * 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");
+ }
+}
diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/NodeStreamingChatHelperToolCallArgsTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/NodeStreamingChatHelperToolCallArgsTest.java
new file mode 100644
index 00000000..acedb909
--- /dev/null
+++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/NodeStreamingChatHelperToolCallArgsTest.java
@@ -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.
+ *
+ *
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");
+ }
+}
diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/RepetitionDetectorTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/RepetitionDetectorTest.java
deleted file mode 100644
index 7702b74a..00000000
--- a/mateclaw-server/src/test/java/vip/mate/agent/graph/RepetitionDetectorTest.java
+++ /dev/null
@@ -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");
- }
-}
diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/ReturnDirectEndToEndTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/ReturnDirectEndToEndTest.java
new file mode 100644
index 00000000..e3e1dba2
--- /dev/null
+++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/ReturnDirectEndToEndTest.java
@@ -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:
+ *
+ *
+ * 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
+ *
+ *
+ * This complements the per-component unit tests by verifying the
+ * composition works: invariants flow correctly between nodes via
+ * {@link OverAllState}, no integration glue is missing, no state key is
+ * misnamed across boundaries.
+ *
+ *
What this does NOT test (still requires manual / SpringBootTest):
+ *
+ * {@code StateGraphReActAgent} stream emission of {@code FINAL_ANSWER}
+ * as {@code content_delta}
+ * {@code StreamAccumulator} capturing {@code tool_direct_result} into
+ * {@code metadata.directToolNames} (covered by the manual demo)
+ * {@code BaseAgent.toSpringMessage} scrubbing on the next user turn
+ * (covered by {@code BaseAgentDirectToolHistoryScrubTest})
+ *
+ */
+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 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 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 outputs = (List) 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 messages = (List) 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 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 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 calls = List.of(
+ new AssistantMessage.ToolCall("c1", "function", "read_medical_record", "{}"),
+ new AssistantMessage.ToolCall("c2", "function", "get_weather", "{}"));
+ Map initial = new HashMap<>();
+ initial.put(TOOL_CALLS, calls);
+ initial.put(CONVERSATION_ID, "conv_mixed");
+ initial.put(AGENT_ID, "agent_mixed");
+
+ Map actionOut = actionNode.apply(new OverAllState(initial));
+ assertEquals(Boolean.TRUE, actionOut.get(RETURN_DIRECT_TRIGGERED));
+
+ Map 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 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 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 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 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 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);
+ }
+ };
+ }
+}
diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/StripThinkingBoundaryTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/StripThinkingBoundaryTest.java
new file mode 100644
index 00000000..488e3eb9
--- /dev/null
+++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/StripThinkingBoundaryTest.java
@@ -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}.
+ *
+ * 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 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 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 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 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 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 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");
+ }
+}
diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/edge/ObservationDispatcherTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/edge/ObservationDispatcherTest.java
index de0e6362..d0d7727b 100644
--- a/mateclaw-server/src/test/java/vip/mate/agent/graph/edge/ObservationDispatcherTest.java
+++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/edge/ObservationDispatcherTest.java
@@ -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));
+ }
}
diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/executor/LaneDExecutorAndConfigTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/executor/LaneDExecutorAndConfigTest.java
new file mode 100644
index 00000000..e1a13147
--- /dev/null
+++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/executor/LaneDExecutorAndConfigTest.java
@@ -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:
+ *
+ *
+ * D-4: ToolExecutionExecutor uses virtual thread executor
+ * D-5: ToolResultProperties defaults updated to 16000/32000
+ *
+ */
+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 responses = List.of(
+ new ToolResponseMessage.ToolResponse("call-1", "read_file", largeRead),
+ new ToolResponseMessage.ToolResponse("call-2", "read_file", largeRead + "tail")
+ );
+
+ List 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()));
+ }
+ }
+}
diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/executor/ToolExecutionExecutorCapToolCallsTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/executor/ToolExecutionExecutorCapToolCallsTest.java
new file mode 100644
index 00000000..ad82a44c
--- /dev/null
+++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/executor/ToolExecutionExecutorCapToolCallsTest.java
@@ -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).
+ *
+ * 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 sequentialCalls(int n) {
+ List 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 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 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 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 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 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 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 input = sequentialCalls(10);
+ ToolExecutionExecutor.CappedToolCalls capped =
+ ToolExecutionExecutor.capToolCalls(input, 3);
+
+ assertTrue(capped.wasTruncated());
+ assertEquals(3, capped.effective().size());
+ assertEquals(7, capped.truncatedResponses().size());
+ }
+}
diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/executor/ToolExecutionExecutorNameNormalizationTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/executor/ToolExecutionExecutorNameNormalizationTest.java
new file mode 100644
index 00000000..0bdc16e5
--- /dev/null
+++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/executor/ToolExecutionExecutorNameNormalizationTest.java
@@ -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());
+ }
+}
diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/executor/ToolExecutionExecutorReturnDirectTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/executor/ToolExecutionExecutorReturnDirectTest.java
new file mode 100644
index 00000000..18f45ed1
--- /dev/null
+++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/executor/ToolExecutionExecutorReturnDirectTest.java
@@ -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}.
+ *
+ * The contract under test:
+ *
+ * A {@code returnDirect=true} tool's full result is captured in
+ * {@link ToolExecutionExecutor.ToolExecutionResult#directOutputs()}.
+ * The corresponding {@link ToolResponseMessage.ToolResponse} carries the
+ * fixed placeholder, not the sensitive content.
+ * An {@code EVENT_TOOL_DIRECT_RESULT} event is emitted with the full text
+ * and {@code renderAs=assistant_message}.
+ * Non-direct tools in the same batch keep their existing behavior.
+ *
+ */
+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 events = new java.util.ArrayList<>();
+ java.util.List 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 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 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 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);
+ }
+ };
+ }
+}
diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/executor/ToolExecutionExecutorSkillAutoRedirectTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/executor/ToolExecutionExecutorSkillAutoRedirectTest.java
new file mode 100644
index 00000000..51d07bab
--- /dev/null
+++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/executor/ToolExecutionExecutorSkillAutoRedirectTest.java
@@ -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.
+ *
+ * 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.
+ *
+ *
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 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 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);
+ }
+}
diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/executor/ToolExecutionExecutorSkillHintTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/executor/ToolExecutionExecutorSkillHintTest.java
new file mode 100644
index 00000000..0facac70
--- /dev/null
+++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/executor/ToolExecutionExecutorSkillHintTest.java
@@ -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 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 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());
+ }
+}
diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/node/FinalAnswerNodeTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/node/FinalAnswerNodeTest.java
index aa0e0cf4..83a25979 100644
--- a/mateclaw-server/src/test/java/vip/mate/agent/graph/node/FinalAnswerNodeTest.java
+++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/node/FinalAnswerNodeTest.java
@@ -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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 result = node.apply(state);
+
+ assertNull(pickFeedbackEvent(result));
+ }
}
diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/node/ReasoningNodeOutputTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/node/ReasoningNodeOutputTest.java
index e5961f58..0ef58218 100644
--- a/mateclaw-server/src/test/java/vip/mate/agent/graph/node/ReasoningNodeOutputTest.java
+++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/node/ReasoningNodeOutputTest.java
@@ -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 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 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 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
diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/state/SourceEvidenceLedgerTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/state/SourceEvidenceLedgerTest.java
new file mode 100644
index 00000000..fa154e9c
--- /dev/null
+++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/state/SourceEvidenceLedgerTest.java
@@ -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"));
+ }
+}
diff --git a/mateclaw-server/src/test/java/vip/mate/agent/service/TemplateServiceBindingTest.java b/mateclaw-server/src/test/java/vip/mate/agent/service/TemplateServiceBindingTest.java
new file mode 100644
index 00000000..a806c45c
--- /dev/null
+++ b/mateclaw-server/src/test/java/vip/mate/agent/service/TemplateServiceBindingTest.java
@@ -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}.
+ *
+ * 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)));
+ }
+}
diff --git a/mateclaw-server/src/test/java/vip/mate/approval/ApprovalReplayContinuityTest.java b/mateclaw-server/src/test/java/vip/mate/approval/ApprovalReplayContinuityTest.java
new file mode 100644
index 00000000..19d99525
--- /dev/null
+++ b/mateclaw-server/src/test/java/vip/mate/approval/ApprovalReplayContinuityTest.java
@@ -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.
+ *
+ *
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());
+ }
+}
diff --git a/mateclaw-server/src/test/java/vip/mate/approval/ApprovalWorkflowServiceGcTest.java b/mateclaw-server/src/test/java/vip/mate/approval/ApprovalWorkflowServiceGcTest.java
new file mode 100644
index 00000000..d9cf8090
--- /dev/null
+++ b/mateclaw-server/src/test/java/vip/mate/approval/ApprovalWorkflowServiceGcTest.java
@@ -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).
+ *
+ * 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:
+ *
+ * Phase A (TTL): expired pending → DB TIMEOUT + metadata DENIED + map removal
+ * Phase B (overflow): pending count over MAX → oldest evicted via the same
+ * full-sync path
+ * Phase C (resolved cleanup): non-pending entries past RESOLVED_TTL drop
+ * from the map only — DB / metadata are not touched
+ * Idempotent on idle ticks: nothing to GC means zero DB / metadata interactions
+ *
+ */
+@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;
+ }
+}
diff --git a/mateclaw-server/src/test/java/vip/mate/approval/ApprovalWorkflowServiceRecoveryTest.java b/mateclaw-server/src/test/java/vip/mate/approval/ApprovalWorkflowServiceRecoveryTest.java
new file mode 100644
index 00000000..e2dc922e
--- /dev/null
+++ b/mateclaw-server/src/test/java/vip/mate/approval/ApprovalWorkflowServiceRecoveryTest.java
@@ -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).
+ *
+ * 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:
+ *
+ * Live row → pendingMap entry preserves the DB pendingId AND createdAt
+ * (so PENDING_TTL math still works after restart)
+ * Expired row (expireAt past) → DB → TIMEOUT, metadata reconciled DENIED,
+ * no pendingMap entry
+ * 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.
+ *
+ */
+@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 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 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;
+ }
+}
diff --git a/mateclaw-server/src/test/java/vip/mate/approval/ApprovalWorkflowServiceResolveTest.java b/mateclaw-server/src/test/java/vip/mate/approval/ApprovalWorkflowServiceResolveTest.java
new file mode 100644
index 00000000..b19a6469
--- /dev/null
+++ b/mateclaw-server/src/test/java/vip/mate/approval/ApprovalWorkflowServiceResolveTest.java
@@ -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).
+ *
+ * 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:
+ *
+ * snapshot (no map mutation)
+ * DB UPDATE conditional on {@code status='PENDING'} (idempotent against concurrent resolve)
+ * metadata reconciliation (same tx)
+ * memory mutation only on commit (afterCommit hook; immediate when no tx active)
+ *
+ *
+ * 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 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 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 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 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;
+ }
+}
diff --git a/mateclaw-server/src/test/java/vip/mate/architecture/StateKeyRegistrationCoverageTest.java b/mateclaw-server/src/test/java/vip/mate/architecture/StateKeyRegistrationCoverageTest.java
new file mode 100644
index 00000000..5e818618
--- /dev/null
+++ b/mateclaw-server/src/test/java/vip/mate/architecture/StateKeyRegistrationCoverageTest.java
@@ -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).
+ *
+ * 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.
+ *
+ *
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 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 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 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).");
+ }
+ }
+}
diff --git a/mateclaw-server/src/test/java/vip/mate/architecture/ToolCallbackToolContextForwardArchTest.java b/mateclaw-server/src/test/java/vip/mate/architecture/ToolCallbackToolContextForwardArchTest.java
new file mode 100644
index 00000000..8794f0f9
--- /dev/null
+++ b/mateclaw-server/src/test/java/vip/mate/architecture/ToolCallbackToolContextForwardArchTest.java
@@ -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}).
+ *
+ * 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.
+ *
+ *
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 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 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 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)."));
+ }
+ }
+ };
+ }
+}
diff --git a/mateclaw-server/src/test/java/vip/mate/auth/pat/PersonalAccessTokenServiceTest.java b/mateclaw-server/src/test/java/vip/mate/auth/pat/PersonalAccessTokenServiceTest.java
new file mode 100644
index 00000000..8101385f
--- /dev/null
+++ b/mateclaw-server/src/test/java/vip/mate/auth/pat/PersonalAccessTokenServiceTest.java
@@ -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:
+ *
+ *
+ * Plaintext format ({@code mc_*}) and uniqueness across mints.
+ * 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.
+ * {@link PersonalAccessTokenService#findActiveByPlaintext} rejects
+ * null, blank, wrong-prefix, hash-miss, disabled, and expired
+ * tokens with no observable difference (don't leak which one).
+ * {@link PersonalAccessTokenService#recordUse} debounces writes so
+ * a CI loop doesn't hammer the row.
+ * {@link PersonalAccessTokenService#revoke} requires owner match —
+ * a token id alone is insufficient to revoke someone else's token.
+ *
+ */
+@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 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 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");
+ }
+}
diff --git a/mateclaw-server/src/test/java/vip/mate/channel/ChannelErrorClassifierTest.java b/mateclaw-server/src/test/java/vip/mate/channel/ChannelErrorClassifierTest.java
new file mode 100644
index 00000000..3de96592
--- /dev/null
+++ b/mateclaw-server/src/test/java/vip/mate/channel/ChannelErrorClassifierTest.java
@@ -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));
+ }
+}
diff --git a/mateclaw-server/src/test/java/vip/mate/channel/ChannelManagerReconcileTest.java b/mateclaw-server/src/test/java/vip/mate/channel/ChannelManagerReconcileTest.java
new file mode 100644
index 00000000..3ede4388
--- /dev/null
+++ b/mateclaw-server/src/test/java/vip/mate/channel/ChannelManagerReconcileTest.java
@@ -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.
+ *
+ * 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> followerRetries =
+ (Map>) 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> followerRetries =
+ (Map>) 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 leases =
+ (Map) 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 leases =
+ (Map) 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 pluginChannels =
+ (Map) ReflectionTestUtils.getField(manager, "pluginChannels");
+ Map pluginLeases =
+ (Map) ReflectionTestUtils.getField(manager, "pluginLeases");
+ Map> pluginHeartbeats =
+ (Map>) 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 active =
+ (Map) ReflectionTestUtils.getField(manager, "activeAdapters");
+ @SuppressWarnings("unchecked")
+ Map leases =
+ (Map) 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 active =
+ (Map) ReflectionTestUtils.getField(manager, "activeAdapters");
+ active.put(id, adapter);
+ lastSeenMap().put(id, updateTime);
+ }
+
+ @SuppressWarnings("unchecked")
+ private Map lastSeenMap() {
+ return (Map) 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 parts) {}
+ @Override public String getChannelType() { return "feishu"; }
+ @Override public boolean requiresSingleLeader() { return true; }
+ }
+}
diff --git a/mateclaw-server/src/test/java/vip/mate/channel/ChannelMessageRouterDebounceTest.java b/mateclaw-server/src/test/java/vip/mate/channel/ChannelMessageRouterDebounceTest.java
new file mode 100644
index 00000000..6e9528ca
--- /dev/null
+++ b/mateclaw-server/src/test/java/vip/mate/channel/ChannelMessageRouterDebounceTest.java
@@ -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)}.
+ *
+ * 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");
+ }
+}
diff --git a/mateclaw-server/src/test/java/vip/mate/channel/ChannelMessageRouterGroupAttributionTest.java b/mateclaw-server/src/test/java/vip/mate/channel/ChannelMessageRouterGroupAttributionTest.java
new file mode 100644
index 00000000..feb047ee
--- /dev/null
+++ b/mateclaw-server/src/test/java/vip/mate/channel/ChannelMessageRouterGroupAttributionTest.java
@@ -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.
+ *
+ *
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));
+ }
+}
diff --git a/mateclaw-server/src/test/java/vip/mate/channel/MediaPathGuardTest.java b/mateclaw-server/src/test/java/vip/mate/channel/MediaPathGuardTest.java
new file mode 100644
index 00000000..e8e63893
--- /dev/null
+++ b/mateclaw-server/src/test/java/vip/mate/channel/MediaPathGuardTest.java
@@ -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.
+ *
+ *
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");
+ }
+}
diff --git a/mateclaw-server/src/test/java/vip/mate/channel/feishu/FeishuTextSplitTest.java b/mateclaw-server/src/test/java/vip/mate/channel/feishu/FeishuTextSplitTest.java
new file mode 100644
index 00000000..236fd870
--- /dev/null
+++ b/mateclaw-server/src/test/java/vip/mate/channel/feishu/FeishuTextSplitTest.java
@@ -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 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 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 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 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 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 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");
+ }
+}
diff --git a/mateclaw-server/src/test/java/vip/mate/channel/leader/ChannelLeaderElectionTest.java b/mateclaw-server/src/test/java/vip/mate/channel/leader/ChannelLeaderElectionTest.java
new file mode 100644
index 00000000..55c00e99
--- /dev/null
+++ b/mateclaw-server/src/test/java/vip/mate/channel/leader/ChannelLeaderElectionTest.java
@@ -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 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 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 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));
+ }
+}
diff --git a/mateclaw-server/src/test/java/vip/mate/channel/leader/SingleLeaderHookTest.java b/mateclaw-server/src/test/java/vip/mate/channel/leader/SingleLeaderHookTest.java
new file mode 100644
index 00000000..95a63def
--- /dev/null
+++ b/mateclaw-server/src/test/java/vip/mate/channel/leader/SingleLeaderHookTest.java
@@ -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");
+ }
+}
diff --git a/mateclaw-server/src/test/java/vip/mate/channel/verifier/ChannelVerifierTest.java b/mateclaw-server/src/test/java/vip/mate/channel/verifier/ChannelVerifierTest.java
new file mode 100644
index 00000000..3117d815
--- /dev/null
+++ b/mateclaw-server/src/test/java/vip/mate/channel/verifier/ChannelVerifierTest.java
@@ -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());
+ }
+}
diff --git a/mateclaw-server/src/test/java/vip/mate/channel/web/ChatControllerPersistStatusTest.java b/mateclaw-server/src/test/java/vip/mate/channel/web/ChatControllerPersistStatusTest.java
new file mode 100644
index 00000000..1249680f
--- /dev/null
+++ b/mateclaw-server/src/test/java/vip/mate/channel/web/ChatControllerPersistStatusTest.java
@@ -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).
+ *
+ * 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();
+ }
+}
diff --git a/mateclaw-server/src/test/java/vip/mate/channel/web/ChatStreamTrackerBatchedRelayTest.java b/mateclaw-server/src/test/java/vip/mate/channel/web/ChatStreamTrackerBatchedRelayTest.java
new file mode 100644
index 00000000..54e1e168
--- /dev/null
+++ b/mateclaw-server/src/test/java/vip/mate/channel/web/ChatStreamTrackerBatchedRelayTest.java
@@ -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 = 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 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 = 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 = 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");
+ }
+}
diff --git a/mateclaw-server/src/test/java/vip/mate/channel/web/ChatStreamTrackerChunkedBroadcastTest.java b/mateclaw-server/src/test/java/vip/mate/channel/web/ChatStreamTrackerChunkedBroadcastTest.java
new file mode 100644
index 00000000..152bb6c9
--- /dev/null
+++ b/mateclaw-server/src/test/java/vip/mate/channel/web/ChatStreamTrackerChunkedBroadcastTest.java
@@ -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> events = new CopyOnWriteArrayList<>();
+
+ CapturingEmitter() {
+ super(60_000L);
+ }
+
+ @Override
+ public void send(SseEventBuilder builder) throws IOException {
+ Set entries = builder.build();
+ // Spring renders the SSE event as:
+ // 1) header string: "event:\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 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 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 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> completed = new ArrayList<>();
+ List> chunks = new ArrayList<>();
+ for (Map 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 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");
+ }
+}
diff --git a/mateclaw-server/src/test/java/vip/mate/channel/web/Utf8SseEmitterTest.java b/mateclaw-server/src/test/java/vip/mate/channel/web/Utf8SseEmitterTest.java
new file mode 100644
index 00000000..cbfac003
--- /dev/null
+++ b/mateclaw-server/src/test/java/vip/mate/channel/web/Utf8SseEmitterTest.java
@@ -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.
+ *
+ * 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);
+ }
+}
diff --git a/mateclaw-server/src/test/java/vip/mate/channel/wecom/AppmsgContentTest.java b/mateclaw-server/src/test/java/vip/mate/channel/wecom/AppmsgContentTest.java
new file mode 100644
index 00000000..346ea21a
--- /dev/null
+++ b/mateclaw-server/src/test/java/vip/mate/channel/wecom/AppmsgContentTest.java
@@ -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.
+ *
+ *
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 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 parts(Object ctx) throws Exception {
+ return (List) 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));
+ }
+}
diff --git a/mateclaw-server/src/test/java/vip/mate/channel/wecom/GroupReplyReqIdCacheTest.java b/mateclaw-server/src/test/java/vip/mate/channel/wecom/GroupReplyReqIdCacheTest.java
new file mode 100644
index 00000000..2c52dcd3
--- /dev/null
+++ b/mateclaw-server/src/test/java/vip/mate/channel/wecom/GroupReplyReqIdCacheTest.java
@@ -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.
+ *
+ * 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 map = (ConcurrentHashMap) 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"));
+ }
+}
diff --git a/mateclaw-server/src/test/java/vip/mate/channel/wecom/QuoteContextTest.java b/mateclaw-server/src/test/java/vip/mate/channel/wecom/QuoteContextTest.java
new file mode 100644
index 00000000..f48c2c80
--- /dev/null
+++ b/mateclaw-server/src/test/java/vip/mate/channel/wecom/QuoteContextTest.java
@@ -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.
+ *
+ * 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.
+ *
+ *
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 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 parts = (List)
+ 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 parts = (List)
+ 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 parts = (List)
+ 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 parts = (List)
+ 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 [] 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);
+ }
+}
diff --git a/mateclaw-server/src/test/java/vip/mate/channel/wecom/ReplyQueueStressTest.java b/mateclaw-server/src/test/java/vip/mate/channel/wecom/ReplyQueueStressTest.java
new file mode 100644
index 00000000..d69603fe
--- /dev/null
+++ b/mateclaw-server/src/test/java/vip/mate/channel/wecom/ReplyQueueStressTest.java
@@ -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.
+ *
+ * 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}).
+ *
+ *
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> f1 = adapter.callSendFrameWithAck(reqId, frame(reqId, "msg1"));
+ CompletableFuture> f2 = adapter.callSendFrameWithAck(reqId, frame(reqId, "msg2"));
+ CompletableFuture> 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> 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>> 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> 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>> 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> 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> 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 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 f = a.sentFrames.poll(timeoutMs, TimeUnit.MILLISECONDS);
+ assertNotNull(f, "no frame dispatched within " + timeoutMs + "ms");
+ @SuppressWarnings("unchecked")
+ Map body = (Map) f.get("body");
+ @SuppressWarnings("unchecked")
+ Map txt = (Map) 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 map =
+ (ConcurrentHashMap) 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> 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, 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 frame) {
+ sentFrames.offer(frame);
+ Function, 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> callSendFrameWithAck(String reqId, Map