mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-21 06:05:56 +08:00
chore(test): backfill mateclaw-server/src/test/ that earlier PRIVATE_ITEMS pattern accidentally excluded
The PRIVATE_ITEMS list contained the bare 'test' entry, which rsync interprets as 'any directory named test at any depth' — so it caught the root-level /test/ scratch directory (intended) AND every src/test/ under each module (not intended). Pattern is already anchored to /test (root-only). This commit rsyncs the accumulated src/test/ tree forward so opensource has the unit tests that have been written / updated against existing src/main/ code since the pattern regression. Going forward each per-commit sync will carry src/test/ files along with the main change.
This commit is contained in:
parent
ed788e9e42
commit
0ffc224623
@ -645,28 +645,5 @@
|
||||
</plugins>
|
||||
</build>
|
||||
</profile>
|
||||
<!--
|
||||
Profile: skip test files that don't compile against the current main
|
||||
sources (pre-existing baseline drift unrelated to a given branch).
|
||||
Activate with `mvn test -P skip-baseline-drift` when you need to run
|
||||
a focused suite without first hand-fixing every stale test ctor.
|
||||
-->
|
||||
<profile>
|
||||
<id>skip-baseline-drift</id>
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<configuration>
|
||||
<testExcludes>
|
||||
<testExclude>vip/mate/llm/oauth/OpenAIOAuthServiceFlowModeTest.java</testExclude>
|
||||
<testExclude>vip/mate/wiki/service/WikiEmbeddingCircuitBreakerTest.java</testExclude>
|
||||
</testExcludes>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</profile>
|
||||
</profiles>
|
||||
</project>
|
||||
|
||||
@ -0,0 +1,89 @@
|
||||
package vip.mate.acp.client;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.condition.DisabledOnOs;
|
||||
import org.junit.jupiter.api.condition.OS;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.attribute.PosixFilePermission;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* RFC-090 Phase 7 — connection-test smoke for {@link AcpStdioClient}.
|
||||
*
|
||||
* <p>Runs a tiny shell-script "agent" that mimics the {@code initialize}
|
||||
* handshake: reads one JSON-RPC request, replies with a matching id and
|
||||
* the expected protocol version. Locks in:
|
||||
* <ul>
|
||||
* <li>spawn → request → response → close all happen cleanly,</li>
|
||||
* <li>protocolVersion is parsed from the result,</li>
|
||||
* <li>the reader thread doesn't leak past close.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>POSIX-only: relies on {@code sh} + executable bit. Windows agents
|
||||
* are exercised via the real CLI integration smoke (manual). The
|
||||
* client itself is OS-neutral; the script harness is what's POSIXy.
|
||||
*/
|
||||
@DisabledOnOs(OS.WINDOWS)
|
||||
class AcpStdioClientTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("initialize handshake completes against a scripted agent")
|
||||
void initializeHandshake() throws Exception {
|
||||
Path script = writeScriptedAgent();
|
||||
try (AcpStdioClient client = AcpStdioClient.spawn(
|
||||
new ObjectMapper(), "sh", List.of(script.toString()),
|
||||
AcpStdioClient.emptyEnv(), null)) {
|
||||
JsonNode result = client.initialize(5_000);
|
||||
assertNotNull(result);
|
||||
assertEquals(AcpStdioClient.PROTOCOL_VERSION,
|
||||
result.path("protocolVersion").asInt());
|
||||
} finally {
|
||||
Files.deleteIfExists(script);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("spawn fails fast for a missing command")
|
||||
void spawnFailsFastForMissingCommand() {
|
||||
assertThrows(IOException.class, () ->
|
||||
AcpStdioClient.spawn(new ObjectMapper(),
|
||||
"/definitely/does/not/exist/acp-test-bin",
|
||||
List.of(), AcpStdioClient.emptyEnv(), null));
|
||||
}
|
||||
|
||||
/**
|
||||
* Tiny shell-script agent: read one JSON-RPC line on stdin and
|
||||
* write a response with a hard-coded result. Just enough surface
|
||||
* to exercise the framing path.
|
||||
*/
|
||||
private Path writeScriptedAgent() throws IOException {
|
||||
Path script = Files.createTempFile("acp-fake-agent-", ".sh");
|
||||
String body = "" +
|
||||
"#!/bin/sh\n" +
|
||||
"read line\n" +
|
||||
// Pull the id; assume integer id at this position.
|
||||
"id=$(printf '%s' \"$line\" | sed -n 's/.*\"id\":\\([0-9]\\+\\).*/\\1/p')\n" +
|
||||
"if [ -z \"$id\" ]; then id=1; fi\n" +
|
||||
"printf '{\"jsonrpc\":\"2.0\",\"id\":%s,\"result\":{\"protocolVersion\":1,\"agentCapabilities\":{}}}\\n' \"$id\"\n";
|
||||
Files.writeString(script, body, StandardCharsets.UTF_8);
|
||||
try {
|
||||
Files.setPosixFilePermissions(script, Set.of(
|
||||
PosixFilePermission.OWNER_READ,
|
||||
PosixFilePermission.OWNER_WRITE,
|
||||
PosixFilePermission.OWNER_EXECUTE));
|
||||
} catch (UnsupportedOperationException ignore) {
|
||||
// Filesystem doesn't support POSIX perms — sh ... still works.
|
||||
}
|
||||
return script;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,145 @@
|
||||
package vip.mate.agent;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.test.context.TestPropertySource;
|
||||
import vip.mate.MateClawApplication;
|
||||
import vip.mate.agent.model.AgentEntity;
|
||||
import vip.mate.exception.MateClawException;
|
||||
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
/**
|
||||
* Pre-flight {@code (workspace_id, name)} uniqueness check that
|
||||
* accompanies the V102 unique index.
|
||||
*
|
||||
* <p>The DB index alone would surface as {@code DataIntegrityViolation}
|
||||
* with a vendor-specific message; the service-layer pre-check converts
|
||||
* that to a stable {@code err.agent.duplicate_name} business code so the
|
||||
* UI can show a localized message and clients can branch deterministically.
|
||||
* These tests pin both the rejection paths and the false-positive guard
|
||||
* (a same-name UPDATE on the row itself must not block).
|
||||
*/
|
||||
@SpringBootTest(
|
||||
classes = MateClawApplication.class,
|
||||
webEnvironment = SpringBootTest.WebEnvironment.NONE
|
||||
)
|
||||
@TestPropertySource(properties = {
|
||||
"spring.datasource.url=jdbc:h2:mem:agent_unique_test_${random.uuid};MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE;DB_CLOSE_DELAY=-1",
|
||||
"spring.ai.dashscope.api-key=test-key",
|
||||
"spring.main.web-application-type=none"
|
||||
})
|
||||
class AgentServiceUniquenessTest {
|
||||
|
||||
/**
|
||||
* Tests share one in-memory DB instance (a single
|
||||
* {@code @SpringBootTest} class shares its application context across
|
||||
* methods). Workspace ids are derived from this counter so concurrent
|
||||
* methods can't trample one another's rows.
|
||||
*/
|
||||
private static final AtomicLong WS_SEQ = new AtomicLong(50_000L);
|
||||
|
||||
@Autowired
|
||||
private AgentService agentService;
|
||||
|
||||
private long workspaceA;
|
||||
private long workspaceB;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
workspaceA = WS_SEQ.getAndIncrement();
|
||||
workspaceB = WS_SEQ.getAndIncrement();
|
||||
}
|
||||
|
||||
private AgentEntity newAgent(String name, long workspaceId) {
|
||||
AgentEntity a = new AgentEntity();
|
||||
a.setName(name);
|
||||
a.setDescription("uniqueness test agent");
|
||||
a.setAgentType("react");
|
||||
a.setSystemPrompt("");
|
||||
a.setMaxIterations(10);
|
||||
a.setWorkspaceId(workspaceId);
|
||||
return a;
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("createAgent 拒绝同 workspace 同名(body code = 409 / msgKey = err.agent.duplicate_name)")
|
||||
void createRejectsDuplicateNameInSameWorkspace() {
|
||||
agentService.createAgent(newAgent("Alpha", workspaceA));
|
||||
|
||||
MateClawException ex = assertThrows(MateClawException.class,
|
||||
() -> agentService.createAgent(newAgent("Alpha", workspaceA)));
|
||||
assertEquals(409, ex.getCode(), "应返回 409 业务码");
|
||||
assertEquals("err.agent.duplicate_name", ex.getMsgKey());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("createAgent 允许不同 workspace 同名(隔离边界生效)")
|
||||
void createAllowsSameNameInDifferentWorkspace() {
|
||||
AgentEntity a = agentService.createAgent(newAgent("Bravo", workspaceA));
|
||||
AgentEntity b = agentService.createAgent(newAgent("Bravo", workspaceB));
|
||||
|
||||
assertNotNull(a.getId());
|
||||
assertNotNull(b.getId());
|
||||
assertNotEquals(a.getId(), b.getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("createAgent 拒绝空名(fail-fast 在 unique 检查之前)")
|
||||
void createRejectsBlankName() {
|
||||
AgentEntity blank = newAgent(null, workspaceA);
|
||||
MateClawException ex = assertThrows(MateClawException.class,
|
||||
() -> agentService.createAgent(blank));
|
||||
assertEquals(400, ex.getCode());
|
||||
assertEquals("err.agent.name_required", ex.getMsgKey());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("updateAgent 拒绝把名字改成 workspace 内已有的别人")
|
||||
void updateRejectsRenamingToExistingName() {
|
||||
AgentEntity first = agentService.createAgent(newAgent("Charlie", workspaceA));
|
||||
AgentEntity second = agentService.createAgent(newAgent("Delta", workspaceA));
|
||||
|
||||
// Try renaming "Delta" → "Charlie" inside the same workspace.
|
||||
second.setName("Charlie");
|
||||
MateClawException ex = assertThrows(MateClawException.class,
|
||||
() -> agentService.updateAgent(second));
|
||||
assertEquals(409, ex.getCode());
|
||||
assertEquals("err.agent.duplicate_name", ex.getMsgKey());
|
||||
|
||||
// The other row must not have been touched.
|
||||
assertEquals("Charlie", agentService.getAgent(first.getId()).getName());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("updateAgent 元数据修改不触发误报(excludeId 跳过自己)")
|
||||
void updateAllowsMetadataEditWithoutFalsePositive() {
|
||||
AgentEntity created = agentService.createAgent(newAgent("Echo", workspaceA));
|
||||
|
||||
// Edit description only, keep the same name. The unique check
|
||||
// should detect "no name change" and skip the SELECT entirely;
|
||||
// even if it didn't, the excludeId branch would filter self out.
|
||||
created.setDescription("edited");
|
||||
assertDoesNotThrow(() -> agentService.updateAgent(created));
|
||||
assertEquals("edited", agentService.getAgent(created.getId()).getDescription());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("updateAgent 改名为新值(不冲突)允许")
|
||||
void updateAllowsRenameToUnusedName() {
|
||||
AgentEntity created = agentService.createAgent(newAgent("Foxtrot", workspaceA));
|
||||
created.setName("Foxtrot-renamed");
|
||||
assertDoesNotThrow(() -> agentService.updateAgent(created));
|
||||
assertEquals("Foxtrot-renamed",
|
||||
agentService.getAgent(created.getId()).getName());
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,123 @@
|
||||
package vip.mate.agent;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.ai.support.ToolCallbacks;
|
||||
import org.springframework.ai.tool.ToolCallback;
|
||||
import org.springframework.ai.tool.annotation.Tool;
|
||||
import org.springframework.ai.tool.annotation.ToolParam;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
/**
|
||||
* Regression test for <a href="https://github.com/matevip/mateclaw/issues/24">issue #24</a>.
|
||||
* <p>
|
||||
* Symptom: agent tool bindings persisted under the Java class name
|
||||
* (e.g. {@code BrowserUseTool}, which is what {@code mate_tool.name} stores) or the
|
||||
* Spring bean name (e.g. {@code browserUseTool}) had no effect at runtime, because the
|
||||
* graph runtime matches against the {@code @Tool} function name (e.g. {@code browser_use}).
|
||||
* <p>
|
||||
* Fix: {@link AgentToolSet} builds an alias index so any of these three identifiers
|
||||
* resolves to the same callback. This test pins that contract.
|
||||
*/
|
||||
class AgentToolSetTest {
|
||||
|
||||
/** Fixture: a bean exposing two {@code @Tool} methods, mirroring real tools like
|
||||
* {@code BrowserUseTool} ({@code browser_use}, {@code browser_screenshot}, ...). */
|
||||
static class FakeBrowserTool {
|
||||
@Tool(description = "Open a URL in the browser")
|
||||
public String browser_use(@ToolParam(description = "url to open") String url) {
|
||||
return "opened " + url;
|
||||
}
|
||||
|
||||
@Tool(description = "Take a screenshot")
|
||||
public String browser_screenshot() {
|
||||
return "shot.png";
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Issue #24: class name and bean name resolve to the same callbacks as the @Tool function name")
|
||||
void aliasIndex_resolvesClassNameAndBeanNameToFunctionCallbacks() {
|
||||
FakeBrowserTool bean = new FakeBrowserTool();
|
||||
List<ToolCallback> callbacks = List.of(ToolCallbacks.from(bean));
|
||||
assertEquals(2, callbacks.size(), "fixture should expose 2 @Tool methods");
|
||||
|
||||
AgentToolSet base = AgentToolSet.fromCallbacks(
|
||||
List.of(bean),
|
||||
callbacks,
|
||||
b -> "fakeBrowserTool" // simulate Spring bean-name lookup
|
||||
);
|
||||
assertEquals(2, base.size());
|
||||
|
||||
// (A) Function name — the historically-correct form
|
||||
AgentToolSet byFn = base.withAllowedToolsOnly(Set.of("browser_use"));
|
||||
assertEquals(1, byFn.size());
|
||||
assertEquals("browser_use", byFn.callbacks().get(0).getToolDefinition().name());
|
||||
|
||||
// (B) Spring bean name → expands to ALL @Tool methods on that bean
|
||||
AgentToolSet byBean = base.withAllowedToolsOnly(Set.of("fakeBrowserTool"));
|
||||
assertEquals(2, byBean.size(),
|
||||
"bean name should pull in every @Tool method on the class");
|
||||
|
||||
// (C) Java class simple name (this is what mate_tool.name actually stores —
|
||||
// e.g. 'BrowserUseTool' — and what the legacy bug saved into mate_agent_tool.tool_name)
|
||||
AgentToolSet byClass = base.withAllowedToolsOnly(Set.of("FakeBrowserTool"));
|
||||
assertEquals(2, byClass.size(),
|
||||
"class simple name should expand to all bean methods (this is the issue #24 fix)");
|
||||
|
||||
// (D) Mixed: known + unknown aliases. Unknowns are silently dropped — callers persist
|
||||
// stale data and we'd rather degrade gracefully than throw.
|
||||
AgentToolSet mixed = base.withAllowedToolsOnly(Set.of("FakeBrowserTool", "nonexistent_tool"));
|
||||
assertEquals(2, mixed.size());
|
||||
|
||||
// (E) Empty allow-list yields empty tool set (NOT global default — only null does that)
|
||||
AgentToolSet none = base.withAllowedToolsOnly(Set.of());
|
||||
assertEquals(0, none.size());
|
||||
|
||||
// (F) null = no per-agent binding → fall back to global default (every tool visible)
|
||||
AgentToolSet allDefault = base.withAllowedToolsOnly(null);
|
||||
assertEquals(2, allDefault.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("withDeniedToolsFiltered accepts function / bean / class names interchangeably")
|
||||
void deniedAliases_areToleranceOfNamingConvention() {
|
||||
FakeBrowserTool bean = new FakeBrowserTool();
|
||||
List<ToolCallback> callbacks = List.of(ToolCallbacks.from(bean));
|
||||
|
||||
AgentToolSet base = AgentToolSet.fromCallbacks(
|
||||
List.of(bean),
|
||||
callbacks,
|
||||
b -> "fakeBrowserTool"
|
||||
);
|
||||
|
||||
// Deny by class name: removes both @Tool methods on that class
|
||||
AgentToolSet none = base.withDeniedToolsFiltered(Set.of("FakeBrowserTool"));
|
||||
assertEquals(0, none.size());
|
||||
|
||||
// Deny by single function name: only that method drops
|
||||
AgentToolSet justOne = base.withDeniedToolsFiltered(Set.of("browser_screenshot"));
|
||||
assertEquals(1, justOne.size());
|
||||
assertEquals("browser_use", justOne.callbacks().get(0).getToolDefinition().name());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Two-arg fromCallbacks (no bean-name resolver): function name + class simple name still indexed (Spring bean name is not)")
|
||||
void twoArgFactory_indexesByFunctionNameAndClassName() {
|
||||
FakeBrowserTool bean = new FakeBrowserTool();
|
||||
List<ToolCallback> callbacks = List.of(ToolCallbacks.from(bean));
|
||||
|
||||
AgentToolSet noResolver = AgentToolSet.fromCallbacks(List.of(bean), callbacks);
|
||||
|
||||
// Function name resolves
|
||||
assertEquals(1, noResolver.withAllowedToolsOnly(Set.of("browser_use")).size());
|
||||
// Class simple name resolves too — derived from bean.getClass() reflection, no resolver needed
|
||||
assertEquals(2, noResolver.withAllowedToolsOnly(Set.of("FakeBrowserTool")).size());
|
||||
// Spring bean name does NOT resolve without a resolver (no source for it)
|
||||
assertEquals(0, noResolver.withAllowedToolsOnly(Set.of("fakeBrowserTool")).size());
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,139 @@
|
||||
package vip.mate.agent;
|
||||
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* RFC-049 PR-2: {@link AssistantThinkingRelay} — RelayEntry carries both
|
||||
* per-assistant thinking and the caller's original {@code user} field, so the
|
||||
* consumer can restore it when rebuilding the outbound request.
|
||||
*/
|
||||
class AssistantThinkingRelayTest {
|
||||
|
||||
@BeforeEach
|
||||
void clear() {
|
||||
AssistantThinkingRelay.clearAll();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
AssistantThinkingRelay.clearAll();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("stash returns token with expected prefix")
|
||||
void stash_returnsTokenWithPrefix() {
|
||||
String token = AssistantThinkingRelay.stash(List.of("thinking-a"), null);
|
||||
assertTrue(AssistantThinkingRelay.isToken(token));
|
||||
assertTrue(token.startsWith(AssistantThinkingRelay.TOKEN_PREFIX));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("stash + take roundtrips thinkings in order and originalUser")
|
||||
void stashTake_roundtrip() {
|
||||
List<String> thinkings = List.of("one", "", "three");
|
||||
String token = AssistantThinkingRelay.stash(thinkings, "caller-user-42");
|
||||
AssistantThinkingRelay.RelayEntry entry = AssistantThinkingRelay.take(token);
|
||||
|
||||
assertNotNull(entry);
|
||||
assertEquals(List.of("one", "", "three"), entry.thinkings());
|
||||
assertEquals("caller-user-42", entry.originalUser());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("stash + take with null originalUser preserves null")
|
||||
void stashTake_nullOriginalUser() {
|
||||
String token = AssistantThinkingRelay.stash(List.of("x"), null);
|
||||
AssistantThinkingRelay.RelayEntry entry = AssistantThinkingRelay.take(token);
|
||||
assertNotNull(entry);
|
||||
assertNull(entry.originalUser());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("take removes entry — subsequent take returns null")
|
||||
void take_removesEntry() {
|
||||
String token = AssistantThinkingRelay.stash(List.of("x"), "u");
|
||||
assertNotNull(AssistantThinkingRelay.take(token));
|
||||
assertNull(AssistantThinkingRelay.take(token));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("take on non-token user returns null")
|
||||
void take_onNonToken_returnsNull() {
|
||||
assertNull(AssistantThinkingRelay.take(null));
|
||||
assertNull(AssistantThinkingRelay.take(""));
|
||||
assertNull(AssistantThinkingRelay.take("some-real-user-id"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("isToken: prefix-based detection")
|
||||
void isToken_prefixDetection() {
|
||||
assertFalse(AssistantThinkingRelay.isToken(null));
|
||||
assertFalse(AssistantThinkingRelay.isToken(""));
|
||||
assertFalse(AssistantThinkingRelay.isToken("regular-user"));
|
||||
assertTrue(AssistantThinkingRelay.isToken(AssistantThinkingRelay.TOKEN_PREFIX + "anything"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("discard after take is a no-op (idempotent)")
|
||||
void discard_idempotent() {
|
||||
String token = AssistantThinkingRelay.stash(List.of("x"), "u");
|
||||
AssistantThinkingRelay.take(token);
|
||||
// Should not throw and not affect other entries
|
||||
AssistantThinkingRelay.discard(token);
|
||||
assertEquals(0, AssistantThinkingRelay.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("discard without take removes the entry (producer failure path)")
|
||||
void discard_withoutTake_removes() {
|
||||
String token = AssistantThinkingRelay.stash(List.of("x"), "u");
|
||||
assertEquals(1, AssistantThinkingRelay.size());
|
||||
AssistantThinkingRelay.discard(token);
|
||||
assertEquals(0, AssistantThinkingRelay.size());
|
||||
// Subsequent take still returns null
|
||||
assertNull(AssistantThinkingRelay.take(token));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("concurrent stashes produce distinct tokens")
|
||||
void stash_distinctTokens() {
|
||||
String a = AssistantThinkingRelay.stash(List.of("a"), "ua");
|
||||
String b = AssistantThinkingRelay.stash(List.of("b"), "ub");
|
||||
assertNotEquals(a, b);
|
||||
|
||||
AssistantThinkingRelay.RelayEntry ea = AssistantThinkingRelay.take(a);
|
||||
AssistantThinkingRelay.RelayEntry eb = AssistantThinkingRelay.take(b);
|
||||
assertEquals(List.of("a"), ea.thinkings());
|
||||
assertEquals("ua", ea.originalUser());
|
||||
assertEquals(List.of("b"), eb.thinkings());
|
||||
assertEquals("ub", eb.originalUser());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("RelayEntry.thinkings is immutable (defensive copy)")
|
||||
void relayEntry_thinkingsImmutable() {
|
||||
java.util.ArrayList<String> mutable = new java.util.ArrayList<>(List.of("a", "b"));
|
||||
String token = AssistantThinkingRelay.stash(mutable, "u");
|
||||
mutable.set(0, "mutated"); // should not affect the stashed copy
|
||||
|
||||
AssistantThinkingRelay.RelayEntry entry = AssistantThinkingRelay.take(token);
|
||||
assertEquals(List.of("a", "b"), entry.thinkings());
|
||||
|
||||
// thinkings returned is also unmodifiable
|
||||
assertThrows(UnsupportedOperationException.class,
|
||||
() -> entry.thinkings().set(0, "x"));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,76 @@
|
||||
package vip.mate.agent;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import vip.mate.workspace.conversation.model.MessageEntity;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* Closure regression test for RFC-067 PR 7.
|
||||
* <p>
|
||||
* PR 1 §4.1.5 flips {@code MessageEntity.status} from {@code awaiting_approval}
|
||||
* to {@code completed} (approve) or {@code stopped} (deny) inside
|
||||
* {@link vip.mate.workspace.conversation.ConversationService#markPendingApprovalsResolved}.
|
||||
* That status flip travels into history sanitization on subsequent LLM turns;
|
||||
* if any sanitizer stage accidentally treated the post-flip status as a stub
|
||||
* marker, the original assistant content would be dropped from history and the
|
||||
* user-visible conversation would lose context after every approval.
|
||||
* <p>
|
||||
* These tests pin the boundary: only the explicit {@code [等待审批]} content
|
||||
* placeholder is dropped by stage 1; a message that carries real streamed text
|
||||
* + {@code status=awaiting_approval | completed | stopped} is preserved exactly
|
||||
* regardless of where in the approval lifecycle it sits.
|
||||
*/
|
||||
class BaseAgentApprovalSanitizationTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("Real assistant content with status=awaiting_approval is NOT a Stage 1 placeholder")
|
||||
void realContentDuringAwaiting() {
|
||||
// Common shape: streamed partial answer + tool_approval_requested mid-flight,
|
||||
// doOnComplete persists with status=awaiting_approval (PR 5).
|
||||
MessageEntity msg = entity("我准备读取你的简历文件。", "awaiting_approval");
|
||||
assertFalse(BaseAgent.isApprovalPlaceholder(msg.getContent()),
|
||||
"real text must not match the placeholder regex — sanitizer would drop it otherwise");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Post-approve message (status=completed, real content) is NOT a placeholder")
|
||||
void postApproveMessageSurvives() {
|
||||
// After PR 1 §4.1.5 reconciles approval: status flips awaiting_approval → completed,
|
||||
// metadata.pendingApproval.status flips pending_approval → approved, content is unchanged.
|
||||
MessageEntity msg = entity("已读取简历,关键信息: ...", "completed");
|
||||
assertFalse(BaseAgent.isApprovalPlaceholder(msg.getContent()),
|
||||
"approved-and-completed history entry must survive sanitization for the next LLM turn");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Post-deny message (status=stopped, real content) is NOT a placeholder")
|
||||
void postDenyMessageSurvives() {
|
||||
// Deny path: status flips awaiting_approval → stopped, content stays as the
|
||||
// partial assistant text. The LLM should still see this on the next turn so
|
||||
// it understands "I started reading then was denied" rather than amnesia.
|
||||
MessageEntity msg = entity("用户拒绝执行工具 write_file", "stopped");
|
||||
assertFalse(BaseAgent.isApprovalPlaceholder(msg.getContent()),
|
||||
"denied turn's assistant text must survive history sanitization");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Pure placeholder content IS dropped (Stage 1's actual job)")
|
||||
void placeholderStubIsFiltered() {
|
||||
// The "[等待审批]" stub is the historical placeholder format that Stage 1 catches —
|
||||
// those rows have no streamed content and add no value to the LLM context.
|
||||
MessageEntity msg = entity("[等待审批]", "awaiting_approval");
|
||||
assertTrue(BaseAgent.isApprovalPlaceholder(msg.getContent()),
|
||||
"stub-only placeholder content must still match so Stage 1 keeps filtering it");
|
||||
}
|
||||
|
||||
private static MessageEntity entity(String content, String status) {
|
||||
MessageEntity m = new MessageEntity();
|
||||
m.setRole("assistant");
|
||||
m.setContent(content);
|
||||
m.setStatus(status);
|
||||
return m;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,150 @@
|
||||
package vip.mate.agent;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import vip.mate.workspace.conversation.model.MessageEntity;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* RFC-052 multi-turn leakage fix: verify that {@link BaseAgent#isDirectToolMessage}
|
||||
* correctly identifies persisted assistant messages produced by a returnDirect
|
||||
* tool path, so {@code toSpringMessage} replaces their content with a placeholder
|
||||
* before the next turn's prompt is built.
|
||||
*
|
||||
* <p>The DB row stays unchanged; only the in-memory {@code AssistantMessage}
|
||||
* handed to the model is scrubbed.
|
||||
*/
|
||||
class BaseAgentDirectToolHistoryScrubTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("metadata.directToolNames non-empty list => identified as direct-tool message")
|
||||
void directToolNamesNonEmpty_recognized() {
|
||||
MessageEntity msg = new MessageEntity();
|
||||
msg.setRole("assistant");
|
||||
msg.setContent("EMPLOYEE-SECRET-DATA");
|
||||
msg.setMetadata("{\"segments\":[],\"directToolNames\":[\"query_employee_salary\"]}");
|
||||
|
||||
assertTrue(BaseAgent.isDirectToolMessage(msg),
|
||||
"Assistant message with directToolNames must be flagged for scrubbing");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("metadata.directToolNames empty list => NOT treated as direct-tool")
|
||||
void directToolNamesEmpty_notRecognized() {
|
||||
MessageEntity msg = new MessageEntity();
|
||||
msg.setRole("assistant");
|
||||
msg.setContent("normal answer");
|
||||
msg.setMetadata("{\"directToolNames\":[]}");
|
||||
|
||||
assertFalse(BaseAgent.isDirectToolMessage(msg),
|
||||
"Empty directToolNames means no direct tool fired — don't scrub");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("metadata without directToolNames => not direct-tool")
|
||||
void noDirectToolNamesField_notRecognized() {
|
||||
MessageEntity msg = new MessageEntity();
|
||||
msg.setRole("assistant");
|
||||
msg.setContent("regular tool-call answer");
|
||||
msg.setMetadata("{\"toolCalls\":[{\"name\":\"get_weather\"}]}");
|
||||
|
||||
assertFalse(BaseAgent.isDirectToolMessage(msg));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("null/empty metadata => not direct-tool")
|
||||
void nullOrEmptyMetadata_notRecognized() {
|
||||
MessageEntity msg = new MessageEntity();
|
||||
msg.setRole("assistant");
|
||||
msg.setContent("hi");
|
||||
|
||||
assertFalse(BaseAgent.isDirectToolMessage(msg), "null metadata");
|
||||
|
||||
msg.setMetadata("");
|
||||
assertFalse(BaseAgent.isDirectToolMessage(msg), "empty metadata string");
|
||||
|
||||
msg.setMetadata("{}");
|
||||
assertFalse(BaseAgent.isDirectToolMessage(msg), "empty JSON object");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("null entity => safely returns false")
|
||||
void nullEntity_safe() {
|
||||
assertFalse(BaseAgent.isDirectToolMessage(null));
|
||||
}
|
||||
|
||||
// ========== OpenClaw-inspired optimization: tool-name-aware placeholder ==========
|
||||
|
||||
@Test
|
||||
@DisplayName("directToolNamesIn extracts the array contents")
|
||||
void extractToolNames_singleAndMultiple() {
|
||||
MessageEntity single = new MessageEntity();
|
||||
single.setRole("assistant");
|
||||
single.setMetadata("{\"directToolNames\":[\"query_employee_salary\"]}");
|
||||
assertEquals(List.of("query_employee_salary"),
|
||||
BaseAgent.directToolNamesIn(single));
|
||||
|
||||
MessageEntity multi = new MessageEntity();
|
||||
multi.setRole("assistant");
|
||||
multi.setMetadata("{\"directToolNames\":[\"tool_a\",\"tool_b\",\"tool_c\"]}");
|
||||
assertEquals(List.of("tool_a", "tool_b", "tool_c"),
|
||||
BaseAgent.directToolNamesIn(multi));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("directToolNamesIn returns empty list for non-direct messages")
|
||||
void extractToolNames_emptyForNonDirect() {
|
||||
MessageEntity msg = new MessageEntity();
|
||||
msg.setRole("assistant");
|
||||
msg.setMetadata("{\"toolCalls\":[{\"name\":\"get_weather\"}]}");
|
||||
assertTrue(BaseAgent.directToolNamesIn(msg).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("History placeholder names the tool so the model retains conversational structure")
|
||||
void placeholder_singleTool_namesIt() {
|
||||
String placeholder = BaseAgent.directToolHistoryPlaceholder(
|
||||
List.of("query_employee_salary"));
|
||||
assertTrue(placeholder.contains("query_employee_salary"),
|
||||
"Single-tool placeholder must name the tool");
|
||||
assertTrue(placeholder.contains("withheld"),
|
||||
"Placeholder must signal the data is withheld");
|
||||
assertTrue(placeholder.contains("call the tool again"),
|
||||
"Placeholder must hint at the recovery path");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Multi-tool placeholder lists every tool")
|
||||
void placeholder_multipleTools_listAll() {
|
||||
String placeholder = BaseAgent.directToolHistoryPlaceholder(
|
||||
List.of("query_employee_salary", "read_medical_record"));
|
||||
assertTrue(placeholder.contains("query_employee_salary"));
|
||||
assertTrue(placeholder.contains("read_medical_record"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Empty/null tool name list falls back to a generic placeholder")
|
||||
void placeholder_emptyList_genericFallback() {
|
||||
String empty = BaseAgent.directToolHistoryPlaceholder(List.of());
|
||||
String nullList = BaseAgent.directToolHistoryPlaceholder(null);
|
||||
assertEquals(empty, nullList,
|
||||
"Both null and empty must produce identical generic placeholders");
|
||||
assertTrue(empty.contains("withheld"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Placeholder MUST NOT echo the original sensitive content")
|
||||
void placeholder_neverContainsTheSensitivePayload() {
|
||||
// Sanity: even if the metadata-extracted tool name happens to be
|
||||
// sensitive-sounding, the placeholder is bounded — it doesn't re-emit
|
||||
// the message content itself.
|
||||
String placeholder = BaseAgent.directToolHistoryPlaceholder(
|
||||
List.of("query_employee_salary"));
|
||||
assertFalse(placeholder.contains("12345"));
|
||||
assertFalse(placeholder.contains("SSN"));
|
||||
assertFalse(placeholder.contains("PWD"));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,192 @@
|
||||
package vip.mate.agent;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.ai.chat.messages.UserMessage;
|
||||
import vip.mate.llm.service.ModelCapabilityService;
|
||||
import vip.mate.workspace.conversation.ConversationService;
|
||||
import vip.mate.workspace.conversation.model.MessageContentPart;
|
||||
import vip.mate.workspace.conversation.model.MessageEntity;
|
||||
|
||||
import java.util.EnumSet;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* Issue #44 regression: when a video attachment cannot be passed to the model
|
||||
* (because the agent's resolved {@link ModelCapabilityService.Modality#VIDEO}
|
||||
* capability is absent), the user message must include a system notice listing
|
||||
* the skipped attachment and instructing the agent NOT to invent a tool call to
|
||||
* read it.
|
||||
*
|
||||
* <p>The original bug: silent skip ({@code log.debug} only) → agent saw
|
||||
* {@code [附件] xxx.mp4} placeholder text in history but no actual media → it
|
||||
* picked {@code BrowserUseTool} or similar to "open" the file, which never
|
||||
* produced useful results.
|
||||
*
|
||||
* <p>These tests pin the contract that the skip path mutates the prompt text,
|
||||
* not just a log line.
|
||||
*
|
||||
* <p>Issue #87 update: the previous "禁止调用任何工具" sentence is no longer
|
||||
* emitted unconditionally — when the agent has any media-capable tool bound,
|
||||
* the LLM is allowed to delegate to it. With no tools (this test scaffold's
|
||||
* default), the notice falls back to a "switch models" suggestion only.
|
||||
*/
|
||||
class BaseAgentMultimodalSkipNoticeTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("Video attachment + model lacks VIDEO capability → skipped, system notice in prompt text")
|
||||
void videoSkipped_emitsSystemNotice() {
|
||||
TestAgent agent = newAgentWithCaps(EnumSet.noneOf(ModelCapabilityService.Modality.class));
|
||||
MessageEntity msg = userMessage("看看这段视频");
|
||||
when(agent.conversationService.parseMessageParts(msg))
|
||||
.thenReturn(List.of(MessageContentPart.video("media-1", "demo.mp4")));
|
||||
|
||||
UserMessage result = agent.callBuildUserMessage(msg, "看看这段视频");
|
||||
|
||||
assertNotNull(result);
|
||||
String text = result.getText();
|
||||
assertTrue(text.contains("[系统提示]"),
|
||||
"skipped video must surface a system notice in the prompt text — issue #44");
|
||||
assertTrue(text.contains("demo.mp4"),
|
||||
"notice must name the skipped file so the agent can tell the user");
|
||||
assertTrue(text.contains("不支持视频输入"),
|
||||
"reason string must name the modality the model cannot consume");
|
||||
assertTrue(text.contains("建议切换"),
|
||||
"notice must tell the agent to recommend switching models when no media tool is bound");
|
||||
assertFalse(text.contains("不要调用任何工具"),
|
||||
"issue #87: the hard tool ban must be gone — bound media tools should still be usable");
|
||||
assertTrue(result.getMedia() == null || result.getMedia().isEmpty(),
|
||||
"video must NOT be injected as Media when capability is absent");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Image attachment + model lacks VISION capability → skipped, system notice")
|
||||
void imageSkipped_emitsSystemNotice() {
|
||||
// Regression for the "GLM-5-Turbo + image upload" failure: when a user
|
||||
// uploads an image to a text-only model, we used to pass the image through
|
||||
// anyway and let the API 400. Now we skip + notify, same as the video gate.
|
||||
TestAgent agent = newAgentWithCaps(EnumSet.noneOf(ModelCapabilityService.Modality.class));
|
||||
MessageEntity msg = userMessage("看看这张图");
|
||||
when(agent.conversationService.parseMessageParts(msg))
|
||||
.thenReturn(List.of(MessageContentPart.file("media-1", "poster.png", "image/png")));
|
||||
|
||||
UserMessage result = agent.callBuildUserMessage(msg, "看看这张图");
|
||||
|
||||
String text = result.getText();
|
||||
assertTrue(text.contains("poster.png"));
|
||||
assertTrue(text.contains("不支持图片输入"),
|
||||
"vision-skip notice must use 不支持图片输入 wording");
|
||||
assertTrue(result.getMedia() == null || result.getMedia().isEmpty(),
|
||||
"image must NOT be injected when model has no VISION capability");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("No attachments → no system notice, prompt text unchanged")
|
||||
void noAttachments_noNoticeAdded() {
|
||||
TestAgent agent = newAgentWithCaps(EnumSet.noneOf(ModelCapabilityService.Modality.class));
|
||||
MessageEntity msg = userMessage("hello");
|
||||
when(agent.conversationService.parseMessageParts(msg)).thenReturn(List.of());
|
||||
|
||||
UserMessage result = agent.callBuildUserMessage(msg, "hello");
|
||||
|
||||
assertFalse(result.getText().contains("[系统提示]"),
|
||||
"no skipped attachments → no notice; clean prompt for normal text-only turns");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Capable model + video → no system notice (notice only fires on actual skip)")
|
||||
void videoCapable_noNotice_attemptInjection() {
|
||||
// VIDEO capability present → no skip-on-capability-grounds. The injection itself
|
||||
// may still fail downstream (file path doesn't exist in this test) — when that
|
||||
// happens, the file-not-found / load-failure branch surfaces its OWN notice with
|
||||
// a different reason string. We assert the capability-skip reason is absent here.
|
||||
TestAgent agent = newAgentWithCaps(
|
||||
EnumSet.of(ModelCapabilityService.Modality.VIDEO, ModelCapabilityService.Modality.TEXT));
|
||||
MessageEntity msg = userMessage("分析视频");
|
||||
when(agent.conversationService.parseMessageParts(msg))
|
||||
.thenReturn(List.of(MessageContentPart.video("media-1", "ok.mp4")));
|
||||
|
||||
UserMessage result = agent.callBuildUserMessage(msg, "分析视频");
|
||||
|
||||
assertFalse(result.getText().contains("不支持视频输入"),
|
||||
"capable model must not be flagged as missing video capability");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("History replay (injectMedia=false) returns text-only — no Media accumulation")
|
||||
void historyReplay_dropsMedia() {
|
||||
// Regression for Zhipu GLM-5V "code:1210 input videos exceeds limit": each
|
||||
// historical user message previously re-injected its video Media on every
|
||||
// turn, so a 2-turn conversation hit the per-request 1-video cap. The
|
||||
// history path must drop Media even when the model supports video.
|
||||
TestAgent agent = newAgentWithCaps(
|
||||
EnumSet.of(ModelCapabilityService.Modality.VIDEO, ModelCapabilityService.Modality.TEXT));
|
||||
MessageEntity msg = userMessage("上一轮的视频");
|
||||
// parseMessageParts is irrelevant when injectMedia=false; verify by NOT stubbing it.
|
||||
|
||||
UserMessage result = agent.callBuildUserMessage(msg, "上一轮的视频", false);
|
||||
|
||||
assertTrue(result.getMedia() == null || result.getMedia().isEmpty(),
|
||||
"history replay must NOT carry Media — even capable models cap video count per request");
|
||||
assertFalse(result.getText().contains("[系统提示]"),
|
||||
"history replay must NOT add the skip notice — the skip notice is a current-turn concern");
|
||||
}
|
||||
|
||||
// ---------- Test scaffold ----------
|
||||
|
||||
private static MessageEntity userMessage(String content) {
|
||||
MessageEntity m = new MessageEntity();
|
||||
m.setRole("user");
|
||||
m.setContent(content);
|
||||
return m;
|
||||
}
|
||||
|
||||
private static TestAgent newAgentWithCaps(EnumSet<ModelCapabilityService.Modality> caps) {
|
||||
ConversationService conv = mock(ConversationService.class);
|
||||
TestAgent agent = new TestAgent(conv);
|
||||
agent.modelCapabilities = caps;
|
||||
agent.modelName = "test-model";
|
||||
agent.agentName = "test-agent";
|
||||
return agent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal concrete BaseAgent for testing buildUserMessage. The abstract
|
||||
* chat / chatStream / execute methods are stubbed because buildUserMessage
|
||||
* does not depend on them.
|
||||
*/
|
||||
static class TestAgent extends BaseAgent {
|
||||
TestAgent(ConversationService conv) {
|
||||
super(null, conv);
|
||||
}
|
||||
|
||||
UserMessage callBuildUserMessage(MessageEntity msg, String renderedContent) {
|
||||
return buildUserMessage(msg, renderedContent);
|
||||
}
|
||||
|
||||
UserMessage callBuildUserMessage(MessageEntity msg, String renderedContent, boolean injectMedia) {
|
||||
return buildUserMessage(msg, renderedContent, injectMedia);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String chat(String userMessage, String conversationId) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public reactor.core.publisher.Flux<String> chatStream(String userMessage, String conversationId) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String execute(String goal, String conversationId) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,78 @@
|
||||
package vip.mate.agent;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* Field-shape tests for {@link GraphEventPublisher#iterationStart} and
|
||||
* {@link GraphEventPublisher#iterationEnd}. Verifies the payload contract
|
||||
* that downstream SSE consumers depend on (index / scope default / optional
|
||||
* subagentId / char counters).
|
||||
*/
|
||||
class GraphEventPublisherIterationTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("iterationStart carries index, reason, scope, timestamp")
|
||||
void iterationStartShape() {
|
||||
GraphEventPublisher.GraphEvent event = GraphEventPublisher.iterationStart(
|
||||
3, "react_step", "parent", null);
|
||||
assertEquals(GraphEventPublisher.EVENT_ITERATION_START, event.type());
|
||||
Map<String, Object> data = event.data();
|
||||
assertEquals(3, data.get("index"));
|
||||
assertEquals("react_step", data.get("reason"));
|
||||
assertEquals("parent", data.get("scope"));
|
||||
assertFalse(data.containsKey("subagentId"),
|
||||
"subagentId must be absent when null/empty");
|
||||
assertTrue(data.containsKey("timestamp"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("iterationStart defaults missing scope to 'parent'")
|
||||
void iterationStartDefaultsScope() {
|
||||
GraphEventPublisher.GraphEvent event = GraphEventPublisher.iterationStart(
|
||||
0, null, null, null);
|
||||
Map<String, Object> data = event.data();
|
||||
assertEquals("parent", data.get("scope"));
|
||||
assertEquals("", data.get("reason"),
|
||||
"Missing reason should serialize as empty string, not null");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("iterationStart includes subagentId when provided")
|
||||
void iterationStartIncludesSubagentId() {
|
||||
GraphEventPublisher.GraphEvent event = GraphEventPublisher.iterationStart(
|
||||
7, "plan_step", "subagent", "sa-42");
|
||||
Map<String, Object> data = event.data();
|
||||
assertEquals("subagent", data.get("scope"));
|
||||
assertEquals("sa-42", data.get("subagentId"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("iterationEnd carries char counters and scope")
|
||||
void iterationEndShape() {
|
||||
GraphEventPublisher.GraphEvent event = GraphEventPublisher.iterationEnd(
|
||||
5, "parent", null, 1234, 56);
|
||||
assertEquals(GraphEventPublisher.EVENT_ITERATION_END, event.type());
|
||||
Map<String, Object> data = event.data();
|
||||
assertEquals(5, data.get("index"));
|
||||
assertEquals("parent", data.get("scope"));
|
||||
assertEquals(1234, data.get("contentChars"));
|
||||
assertEquals(56, data.get("thinkingChars"));
|
||||
assertFalse(data.containsKey("subagentId"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("iterationEnd defaults scope to 'parent' when null")
|
||||
void iterationEndDefaultsScope() {
|
||||
GraphEventPublisher.GraphEvent event = GraphEventPublisher.iterationEnd(
|
||||
0, null, "", 0, 0);
|
||||
Map<String, Object> data = event.data();
|
||||
assertEquals("parent", data.get("scope"));
|
||||
assertFalse(data.containsKey("subagentId"),
|
||||
"Empty subagentId must be omitted");
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,430 @@
|
||||
package vip.mate.agent;
|
||||
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.ai.openai.api.OpenAiApi;
|
||||
import org.springframework.ai.openai.api.OpenAiApi.ChatCompletionMessage;
|
||||
import org.springframework.ai.openai.api.OpenAiApi.ChatCompletionMessage.Role;
|
||||
import org.springframework.ai.openai.api.OpenAiApi.ChatCompletionMessage.ToolCall;
|
||||
import org.springframework.ai.openai.api.OpenAiApi.ChatCompletionRequest;
|
||||
import vip.mate.llm.model.ModelProviderEntity;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
|
||||
/**
|
||||
* RFC-049 PR-2 consumer-side tests for
|
||||
* {@link AgentGraphBuilder#patchReasoningContent(ChatCompletionRequest, ModelProviderEntity)}.
|
||||
*
|
||||
* <p>Covers four orthogonal dimensions:
|
||||
* <ul>
|
||||
* <li>FallbackPolicy — DEEPSEEK (null + warn + patchNonToolCall) vs KIMI / OPENAI /
|
||||
* DEFAULT (" " + no-warn + tool-call-only)</li>
|
||||
* <li>{@code lastUserIdx} scope — assistants at {@code i <= lastUserIdx} never patched;
|
||||
* iterator still advances for alignment</li>
|
||||
* <li>sanitizedUser — restored from {@code RelayEntry.originalUser}; relay token
|
||||
* never egresses</li>
|
||||
* <li>relay presence — iterator consumed in order; missing relay triggers policy
|
||||
* fallback only for in-turn messages</li>
|
||||
* </ul>
|
||||
*/
|
||||
class PatchReasoningContentTest {
|
||||
|
||||
// ---------- Fixtures ----------
|
||||
|
||||
private static ModelProviderEntity provider(String id) {
|
||||
ModelProviderEntity p = new ModelProviderEntity();
|
||||
p.setProviderId(id);
|
||||
return p;
|
||||
}
|
||||
|
||||
private static ChatCompletionMessage user(String text) {
|
||||
return new ChatCompletionMessage(text, Role.USER);
|
||||
}
|
||||
|
||||
private static ChatCompletionMessage system(String text) {
|
||||
return new ChatCompletionMessage(text, Role.SYSTEM);
|
||||
}
|
||||
|
||||
/** Plain assistant message — no tool calls, no reasoning_content. */
|
||||
private static ChatCompletionMessage assistantPlain(String text) {
|
||||
return new ChatCompletionMessage(text, Role.ASSISTANT, null, null, null, null, null, null, null);
|
||||
}
|
||||
|
||||
/** Assistant tool_call message with optional pre-existing reasoning_content. */
|
||||
private static ChatCompletionMessage assistantToolCall(String text, String reasoningContent) {
|
||||
ToolCall tc = new ToolCall("call_1", "function", null);
|
||||
return new ChatCompletionMessage(text, Role.ASSISTANT, null, null, List.of(tc), null, null, null, reasoningContent);
|
||||
}
|
||||
|
||||
/** Build a ChatCompletionRequest with the given messages + user field; all other fields null. */
|
||||
private static ChatCompletionRequest request(List<ChatCompletionMessage> messages, String user) {
|
||||
return new ChatCompletionRequest(
|
||||
messages, // messages
|
||||
"test-model", // model
|
||||
null, null, null, null, null, null, null, null, null, null, null,
|
||||
null, null, null, null, null, null, null, null, null, null,
|
||||
null, null, // toolChoice, parallelToolCalls
|
||||
user, // user
|
||||
null, // reasoningEffort
|
||||
null, null, null, null, null
|
||||
);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void clearRelay() {
|
||||
AssistantThinkingRelay.clearAll();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void clearRelayAfter() {
|
||||
AssistantThinkingRelay.clearAll();
|
||||
}
|
||||
|
||||
// ---------- No-relay, no-thinking-mode path ----------
|
||||
|
||||
@Test
|
||||
@DisplayName("No relay token + no thinking signals → request passes through unchanged")
|
||||
void noop_whenNoRelayAndNoThinkingMode() {
|
||||
ChatCompletionRequest req = request(List.of(
|
||||
system("sys"),
|
||||
user("q1"),
|
||||
assistantToolCall("", null) // no reasoning_content anywhere → not thinking mode
|
||||
), "caller-user-1");
|
||||
|
||||
// model is "test-model" which maps to STANDARD family → requiresReasoningContentPatch returns false
|
||||
ChatCompletionRequest out = AgentGraphBuilder.patchReasoningContent(req, provider("deepseek"));
|
||||
assertSame(req, out, "no thinking signal → no rebuild");
|
||||
assertEquals("caller-user-1", out.user(), "user field untouched");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Leaked relay token (no entry in map) + no thinking signals → strips token, rebuilds user")
|
||||
void stripsLeakedToken_whenNoEntryNoThinking() {
|
||||
// Prefix-shaped but not actually stashed — simulates consumer running after
|
||||
// producer's finally already discarded. take() returns null; isToken() still true.
|
||||
String fakeToken = AssistantThinkingRelay.TOKEN_PREFIX + "orphan-uuid";
|
||||
ChatCompletionRequest req = request(List.of(
|
||||
user("q1"),
|
||||
assistantPlain("hi")
|
||||
), fakeToken);
|
||||
|
||||
ChatCompletionRequest out = AgentGraphBuilder.patchReasoningContent(req, provider("openai"));
|
||||
assertNotSame(req, out, "rebuild expected to strip leaked token");
|
||||
assertNull(out.user(), "leaked token must be sanitized to null");
|
||||
}
|
||||
|
||||
// ---------- sanitizedUser restoration from RelayEntry ----------
|
||||
|
||||
@Test
|
||||
@DisplayName("sanitizedUser is restored from RelayEntry.originalUser")
|
||||
void sanitizedUser_restoredFromRelayEntry() {
|
||||
List<String> thinkings = List.of("", "in-turn-think");
|
||||
String token = AssistantThinkingRelay.stash(thinkings, "original-caller-42");
|
||||
|
||||
ChatCompletionRequest req = request(List.of(
|
||||
assistantPlain("prior-assistant"), // i=0, position 0 in thinkings → ""
|
||||
user("q1"),
|
||||
assistantToolCall("a1", null) // i=2, position 1 in thinkings → "in-turn-think"
|
||||
), token);
|
||||
|
||||
ChatCompletionRequest out = AgentGraphBuilder.patchReasoningContent(req, provider("deepseek"));
|
||||
|
||||
assertEquals("original-caller-42", out.user(), "sanitizedUser must equal entry.originalUser()");
|
||||
assertEquals("in-turn-think", out.messages().get(2).reasoningContent(),
|
||||
"in-turn assistant (i=2 > lastUserIdx=1) should receive the real thinking");
|
||||
}
|
||||
|
||||
// ---------- lastUserIdx scope ----------
|
||||
|
||||
@Test
|
||||
@DisplayName("DEEPSEEK patchCrossTurn=true: prior-turn assistants get ' ' fallback so multi-turn doesn't 400")
|
||||
void crossTurnAssistants_patchedWithSpace_deepseek() {
|
||||
// [sys, U1, A1(tool_call, no-rc), U2, A2(tool_call, no-rc)]
|
||||
// lastUserIdx = 3 (U2)
|
||||
// Relay thinkings: [null for A1, "real-a2" for A2]
|
||||
//
|
||||
// DeepSeek (since 2026-04) requires reasoning_content on EVERY assistant
|
||||
// in the request — prior-turn included. Without patchCrossTurn, A1 stays
|
||||
// null and DeepSeek 400s on every multi-turn conversation. With it, A1
|
||||
// gets the same " " fallback in-turn assistants get.
|
||||
List<String> thinkings = Arrays.asList("", "real-a2");
|
||||
String token = AssistantThinkingRelay.stash(thinkings, null);
|
||||
|
||||
ChatCompletionRequest req = request(List.of(
|
||||
system("sys"),
|
||||
user("q1"),
|
||||
assistantToolCall("a1", null), // i=2, cross-turn (2 <= 3)
|
||||
user("q2"),
|
||||
assistantToolCall("a2", null) // i=4, in-turn (4 > 3)
|
||||
), token);
|
||||
|
||||
ChatCompletionRequest out = AgentGraphBuilder.patchReasoningContent(req, provider("deepseek"));
|
||||
|
||||
assertEquals(" ", out.messages().get(2).reasoningContent(),
|
||||
"cross-turn A1 gets ' ' fallback so DeepSeek thinking-mode validation passes");
|
||||
assertEquals("real-a2", out.messages().get(4).reasoningContent(),
|
||||
"in-turn A2 (i=4 > lastUserIdx=3) receives the real relay value");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Iterator stays aligned: cross-turn consumes '' positions so in-turn gets correct thinking")
|
||||
void iteratorAlignment_acrossCrossTurnAndInTurn() {
|
||||
// [U1, A1(no-rc), A2(no-rc), U2, A3(no-rc), A4(no-rc)]
|
||||
// lastUserIdx = 3 (U2). Producer extraction order = A1,A2,A3,A4.
|
||||
// Relay: ["","" (cross-turn, stripped already), "real-a3", "real-a4"]
|
||||
List<String> thinkings = List.of("", "", "real-a3", "real-a4");
|
||||
String token = AssistantThinkingRelay.stash(thinkings, null);
|
||||
|
||||
ChatCompletionRequest req = request(List.of(
|
||||
user("q1"),
|
||||
assistantToolCall("a1", null), // i=1 cross-turn
|
||||
assistantToolCall("a2", null), // i=2 cross-turn
|
||||
user("q2"),
|
||||
assistantToolCall("a3", null), // i=4 in-turn
|
||||
assistantToolCall("a4", null) // i=5 in-turn
|
||||
), token);
|
||||
|
||||
ChatCompletionRequest out = AgentGraphBuilder.patchReasoningContent(req, provider("deepseek"));
|
||||
|
||||
// DEEPSEEK patchCrossTurn=true: cross-turn now also gets ' ' fallback.
|
||||
// Iterator alignment is preserved: A1/A2 consume the empty entries '',
|
||||
// A3/A4 consume their real values in correct positions.
|
||||
assertEquals(" ", out.messages().get(1).reasoningContent(), "A1 cross-turn ' ' fallback");
|
||||
assertEquals(" ", out.messages().get(2).reasoningContent(), "A2 cross-turn ' ' fallback");
|
||||
assertEquals("real-a3", out.messages().get(4).reasoningContent(), "A3 in-turn gets real-a3 (not real-a4)");
|
||||
assertEquals("real-a4", out.messages().get(5).reasoningContent(), "A4 in-turn gets real-a4");
|
||||
}
|
||||
|
||||
// ---------- FallbackPolicy × emptyFallback ----------
|
||||
|
||||
@Test
|
||||
@DisplayName("DEEPSEEK policy: relay empty for in-turn tool_call → reasoning_content gets ' ' fallback")
|
||||
void deepseek_relayEmpty_fallsBackToSpace() {
|
||||
// 72bd33dc switched DEEPSEEK from emptyFallback=null (force explicit 400)
|
||||
// to " " — null kept self-replicating 400s every multi-tool turn that
|
||||
// crossed a summarizing boundary. Aligning with KIMI/OPENAI tolerance.
|
||||
List<String> thinkings = List.of(""); // one assistant, no real thinking
|
||||
String token = AssistantThinkingRelay.stash(thinkings, null);
|
||||
|
||||
ChatCompletionRequest req = request(List.of(
|
||||
user("q1"),
|
||||
assistantToolCall("a1", null) // in-turn
|
||||
), token);
|
||||
|
||||
ChatCompletionRequest out = AgentGraphBuilder.patchReasoningContent(req, provider("deepseek"));
|
||||
|
||||
assertEquals(" ", out.messages().get(1).reasoningContent(),
|
||||
"DeepSeek: ' ' fallback restores forward progress when relay has no real value");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("KIMI policy: relay empty for in-turn tool_call → ' ' injected (legacy tolerance)")
|
||||
void kimi_relayEmpty_injectsSpace() {
|
||||
// Kimi path is triggered by the model-family check; use model name that maps to KIMI_THINKING.
|
||||
List<String> thinkings = List.of("");
|
||||
String token = AssistantThinkingRelay.stash(thinkings, null);
|
||||
|
||||
List<ChatCompletionMessage> msgs = List.of(
|
||||
user("q1"),
|
||||
assistantToolCall("a1", null)
|
||||
);
|
||||
ChatCompletionRequest req = new ChatCompletionRequest(
|
||||
msgs, "kimi-k2.5", null, null, null, null, null, null, null, null, null, null, null,
|
||||
null, null, null, null, null, null, null, null, null, null, null, null, token,
|
||||
null, null, null, null, null, null
|
||||
);
|
||||
|
||||
ChatCompletionRequest out = AgentGraphBuilder.patchReasoningContent(req, provider("kimi-cn"));
|
||||
|
||||
assertEquals(" ", out.messages().get(1).reasoningContent(),
|
||||
"Kimi tolerates ' ' — preserve legacy behavior");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Unknown provider uses DEFAULT policy: ' ' injected (legacy tolerance, not noop)")
|
||||
void defaultPolicy_unknownProvider_injectsSpace() {
|
||||
List<String> thinkings = List.of("");
|
||||
String token = AssistantThinkingRelay.stash(thinkings, null);
|
||||
|
||||
// Use a model that triggers requiresReasoningContentPatch so thinking mode is active
|
||||
List<ChatCompletionMessage> msgs = List.of(
|
||||
user("q1"),
|
||||
assistantToolCall("a1", null)
|
||||
);
|
||||
ChatCompletionRequest req = new ChatCompletionRequest(
|
||||
msgs, "deepseek-reasoner", null, null, null, null, null, null, null, null, null, null, null,
|
||||
null, null, null, null, null, null, null, null, null, null, null, null, token,
|
||||
null, null, null, null, null, null
|
||||
);
|
||||
|
||||
ChatCompletionRequest out = AgentGraphBuilder.patchReasoningContent(req, provider("custom-gateway"));
|
||||
|
||||
assertEquals(" ", out.messages().get(1).reasoningContent(),
|
||||
"DEFAULT keeps legacy ' ' for unrecognized providers — avoid regressing self-hosted backends");
|
||||
}
|
||||
|
||||
// ---------- FallbackPolicy × patchNonToolCall ----------
|
||||
|
||||
@Test
|
||||
@DisplayName("DEEPSEEK policy patches non-tool_call in-turn assistants too (patchNonToolCall=true)")
|
||||
void deepseek_patchesNonToolCallAssistant() {
|
||||
List<String> thinkings = List.of("thinking-for-plain");
|
||||
String token = AssistantThinkingRelay.stash(thinkings, null);
|
||||
|
||||
ChatCompletionRequest req = request(List.of(
|
||||
user("q1"),
|
||||
assistantPlain("plain answer") // no tool_calls
|
||||
), token);
|
||||
|
||||
ChatCompletionRequest out = AgentGraphBuilder.patchReasoningContent(req, provider("deepseek"));
|
||||
|
||||
assertEquals("thinking-for-plain", out.messages().get(1).reasoningContent(),
|
||||
"DeepSeek contract requires reasoning_content even on non-tool_call assistants when in thinking mode");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("KIMI policy leaves non-tool_call assistants alone (patchNonToolCall=false)")
|
||||
void kimi_skipsNonToolCallAssistant() {
|
||||
List<String> thinkings = List.of("would-not-be-used");
|
||||
String token = AssistantThinkingRelay.stash(thinkings, null);
|
||||
|
||||
List<ChatCompletionMessage> msgs = List.of(
|
||||
user("q1"),
|
||||
assistantPlain("plain answer")
|
||||
);
|
||||
ChatCompletionRequest req = new ChatCompletionRequest(
|
||||
msgs, "kimi-k2.5", null, null, null, null, null, null, null, null, null, null, null,
|
||||
null, null, null, null, null, null, null, null, null, null, null, null, token,
|
||||
null, null, null, null, null, null
|
||||
);
|
||||
|
||||
ChatCompletionRequest out = AgentGraphBuilder.patchReasoningContent(req, provider("kimi-cn"));
|
||||
|
||||
assertNull(out.messages().get(1).reasoningContent(),
|
||||
"Kimi only patches tool_call assistants; plain assistants are untouched");
|
||||
}
|
||||
|
||||
// ---------- Preserve pre-existing real values ----------
|
||||
|
||||
@Test
|
||||
@DisplayName("Assistant that already has real reasoning_content is left alone")
|
||||
void existingRealValue_preserved() {
|
||||
List<String> thinkings = List.of("would-overwrite");
|
||||
String token = AssistantThinkingRelay.stash(thinkings, null);
|
||||
|
||||
ChatCompletionRequest req = request(List.of(
|
||||
user("q1"),
|
||||
assistantToolCall("a1", "pre-existing-real-thinking") // already has a value
|
||||
), token);
|
||||
|
||||
ChatCompletionRequest out = AgentGraphBuilder.patchReasoningContent(req, provider("deepseek"));
|
||||
|
||||
assertEquals("pre-existing-real-thinking", out.messages().get(1).reasoningContent(),
|
||||
"non-blank existing reasoning_content must not be overwritten by relay");
|
||||
}
|
||||
|
||||
// ---------- Edge: empty messages ----------
|
||||
|
||||
@Test
|
||||
@DisplayName("Empty messages list: no-op, returns same instance")
|
||||
void emptyMessages_noop() {
|
||||
ChatCompletionRequest req = request(List.of(), null);
|
||||
assertSame(req, AgentGraphBuilder.patchReasoningContent(req, provider("deepseek")));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Null messages: no-op, returns same instance")
|
||||
void nullMessages_noop() {
|
||||
ChatCompletionRequest req = new ChatCompletionRequest(
|
||||
null, "m", null, null, null, null, null, null, null, null, null, null, null,
|
||||
null, null, null, null, null, null, null, null, null, null, null, null, null,
|
||||
null, null, null, null, null, null
|
||||
);
|
||||
assertSame(req, AgentGraphBuilder.patchReasoningContent(req, provider("deepseek")));
|
||||
}
|
||||
|
||||
// ---------- Fewer relay entries than assistants: defensive policy fallback ----------
|
||||
|
||||
@Test
|
||||
@DisplayName("Relay shorter than assistant count: extra in-turn assistants fall back to policy")
|
||||
void relayShorterThanAssistants_fallsBack() {
|
||||
// Producer extracted 1 entry but there are 2 in-turn tool_call assistants
|
||||
// (e.g. one was added after relay stash — shouldn't happen but be defensive).
|
||||
List<String> thinkings = List.of("real-1");
|
||||
String token = AssistantThinkingRelay.stash(thinkings, null);
|
||||
|
||||
ChatCompletionRequest req = request(new ArrayList<>(List.of(
|
||||
user("q1"),
|
||||
assistantToolCall("a1", null),
|
||||
assistantToolCall("a2", null)
|
||||
)), token);
|
||||
|
||||
ChatCompletionRequest out = AgentGraphBuilder.patchReasoningContent(req, provider("deepseek"));
|
||||
|
||||
assertEquals("real-1", out.messages().get(1).reasoningContent());
|
||||
assertEquals(" ", out.messages().get(2).reasoningContent(),
|
||||
"DEEPSEEK with emptyFallback=' ' (post-72bd33dc): missing real values get the same tolerant fallback");
|
||||
}
|
||||
|
||||
// ---------- patchCrossTurn policy (2026-04-29) ----------
|
||||
|
||||
@Test
|
||||
@DisplayName("KIMI / OPENAI / DEFAULT do NOT patch cross-turn — only DEEPSEEK does")
|
||||
void crossTurnPatching_isDeepseekOnly() {
|
||||
// Same shape as crossTurnAssistants_patchedWithSpace_deepseek but with
|
||||
// KIMI provider — KIMI's contract resets thinking across user turns,
|
||||
// so prior-turn assistants must remain null. Pinning this here protects
|
||||
// against accidentally flipping patchCrossTurn=true for all providers.
|
||||
List<String> thinkings = Arrays.asList("", "real-a2");
|
||||
String token = AssistantThinkingRelay.stash(thinkings, null);
|
||||
|
||||
ChatCompletionRequest req = request(List.of(
|
||||
user("q1"),
|
||||
assistantToolCall("a1", null), // i=1, cross-turn (1 <= 2)
|
||||
user("q2"),
|
||||
assistantToolCall("a2", null) // i=3, in-turn (3 > 2)
|
||||
), token);
|
||||
|
||||
ChatCompletionRequest out = AgentGraphBuilder.patchReasoningContent(req, provider("kimi-cn"));
|
||||
|
||||
assertNull(out.messages().get(1).reasoningContent(),
|
||||
"KIMI does not patch cross-turn — thinking resets across user turns");
|
||||
assertEquals("real-a2", out.messages().get(3).reasoningContent(),
|
||||
"KIMI in-turn assistants still receive their relay value");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("DEEPSEEK cross-turn assistant without tool_calls also patched (patchNonToolCall=true)")
|
||||
void crossTurnPlainAssistant_patchedForDeepseek() {
|
||||
// Plain prior-turn text assistant (no tool_calls): without the
|
||||
// patchNonToolCall guard, this would still be skipped. DEEPSEEK has
|
||||
// both patchNonToolCall=true AND patchCrossTurn=true, so it should
|
||||
// get the ' ' fallback. This is the most common production case
|
||||
// since plain assistants dominate IM channel history.
|
||||
List<String> thinkings = Arrays.asList("", "");
|
||||
String token = AssistantThinkingRelay.stash(thinkings, null);
|
||||
|
||||
ChatCompletionRequest req = request(List.of(
|
||||
user("q1"),
|
||||
new ChatCompletionMessage("plain a1", Role.ASSISTANT), // no tool_calls
|
||||
user("q2"),
|
||||
new ChatCompletionMessage("plain a2", Role.ASSISTANT)
|
||||
), token);
|
||||
|
||||
ChatCompletionRequest out = AgentGraphBuilder.patchReasoningContent(req, provider("deepseek"));
|
||||
|
||||
assertEquals(" ", out.messages().get(1).reasoningContent(),
|
||||
"DEEPSEEK plain prior-turn assistant gets ' ' so request validates");
|
||||
assertEquals(" ", out.messages().get(3).reasoningContent(),
|
||||
"DEEPSEEK plain in-turn assistant gets ' ' as before");
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,212 @@
|
||||
package vip.mate.agent;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.ai.openai.api.OpenAiApi;
|
||||
import vip.mate.llm.model.ModelProviderEntity;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* RFC-049 PR-1.3 verification — covers §5.2 Case E3.1 / E3.2 / E3.3 plus the
|
||||
* whitelist positive path.
|
||||
*
|
||||
* <p>The sanitizer is provider-first with default-deny: only providerId in
|
||||
* {@code {openai, azure-openai}} is allowed to carry {@code reasoning_effort}.
|
||||
* All other providers (including unknown ones) must strip regardless of what
|
||||
* {@code request.model()} says, because the model name may have leaked from a
|
||||
* failover primary.
|
||||
*/
|
||||
class ReasoningEffortSanitizerTest {
|
||||
|
||||
private static ModelProviderEntity provider(String id) {
|
||||
ModelProviderEntity p = new ModelProviderEntity();
|
||||
p.setProviderId(id);
|
||||
return p;
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct a minimal {@link OpenAiApi.ChatCompletionRequest} via its record canonical
|
||||
* constructor with only {@code messages}, {@code model}, and {@code reasoningEffort} set
|
||||
* — everything else is null. {@code ChatCompletionRequest} in Spring AI 1.1.4 has no
|
||||
* public builder.
|
||||
*/
|
||||
private static OpenAiApi.ChatCompletionRequest request(String model, String reasoningEffort) {
|
||||
return new OpenAiApi.ChatCompletionRequest(
|
||||
List.of(), // messages
|
||||
model, // model
|
||||
null, // store
|
||||
null, // metadata
|
||||
null, // frequencyPenalty
|
||||
null, // logitBias
|
||||
null, // logprobs
|
||||
null, // topLogprobs
|
||||
null, // maxTokens
|
||||
null, // maxCompletionTokens
|
||||
null, // n
|
||||
null, // outputModalities
|
||||
null, // audioParameters
|
||||
null, // presencePenalty
|
||||
null, // responseFormat
|
||||
null, // seed
|
||||
null, // serviceTier
|
||||
null, // stop
|
||||
null, // stream
|
||||
null, // streamOptions
|
||||
null, // temperature
|
||||
null, // topP
|
||||
null, // tools
|
||||
null, // toolChoice
|
||||
null, // parallelToolCalls
|
||||
null, // user
|
||||
reasoningEffort, // reasoningEffort
|
||||
null, // webSearchOptions
|
||||
null, // verbosity
|
||||
null, // promptCacheKey
|
||||
null, // safetyIdentifier
|
||||
null // extraBody
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Whitelist: openai is allowed")
|
||||
void whitelist_openai() {
|
||||
assertTrue(AgentGraphBuilder.isReasoningEffortWhitelistedProvider(provider("openai")));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Whitelist: azure-openai is allowed")
|
||||
void whitelist_azureOpenai() {
|
||||
assertTrue(AgentGraphBuilder.isReasoningEffortWhitelistedProvider(provider("azure-openai")));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Whitelist: case-insensitive (Azure-OpenAI)")
|
||||
void whitelist_caseInsensitive() {
|
||||
assertTrue(AgentGraphBuilder.isReasoningEffortWhitelistedProvider(provider("Azure-OpenAI")));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Whitelist: deepseek is denied")
|
||||
void denylist_deepseek() {
|
||||
assertFalse(AgentGraphBuilder.isReasoningEffortWhitelistedProvider(provider("deepseek")));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Whitelist: kimi family denied")
|
||||
void denylist_kimi() {
|
||||
assertFalse(AgentGraphBuilder.isReasoningEffortWhitelistedProvider(provider("kimi-cn")));
|
||||
assertFalse(AgentGraphBuilder.isReasoningEffortWhitelistedProvider(provider("kimi-intl")));
|
||||
assertFalse(AgentGraphBuilder.isReasoningEffortWhitelistedProvider(provider("kimi-code")));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Whitelist: dashscope / ollama / anthropic denied")
|
||||
void denylist_misc() {
|
||||
assertFalse(AgentGraphBuilder.isReasoningEffortWhitelistedProvider(provider("dashscope")));
|
||||
assertFalse(AgentGraphBuilder.isReasoningEffortWhitelistedProvider(provider("ollama")));
|
||||
assertFalse(AgentGraphBuilder.isReasoningEffortWhitelistedProvider(provider("anthropic")));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Whitelist: unknown providerId denied (default-deny — §5.2 Case E3.3)")
|
||||
void denylist_unknownProvider() {
|
||||
// This is the critical regression guard: if anyone re-adds a default-allow
|
||||
// branch to isReasoningEffortWhitelistedProvider, this case fails first.
|
||||
assertFalse(AgentGraphBuilder.isReasoningEffortWhitelistedProvider(
|
||||
provider("my-custom-openai-compat-gateway")));
|
||||
assertFalse(AgentGraphBuilder.isReasoningEffortWhitelistedProvider(
|
||||
provider("openrouter")));
|
||||
assertFalse(AgentGraphBuilder.isReasoningEffortWhitelistedProvider(
|
||||
provider("together")));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Whitelist: null provider / null providerId denied")
|
||||
void denylist_nulls() {
|
||||
assertFalse(AgentGraphBuilder.isReasoningEffortWhitelistedProvider(null));
|
||||
assertFalse(AgentGraphBuilder.isReasoningEffortWhitelistedProvider(new ModelProviderEntity()));
|
||||
}
|
||||
|
||||
// ---------- sanitizeReasoningEffortForProvider ----------
|
||||
|
||||
@Test
|
||||
@DisplayName("Sanitize no-op: request has no reasoning_effort")
|
||||
void sanitize_noop_noReasoningEffort() {
|
||||
OpenAiApi.ChatCompletionRequest req = request("gpt-5", null);
|
||||
OpenAiApi.ChatCompletionRequest out = AgentGraphBuilder.sanitizeReasoningEffortForProvider(req, provider("deepseek"));
|
||||
assertSame(req, out, "should return same instance when reasoning_effort is already null");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("§5.2 Case E3.1: primary=gpt-5 → fallback=deepseek strips reasoning_effort")
|
||||
void sanitize_failover_deepseek_strips() {
|
||||
// Simulate failover: OpenAiChatOptions.model still leaked as "gpt-5" on the deepseek request.
|
||||
OpenAiApi.ChatCompletionRequest req = request("gpt-5", "high");
|
||||
OpenAiApi.ChatCompletionRequest out = AgentGraphBuilder.sanitizeReasoningEffortForProvider(req, provider("deepseek"));
|
||||
assertNull(out.reasoningEffort(), "deepseek is not on the whitelist — strip regardless of model name");
|
||||
// Other fields preserved
|
||||
assertEquals("gpt-5", out.model());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("§5.2 Case E3.2: kimi / dashscope / ollama also strip")
|
||||
void sanitize_failover_otherDenied_strips() {
|
||||
for (String pid : List.of("kimi-cn", "kimi-intl", "kimi-code", "dashscope", "ollama", "anthropic")) {
|
||||
OpenAiApi.ChatCompletionRequest req = request("gpt-5", "medium");
|
||||
OpenAiApi.ChatCompletionRequest out = AgentGraphBuilder.sanitizeReasoningEffortForProvider(req, provider(pid));
|
||||
assertNull(out.reasoningEffort(), "provider=" + pid + " must strip");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("§5.2 Case E3.3: unknown provider strips (default-deny regression guard)")
|
||||
void sanitize_unknownProvider_strips() {
|
||||
OpenAiApi.ChatCompletionRequest req = request("gpt-5", "high");
|
||||
OpenAiApi.ChatCompletionRequest out = AgentGraphBuilder.sanitizeReasoningEffortForProvider(
|
||||
req, provider("my-custom-openai-compat-gateway"));
|
||||
assertNull(out.reasoningEffort(),
|
||||
"unknown provider must strip (default-deny) — if this fails, someone re-added default-allow");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Whitelist + supporting model: keep reasoning_effort (gpt-5 on openai)")
|
||||
void sanitize_whitelisted_supportingModel_keeps() {
|
||||
OpenAiApi.ChatCompletionRequest req = request("gpt-5", "high");
|
||||
OpenAiApi.ChatCompletionRequest out = AgentGraphBuilder.sanitizeReasoningEffortForProvider(req, provider("openai"));
|
||||
assertSame(req, out, "gpt-5 on openai should pass through unchanged");
|
||||
assertEquals("high", out.reasoningEffort());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Whitelist + non-supporting model: strip (gpt-4 on openai)")
|
||||
void sanitize_whitelisted_nonSupportingModel_strips() {
|
||||
// gpt-4 is NOT OPENAI_REASONING family — reasoning_effort is not applicable there.
|
||||
OpenAiApi.ChatCompletionRequest req = request("gpt-4", "medium");
|
||||
OpenAiApi.ChatCompletionRequest out = AgentGraphBuilder.sanitizeReasoningEffortForProvider(req, provider("openai"));
|
||||
assertNull(out.reasoningEffort(),
|
||||
"gpt-4 is whitelisted-provider but non-supporting-family — family gate should strip");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Azure OpenAI with supporting model: keep reasoning_effort")
|
||||
void sanitize_azureOpenai_supporting_keeps() {
|
||||
OpenAiApi.ChatCompletionRequest req = request("gpt-5", "low");
|
||||
OpenAiApi.ChatCompletionRequest out = AgentGraphBuilder.sanitizeReasoningEffortForProvider(req, provider("azure-openai"));
|
||||
assertEquals("low", out.reasoningEffort());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Null provider: strip (defensive)")
|
||||
void sanitize_nullProvider_strips() {
|
||||
OpenAiApi.ChatCompletionRequest req = request("gpt-5", "high");
|
||||
OpenAiApi.ChatCompletionRequest out = AgentGraphBuilder.sanitizeReasoningEffortForProvider(req, null);
|
||||
assertNull(out.reasoningEffort());
|
||||
}
|
||||
}
|
||||
@ -11,6 +11,7 @@ import org.springframework.test.context.TestPropertySource;
|
||||
import vip.mate.MateClawApplication;
|
||||
import vip.mate.agent.binding.model.AgentToolBinding;
|
||||
import vip.mate.agent.binding.service.AgentBindingService;
|
||||
import vip.mate.exception.MateClawException;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
@ -47,8 +48,54 @@ class AgentBindingServiceTest {
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
// 每个用例用独立 agent_id,互不干扰
|
||||
// Each test gets its own agent id so concurrent runs don't clash.
|
||||
agentId = AGENT_ID_SEQ.getAndIncrement();
|
||||
// Seed a real mate_agent row so AgentBindingService.requireSameWorkspace
|
||||
// can resolve the agent's workspace during bindSkill/setSkillBindings.
|
||||
// Tool-binding tests don't strictly need it but seeding is cheap and
|
||||
// keeps every code path realistic.
|
||||
seedAgent(agentId);
|
||||
}
|
||||
|
||||
private void seedAgent(long id) {
|
||||
jdbcTemplate.update(
|
||||
"MERGE INTO mate_agent (id, name, agent_type, system_prompt, max_iterations, enabled, " +
|
||||
"workspace_id, create_time, update_time, deleted) " +
|
||||
"KEY(id) VALUES (?, ?, 'react', '', 10, TRUE, 1, " +
|
||||
"CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)",
|
||||
id, "binding-test-agent-" + id);
|
||||
}
|
||||
|
||||
private void seedSkill(long id) {
|
||||
seedSkill(id, 1L);
|
||||
}
|
||||
|
||||
/**
|
||||
* Skill seeder with explicit workspace_id so the cross-workspace
|
||||
* rejection path can be exercised.
|
||||
*/
|
||||
private void seedSkill(long id, long workspaceId) {
|
||||
jdbcTemplate.update(
|
||||
"MERGE INTO mate_skill (id, name, skill_type, version, enabled, builtin, " +
|
||||
"workspace_id, create_time, update_time, deleted) " +
|
||||
"KEY(id) VALUES (?, ?, 'dynamic', '1.0.0', TRUE, FALSE, ?, " +
|
||||
"CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)",
|
||||
id, "binding-test-skill-" + id, workspaceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* ACP endpoint seeder. The bridge synthesizes a virtual skill with id
|
||||
* {@code AcpSkillBridge.VIRTUAL_ID_BASE + endpointId} and inherits
|
||||
* {@code workspaceId} from the row, so this is the lever for testing
|
||||
* the bridge-backed workspace check in {@code requireSameWorkspace}.
|
||||
*/
|
||||
private void seedAcpEndpoint(long endpointId, long workspaceId) {
|
||||
jdbcTemplate.update(
|
||||
"MERGE INTO mate_acp_endpoint (id, name, command, builtin, trusted, enabled, " +
|
||||
"workspace_id, create_time, update_time, deleted) " +
|
||||
"KEY(id) VALUES (?, ?, 'echo', FALSE, TRUE, TRUE, ?, " +
|
||||
"CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)",
|
||||
endpointId, "binding-test-acp-" + endpointId, workspaceId);
|
||||
}
|
||||
|
||||
@Test
|
||||
@ -67,6 +114,7 @@ class AgentBindingServiceTest {
|
||||
@DisplayName("bindSkill → unbindSkill → bindSkill 同一 (agent, skill) 不抛异常")
|
||||
void rebindSkillAfterUnbind() {
|
||||
long skillId = 7_777_001L;
|
||||
seedSkill(skillId);
|
||||
bindingService.bindSkill(agentId, skillId);
|
||||
bindingService.unbindSkill(agentId, skillId);
|
||||
assertDoesNotThrow(() -> bindingService.bindSkill(agentId, skillId));
|
||||
@ -79,6 +127,13 @@ class AgentBindingServiceTest {
|
||||
@Test
|
||||
@DisplayName("setToolBindings 连续调用两次相同列表不抛异常,状态收敛")
|
||||
void setToolBindingsIsIdempotent() {
|
||||
// setToolBindings now refuses unknown tool names (so an API caller
|
||||
// can't write a binding the runtime won't be able to resolve).
|
||||
// Seed two real rows in mate_tool first so the validator considers
|
||||
// the names bindable; the test's intent — idempotent persistence —
|
||||
// is unchanged.
|
||||
seedBuiltinTool("tool_a");
|
||||
seedBuiltinTool("tool_b");
|
||||
List<String> desired = List.of("tool_a", "tool_b");
|
||||
bindingService.setToolBindings(agentId, desired);
|
||||
assertDoesNotThrow(() -> bindingService.setToolBindings(agentId, desired));
|
||||
@ -89,10 +144,18 @@ class AgentBindingServiceTest {
|
||||
assertTrue(names.containsAll(desired));
|
||||
}
|
||||
|
||||
private void seedBuiltinTool(String name) {
|
||||
jdbcTemplate.update(
|
||||
"MERGE INTO mate_tool (id, name, display_name, description, tool_type, bean_name, icon, enabled, builtin, create_time, update_time, deleted) " +
|
||||
"KEY(name) VALUES (?, ?, ?, ?, 'builtin', ?, '🔧', TRUE, TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)",
|
||||
System.nanoTime(), name, name, "test fixture", name);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("setSkillBindings 连续调用两次相同列表不抛异常,状态收敛")
|
||||
void setSkillBindingsIsIdempotent() {
|
||||
List<Long> desired = List.of(7_777_101L, 7_777_102L);
|
||||
desired.forEach(this::seedSkill);
|
||||
bindingService.setSkillBindings(agentId, desired);
|
||||
assertDoesNotThrow(() -> bindingService.setSkillBindings(agentId, desired));
|
||||
|
||||
@ -122,6 +185,7 @@ class AgentBindingServiceTest {
|
||||
@DisplayName("唯一性回归:同一 (agent, skill) 直接 INSERT 第二行仍被唯一索引拦截")
|
||||
void uniqueIndexStillEnforcedForSkill() {
|
||||
long skillId = 7_777_201L;
|
||||
seedSkill(skillId);
|
||||
bindingService.bindSkill(agentId, skillId);
|
||||
|
||||
assertThrows(DuplicateKeyException.class, () ->
|
||||
@ -134,6 +198,94 @@ class AgentBindingServiceTest {
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("bindSkill 拒绝跨 workspace 的真实 skill(防止 tenancy 越界)")
|
||||
void bindSkillRejectsCrossWorkspaceRealSkill() {
|
||||
// Agent lives in workspace 1 (set up in @BeforeEach). Seed a skill
|
||||
// in workspace 2 — the new requireSameWorkspace check should refuse.
|
||||
long otherWorkspaceSkillId = 7_777_301L;
|
||||
seedSkill(otherWorkspaceSkillId, 2L);
|
||||
|
||||
MateClawException ex = assertThrows(MateClawException.class,
|
||||
() -> bindingService.bindSkill(agentId, otherWorkspaceSkillId));
|
||||
assertEquals(403, ex.getCode(), "应返回 403 业务码(跨 workspace 越界)");
|
||||
assertEquals("err.skill.cross_workspace_binding", ex.getMsgKey(),
|
||||
"应使用专用 i18n key,前端可精确分支");
|
||||
|
||||
// No row should have been written before the check failed.
|
||||
Integer count = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM mate_agent_skill WHERE agent_id = ? AND skill_id = ?",
|
||||
Integer.class, agentId, otherWorkspaceSkillId);
|
||||
assertNotNull(count);
|
||||
assertEquals(0, count, "拒绝时不能写入绑定行");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("bindSkill 允许 MCP 虚拟 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<Long> ids = bindingService.getBoundSkillIds(agentId);
|
||||
assertNotNull(ids);
|
||||
assertTrue(ids.contains(virtualMcpId), "MCP virtual binding 应当落到 mate_agent_skill");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("bindSkill 允许同 workspace 的 ACP 虚拟 skill(走 AcpSkillBridge 解析 workspace)")
|
||||
void bindSkillAllowsVirtualAcpSkillSameWorkspace() {
|
||||
long endpointId = 4_242_001L;
|
||||
seedAcpEndpoint(endpointId, 1L); // matches the agent's workspace
|
||||
long virtualAcpId = vip.mate.skill.acp.AcpSkillBridge.VIRTUAL_ID_BASE + endpointId;
|
||||
|
||||
assertDoesNotThrow(() -> bindingService.bindSkill(agentId, virtualAcpId));
|
||||
|
||||
Set<Long> ids = bindingService.getBoundSkillIds(agentId);
|
||||
assertNotNull(ids);
|
||||
assertTrue(ids.contains(virtualAcpId), "ACP virtual binding 应当落到 mate_agent_skill");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("bindSkill 拒绝跨 workspace 的 ACP 虚拟 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<Long> remaining = bindingService.getBoundSkillIds(agentId);
|
||||
assertNotNull(remaining);
|
||||
assertTrue(remaining.contains(goodSkill),
|
||||
"validation 必须在 delete 旧绑定之前完成,否则会留下空绑定状态");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("unbindTool 后 DB 里真的没行(物理 delete,不是软删留 deleted=1)")
|
||||
void unbindPhysicallyRemovesRow() {
|
||||
|
||||
@ -0,0 +1,186 @@
|
||||
package vip.mate.agent.binding;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import vip.mate.agent.binding.model.AgentToolBinding;
|
||||
import vip.mate.agent.binding.repository.AgentProviderPreferenceMapper;
|
||||
import vip.mate.agent.binding.repository.AgentSkillBindingMapper;
|
||||
import vip.mate.agent.binding.repository.AgentToolBindingMapper;
|
||||
import vip.mate.agent.binding.service.AgentBindingService;
|
||||
import vip.mate.agent.repository.AgentMapper;
|
||||
import vip.mate.exception.MateClawException;
|
||||
import vip.mate.skill.acp.AcpSkillBridge;
|
||||
import vip.mate.skill.repository.SkillMapper;
|
||||
import vip.mate.skill.runtime.SkillRuntimeService;
|
||||
import vip.mate.tool.model.AvailableToolDTO;
|
||||
import vip.mate.tool.service.AvailableToolService;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* Unit-level coverage for {@code setToolBindings}'s validation gate —
|
||||
* proves that hand-crafted API requests can't write a tool name the
|
||||
* runtime won't be able to resolve.
|
||||
*/
|
||||
class AgentBindingServiceValidationTest {
|
||||
|
||||
private AgentToolBindingMapper toolBindingMapper;
|
||||
private AvailableToolService availableToolService;
|
||||
private AgentBindingService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
AgentSkillBindingMapper skillBindingMapper = mock(AgentSkillBindingMapper.class);
|
||||
toolBindingMapper = mock(AgentToolBindingMapper.class);
|
||||
AgentProviderPreferenceMapper providerPreferenceMapper = mock(AgentProviderPreferenceMapper.class);
|
||||
SkillRuntimeService skillRuntimeService = mock(SkillRuntimeService.class);
|
||||
availableToolService = mock(AvailableToolService.class);
|
||||
// Tool-binding tests don't exercise the agent/skill workspace lookup,
|
||||
// so empty mocks are enough — the wired-in fields just need to be
|
||||
// non-null for construction.
|
||||
AgentMapper agentMapper = mock(AgentMapper.class);
|
||||
SkillMapper skillMapper = mock(SkillMapper.class);
|
||||
AcpSkillBridge acpSkillBridge = mock(AcpSkillBridge.class);
|
||||
service = new AgentBindingService(
|
||||
skillBindingMapper,
|
||||
toolBindingMapper,
|
||||
providerPreferenceMapper,
|
||||
skillRuntimeService,
|
||||
availableToolService,
|
||||
agentMapper,
|
||||
skillMapper,
|
||||
acpSkillBridge);
|
||||
// No existing binding by default — each test overrides as needed.
|
||||
when(toolBindingMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a known available tool name persists")
|
||||
void availableNameIsAccepted() {
|
||||
when(availableToolService.listAvailable()).thenReturn(List.of(
|
||||
bindable("web_search"),
|
||||
bindable("mcp_42_search_aaaaaa")));
|
||||
|
||||
service.setToolBindings(99L, List.of("mcp_42_search_aaaaaa"));
|
||||
|
||||
ArgumentCaptor<AgentToolBinding> captor = ArgumentCaptor.forClass(AgentToolBinding.class);
|
||||
verify(toolBindingMapper, times(1)).insert(captor.capture());
|
||||
assertEquals("mcp_42_search_aaaaaa", captor.getValue().getToolName());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an unknown name (typo / legacy unprefixed) is refused")
|
||||
void unknownNameIsRejected() {
|
||||
when(availableToolService.listAvailable()).thenReturn(List.of(
|
||||
bindable("mcp_42_search_aaaaaa")));
|
||||
|
||||
MateClawException ex = assertThrows(MateClawException.class,
|
||||
() -> service.setToolBindings(99L, List.of("search_typo")));
|
||||
assertTrue(ex.getMessage().contains("search_typo"),
|
||||
"error should name the rejected tool, got: " + ex.getMessage());
|
||||
// Nothing should have been persisted — validation runs before delete.
|
||||
verify(toolBindingMapper, never()).delete(any());
|
||||
verify(toolBindingMapper, never()).insert(any(AgentToolBinding.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a name marked available=false (e.g. hash collision) is refused")
|
||||
void unavailableNameIsRejected() {
|
||||
AvailableToolDTO collided = AvailableToolDTO.builder()
|
||||
.name("mcp_42_search_aaaaaa")
|
||||
.available(false)
|
||||
.unavailableReason("HASH_COLLISION:other")
|
||||
.build();
|
||||
when(availableToolService.listAvailable()).thenReturn(List.of(collided));
|
||||
|
||||
assertThrows(MateClawException.class,
|
||||
() -> service.setToolBindings(99L, List.of("mcp_42_search_aaaaaa")));
|
||||
verify(toolBindingMapper, never()).delete(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a stale/unavailable name already in the existing binding can be removed (not blocked)")
|
||||
void existingUnbindableCanBeRemoved() {
|
||||
// Existing binding holds a name that has since become unavailable.
|
||||
// The user removes it — passing an empty incoming list. Validation
|
||||
// must NOT block this because the new name set introduces nothing
|
||||
// new to validate.
|
||||
AgentToolBinding existing = new AgentToolBinding();
|
||||
existing.setAgentId(99L);
|
||||
existing.setToolName("mcp_42_search_aaaaaa");
|
||||
existing.setEnabled(true);
|
||||
when(toolBindingMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(existing));
|
||||
when(availableToolService.listAvailable()).thenReturn(List.of()); // tool no longer available
|
||||
|
||||
service.setToolBindings(99L, List.of());
|
||||
|
||||
verify(toolBindingMapper, times(1)).delete(any());
|
||||
verify(toolBindingMapper, never()).insert(any(AgentToolBinding.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("keeping an existing-but-now-stale binding is allowed; adding a NEW unknown is still refused")
|
||||
void mixedKeepAndUnknownAdd() {
|
||||
// Existing has one binding; user tries to keep it AND add a typo.
|
||||
AgentToolBinding existing = new AgentToolBinding();
|
||||
existing.setAgentId(99L);
|
||||
existing.setToolName("mcp_42_search_aaaaaa");
|
||||
existing.setEnabled(true);
|
||||
when(toolBindingMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(existing));
|
||||
// Only a different name is currently available.
|
||||
when(availableToolService.listAvailable()).thenReturn(List.of(
|
||||
bindable("web_search")));
|
||||
|
||||
assertThrows(MateClawException.class,
|
||||
() -> service.setToolBindings(99L, List.of("mcp_42_search_aaaaaa", "typo")));
|
||||
verify(toolBindingMapper, never()).delete(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("blank or null entries in incoming list are rejected")
|
||||
void blankEntriesAreRejected() {
|
||||
when(availableToolService.listAvailable()).thenReturn(List.of(bindable("web_search")));
|
||||
assertThrows(MateClawException.class,
|
||||
() -> service.setToolBindings(99L, java.util.Arrays.asList("web_search", "")));
|
||||
assertThrows(MateClawException.class,
|
||||
() -> service.setToolBindings(99L, java.util.Arrays.asList("web_search", (String) null)));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("AvailableToolService failure: validation refuses any new name (conservative)")
|
||||
void availableServiceFailureIsConservative() {
|
||||
when(availableToolService.listAvailable()).thenThrow(new RuntimeException("picker down"));
|
||||
|
||||
// Existing-only saves still succeed.
|
||||
AgentToolBinding existing = new AgentToolBinding();
|
||||
existing.setAgentId(99L);
|
||||
existing.setToolName("web_search");
|
||||
when(toolBindingMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(existing));
|
||||
service.setToolBindings(99L, List.of("web_search"));
|
||||
verify(toolBindingMapper, times(1)).delete(any());
|
||||
|
||||
// Adding a new one fails fast.
|
||||
assertThrows(MateClawException.class,
|
||||
() -> service.setToolBindings(99L, List.of("web_search", "another")));
|
||||
}
|
||||
|
||||
private static AvailableToolDTO bindable(String name) {
|
||||
return AvailableToolDTO.builder()
|
||||
.name(name)
|
||||
.available(true)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,71 @@
|
||||
package vip.mate.agent.chatmodel;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* RFC-001 (Claude 4.7 contract): {@link AgentAnthropicChatModelBuilder#isClaude47}
|
||||
* must correctly classify the model variants we'll see in production.
|
||||
*
|
||||
* <p>Reference: hermes-agent {@code anthropic_adapter._NO_SAMPLING_PARAMS_SUBSTRINGS}.
|
||||
* Claude 4.7 forbids temperature / top_p / top_k entirely — the builder relies
|
||||
* on this detector to skip those fields rather than letting Anthropic 400.
|
||||
*/
|
||||
class AgentAnthropicChatModelBuilderClaude47Test {
|
||||
|
||||
@Test
|
||||
@DisplayName("isClaude47 detects hyphenated direct-API model names")
|
||||
void detect_hyphenated() {
|
||||
assertTrue(AgentAnthropicChatModelBuilder.isClaude47("claude-opus-4-7"));
|
||||
assertTrue(AgentAnthropicChatModelBuilder.isClaude47("claude-sonnet-4-7"));
|
||||
assertTrue(AgentAnthropicChatModelBuilder.isClaude47("claude-haiku-4-7"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("isClaude47 detects dotted variants (e.g. OpenRouter / mixed dialects)")
|
||||
void detect_dotted() {
|
||||
assertTrue(AgentAnthropicChatModelBuilder.isClaude47("claude-opus-4.7"));
|
||||
assertTrue(AgentAnthropicChatModelBuilder.isClaude47("claude.sonnet.4.7"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("isClaude47 detects OpenRouter-style prefixed model ids")
|
||||
void detect_openrouterPrefix() {
|
||||
assertTrue(AgentAnthropicChatModelBuilder.isClaude47("anthropic/claude-opus-4-7"));
|
||||
assertTrue(AgentAnthropicChatModelBuilder.isClaude47("anthropic/claude-sonnet-4-7"));
|
||||
assertTrue(AgentAnthropicChatModelBuilder.isClaude47("anthropic/claude-opus-4.7"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("isClaude47 ignores 4.5 / 4.6 / 4.0 / 3.x and unrelated names")
|
||||
void detect_negatives() {
|
||||
assertFalse(AgentAnthropicChatModelBuilder.isClaude47("claude-opus-4-6"));
|
||||
assertFalse(AgentAnthropicChatModelBuilder.isClaude47("claude-sonnet-4-5"));
|
||||
assertFalse(AgentAnthropicChatModelBuilder.isClaude47("claude-3-7-sonnet"),
|
||||
"3.7 must not match 4.7");
|
||||
assertFalse(AgentAnthropicChatModelBuilder.isClaude47("claude-3-5-sonnet"));
|
||||
// The "claude" prefix guard prevents non-Anthropic models from spuriously
|
||||
// matching even if they contain "4-7" / "4.7" substrings.
|
||||
assertFalse(AgentAnthropicChatModelBuilder.isClaude47("gpt-4-7"),
|
||||
"Non-Claude models must NOT match — claude prefix guard active");
|
||||
assertFalse(AgentAnthropicChatModelBuilder.isClaude47("nemotron-4-7-instruct"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("isClaude47 null-safe")
|
||||
void detect_nullSafe() {
|
||||
assertFalse(AgentAnthropicChatModelBuilder.isClaude47(null));
|
||||
assertFalse(AgentAnthropicChatModelBuilder.isClaude47(""));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Note: claude-3-7-sonnet correctly distinguished from claude-4-7-*")
|
||||
void detect_3_7_vs_4_7() {
|
||||
// Both contain "-7" but only the second contains "4-7" as a substring.
|
||||
assertFalse(AgentAnthropicChatModelBuilder.isClaude47("claude-3-7-sonnet-20250219"));
|
||||
assertTrue(AgentAnthropicChatModelBuilder.isClaude47("claude-opus-4-7-20260415"),
|
||||
"Date-stamped 4-7 variants must still match");
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,138 @@
|
||||
package vip.mate.agent.chatmodel;
|
||||
|
||||
import io.micrometer.observation.ObservationRegistry;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.ai.anthropic.api.AnthropicApi;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.retry.support.RetryTemplate;
|
||||
import org.springframework.web.client.RestClient;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
import vip.mate.exception.MateClawException;
|
||||
import vip.mate.llm.anthropic.oauth.ClaudeCodeApiHeaders;
|
||||
import vip.mate.llm.anthropic.oauth.ClaudeCodeOAuthService;
|
||||
import vip.mate.llm.anthropic.oauth.ClaudeCodeVersionDetector;
|
||||
import vip.mate.llm.model.ModelProtocol;
|
||||
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* Header-construction + token-fetch coverage for the Claude Code OAuth chat
|
||||
* model builder. Building a real {@link AnthropicApi} doesn't make a network
|
||||
* call (Spring AI defers all I/O to {@code chatCompletionEntity}), so these
|
||||
* tests can exercise the full assembly path without mocking the API client.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class AgentClaudeCodeChatModelBuilderTest {
|
||||
|
||||
@Mock
|
||||
private AgentAnthropicChatModelBuilder anthropicBuilder;
|
||||
|
||||
@Mock
|
||||
private ClaudeCodeOAuthService oauthService;
|
||||
|
||||
private ClaudeCodeApiHeaders apiHeaders;
|
||||
|
||||
private AgentClaudeCodeChatModelBuilder builder;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
// Real ApiHeaders with a stub version detector — the version string
|
||||
// shows up verbatim in User-Agent assertions.
|
||||
ClaudeCodeVersionDetector detector = new ClaudeCodeVersionDetector() {
|
||||
@Override
|
||||
public String get() { return "2.1.114"; }
|
||||
};
|
||||
apiHeaders = new ClaudeCodeApiHeaders(detector);
|
||||
|
||||
builder = new AgentClaudeCodeChatModelBuilder(
|
||||
anthropicBuilder,
|
||||
oauthService,
|
||||
apiHeaders,
|
||||
providerOf(RestClient::builder),
|
||||
providerOf(WebClient::builder),
|
||||
providerOf(() -> ObservationRegistry.NOOP),
|
||||
new com.fasterxml.jackson.databind.ObjectMapper());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("supportedProtocol returns ANTHROPIC_CLAUDE_CODE")
|
||||
void supportedProtocol() {
|
||||
assertEquals(ModelProtocol.ANTHROPIC_CLAUDE_CODE, builder.supportedProtocol());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("buildOauthAnthropicApi accepts a token and produces a non-null AnthropicApi")
|
||||
void buildOauthAnthropicApi_returnsClient() {
|
||||
// Sanity check: the NoopApiKey path passes Spring AI's notNull assertion
|
||||
// and the OAuth headers attach without throwing. If this test ever
|
||||
// fails, the most likely cause is a Spring AI upgrade tightening the
|
||||
// ApiKey contract — see AgentClaudeCodeChatModelBuilder javadoc.
|
||||
AnthropicApi api = builder.buildOauthAnthropicApi("sk-ant-oat01-test-token");
|
||||
assertNotNull(api);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("build delegates to oauthService and reuses anthropicBuilder.buildAnthropicOptions")
|
||||
void build_invokesOauthAndReusesOptions() {
|
||||
when(oauthService.getValidToken()).thenReturn("tok-123");
|
||||
// anthropicBuilder.buildAnthropicOptions returns a real options object —
|
||||
// we don't need a strict comparison, just that it gets invoked once and
|
||||
// its result is fed through.
|
||||
when(anthropicBuilder.buildAnthropicOptions(any()))
|
||||
.thenReturn(org.springframework.ai.anthropic.AnthropicChatOptions.builder().build());
|
||||
|
||||
var result = builder.build(new vip.mate.llm.model.ModelConfigEntity(), null,
|
||||
RetryTemplate.defaultInstance());
|
||||
assertNotNull(result);
|
||||
verify(oauthService, times(1)).getValidToken();
|
||||
verify(anthropicBuilder, times(1)).buildAnthropicOptions(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("build propagates OAuth errors without calling buildAnthropicOptions")
|
||||
void build_propagatesOauthErrors() {
|
||||
// Simulates "no Claude Code on disk" — caller surface is the same
|
||||
// MateClawException so the global handler can format the i18n message.
|
||||
when(oauthService.getValidToken()).thenThrow(new MateClawException(
|
||||
"err.anthropic.no_claude_code", "no creds"));
|
||||
|
||||
assertThrows(MateClawException.class,
|
||||
() -> builder.build(new vip.mate.llm.model.ModelConfigEntity(), null, null));
|
||||
// anthropicBuilder shouldn't have been touched — short-circuit before
|
||||
// it would have wasted a buildAnthropicOptions call.
|
||||
verify(anthropicBuilder, never()).buildAnthropicOptions(any());
|
||||
}
|
||||
|
||||
/* ----- ObjectProvider test helper ----- */
|
||||
|
||||
/** Minimal {@link ObjectProvider} that defers to a {@link Supplier} for {@code getIfAvailable}. */
|
||||
@SuppressWarnings("unchecked")
|
||||
private static <T> ObjectProvider<T> providerOf(Supplier<T> supplier) {
|
||||
ObjectProvider<T> mock = mock(ObjectProvider.class);
|
||||
// Use lenient — not every test triggers a getIfAvailable call (e.g.
|
||||
// the supportedProtocol test takes a short path), and the strict
|
||||
// default would fail with UnnecessaryStubbingException.
|
||||
lenient().when(mock.getIfAvailable(any(Supplier.class))).thenAnswer(inv -> {
|
||||
Supplier<T> fallback = inv.getArgument(0);
|
||||
T v = supplier.get();
|
||||
return v != null ? v : fallback.get();
|
||||
});
|
||||
return mock;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,359 @@
|
||||
package vip.mate.agent.chatmodel;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.ai.anthropic.AnthropicChatOptions;
|
||||
import org.springframework.ai.chat.messages.AssistantMessage;
|
||||
import org.springframework.ai.chat.messages.Message;
|
||||
import org.springframework.ai.chat.messages.SystemMessage;
|
||||
import org.springframework.ai.chat.messages.UserMessage;
|
||||
import org.springframework.ai.chat.model.ChatModel;
|
||||
import org.springframework.ai.chat.model.ChatResponse;
|
||||
import org.springframework.ai.chat.model.Generation;
|
||||
import org.springframework.ai.chat.prompt.Prompt;
|
||||
import org.springframework.ai.tool.ToolCallback;
|
||||
import org.springframework.ai.chat.model.ToolContext;
|
||||
import org.springframework.ai.tool.definition.DefaultToolDefinition;
|
||||
import org.springframework.ai.tool.definition.ToolDefinition;
|
||||
import reactor.core.publisher.Flux;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* Verifies the OAuth-mode prompt rewriting that prevents Anthropic's edge
|
||||
* from rate-limiting MateClaw traffic. Each test corresponds to one of the
|
||||
* transforms hermes-agent applies on {@code is_oauth=True} requests.
|
||||
*/
|
||||
class ClaudeCodeIdentityChatModelDecoratorTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("transform prepends Claude Code identity as its own SystemMessage before the original")
|
||||
void transform_prependsToExistingSystem() {
|
||||
ClaudeCodeIdentityChatModelDecorator d = new ClaudeCodeIdentityChatModelDecorator(noopDelegate());
|
||||
Prompt input = new Prompt(List.of(
|
||||
new SystemMessage("You are a helpful coding assistant."),
|
||||
new UserMessage("hi")));
|
||||
Prompt result = d.transform(input);
|
||||
|
||||
// RFC-062: identity must be its OWN system block (not merged into one string)
|
||||
// — Anthropic's OAuth anti-abuse gate 429s the merged form, accepts the array form.
|
||||
SystemMessage identity = (SystemMessage) result.getInstructions().get(0);
|
||||
assertEquals(ClaudeCodeIdentityChatModelDecorator.CLAUDE_CODE_SYSTEM_PREFIX, identity.getText());
|
||||
SystemMessage body = (SystemMessage) result.getInstructions().get(1);
|
||||
assertTrue(body.getText().contains("helpful coding assistant"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("transform inserts a system message when none was present")
|
||||
void transform_insertsSystemWhenAbsent() {
|
||||
ClaudeCodeIdentityChatModelDecorator d = new ClaudeCodeIdentityChatModelDecorator(noopDelegate());
|
||||
Prompt input = new Prompt(List.of(new UserMessage("hello")));
|
||||
Prompt result = d.transform(input);
|
||||
|
||||
// First message must be a system message with just the identity prefix —
|
||||
// hermes-agent does the same: system = [cc_block] when none was supplied.
|
||||
Message first = result.getInstructions().get(0);
|
||||
assertTrue(first instanceof SystemMessage);
|
||||
assertEquals(ClaudeCodeIdentityChatModelDecorator.CLAUDE_CODE_SYSTEM_PREFIX,
|
||||
((SystemMessage) first).getText());
|
||||
// User message is preserved at index 1.
|
||||
assertTrue(result.getInstructions().get(1) instanceof UserMessage);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("transform is idempotent — second pass doesn't double-prefix")
|
||||
void transform_idempotent() {
|
||||
// Defends against accidental double-wrapping (e.g. nested decorators or
|
||||
// a re-issue of the same Prompt). hermes-agent doesn't have this concern
|
||||
// because its rewrite happens in one place; we keep this guard so the
|
||||
// identity prefix doesn't compound to "You are Claude Code...You are Claude Code...".
|
||||
ClaudeCodeIdentityChatModelDecorator d = new ClaudeCodeIdentityChatModelDecorator(noopDelegate());
|
||||
Prompt original = new Prompt(List.of(new SystemMessage("Body"), new UserMessage("hi")));
|
||||
Prompt once = d.transform(original);
|
||||
Prompt twice = d.transform(once);
|
||||
|
||||
SystemMessage sys = (SystemMessage) twice.getInstructions().get(0);
|
||||
// Identity should appear exactly once.
|
||||
int firstIdx = sys.getText().indexOf(ClaudeCodeIdentityChatModelDecorator.CLAUDE_CODE_SYSTEM_PREFIX);
|
||||
int secondIdx = sys.getText().indexOf(
|
||||
ClaudeCodeIdentityChatModelDecorator.CLAUDE_CODE_SYSTEM_PREFIX, firstIdx + 1);
|
||||
assertTrue(firstIdx >= 0 && secondIdx == -1,
|
||||
"Identity prefix must appear exactly once even after multiple transform passes");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("sanitizeBranding replaces MateClaw references")
|
||||
void sanitizeBranding_replacesProductNames() {
|
||||
// Anthropic's content filter flags self-contradicting identity claims —
|
||||
// a "You are Claude Code" prefix followed by a body that says "You are
|
||||
// MateClaw" trips the filter. Strip the conflicting brand.
|
||||
String sanitized = ClaudeCodeIdentityChatModelDecorator.sanitizeBranding(
|
||||
"You are MateClaw, built on mateclaw");
|
||||
assertEquals("You are Claude Code, built on claude-code", sanitized);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("sanitizeBranding tolerates empty / null input")
|
||||
void sanitizeBranding_nullSafe() {
|
||||
// Defensive — a prompt with no system text shouldn't NPE here.
|
||||
assertEquals("", ClaudeCodeIdentityChatModelDecorator.sanitizeBranding(""));
|
||||
assertEquals(null, ClaudeCodeIdentityChatModelDecorator.sanitizeBranding(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("transform preserves chat options (temperature, model, etc.)")
|
||||
void transform_preservesOptions() {
|
||||
// Spring AI's AnthropicChatOptions carry critical per-request state
|
||||
// (max_tokens, thinking budget, cache_control). Losing them on rewrite
|
||||
// would silently break Claude 4.7 thinking mode.
|
||||
var options = org.springframework.ai.anthropic.AnthropicChatOptions.builder()
|
||||
.model("claude-opus-4-7").maxTokens(1234).build();
|
||||
ClaudeCodeIdentityChatModelDecorator d = new ClaudeCodeIdentityChatModelDecorator(noopDelegate());
|
||||
Prompt input = new Prompt(List.of(new UserMessage("hi")), options);
|
||||
Prompt result = d.transform(input);
|
||||
|
||||
var resultOpts = (org.springframework.ai.anthropic.AnthropicChatOptions) result.getOptions();
|
||||
assertEquals("claude-opus-4-7", resultOpts.getModel());
|
||||
assertEquals(1234, resultOpts.getMaxTokens());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("call delegates the rewritten prompt downstream")
|
||||
void call_delegatesRewritten() {
|
||||
// Sanity: the prompt that reaches the underlying ChatModel must be the
|
||||
// rewritten one, not the original — otherwise the decorator is dead code.
|
||||
AtomicReference<Prompt> captured = new AtomicReference<>();
|
||||
ChatModel capturing = new TestDelegate() {
|
||||
@Override
|
||||
public ChatResponse call(Prompt prompt) {
|
||||
captured.set(prompt);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
ClaudeCodeIdentityChatModelDecorator d = new ClaudeCodeIdentityChatModelDecorator(capturing);
|
||||
d.call(new Prompt(List.of(new UserMessage("hi"))));
|
||||
|
||||
assertNotNull(captured.get());
|
||||
Message first = captured.get().getInstructions().get(0);
|
||||
assertTrue(first instanceof SystemMessage);
|
||||
assertEquals(ClaudeCodeIdentityChatModelDecorator.CLAUDE_CODE_SYSTEM_PREFIX,
|
||||
((SystemMessage) first).getText());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("transform leaves non-system messages untouched")
|
||||
void transform_preservesUserAndAssistantMessages() {
|
||||
ClaudeCodeIdentityChatModelDecorator d = new ClaudeCodeIdentityChatModelDecorator(noopDelegate());
|
||||
Prompt input = new Prompt(List.of(
|
||||
new SystemMessage("be helpful"),
|
||||
new UserMessage("question 1"),
|
||||
new AssistantMessage("answer 1"),
|
||||
new UserMessage("question 2")));
|
||||
Prompt result = d.transform(input);
|
||||
|
||||
// RFC-062: system splits into [identity, sanitized body] so user/assistant
|
||||
// shift to indices 2, 3, 4. Their content is the original instance — a copy
|
||||
// here would force Spring AI to re-encode multimodal content (images,
|
||||
// tool_results) for no benefit.
|
||||
assertTrue(result.getInstructions().get(2) instanceof UserMessage);
|
||||
assertEquals("question 1", ((UserMessage) result.getInstructions().get(2)).getText());
|
||||
assertEquals("answer 1", ((AssistantMessage) result.getInstructions().get(3)).getText());
|
||||
assertEquals("question 2", ((UserMessage) result.getInstructions().get(4)).getText());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("transform wraps tool callbacks so getToolDefinition().name() returns mcp_<orig>")
|
||||
void transform_prefixesOutgoingToolNames() {
|
||||
// Anthropic's anti-abuse path inspects tool definitions; tools without
|
||||
// the mcp_ prefix on a request claiming Claude Code identity get the
|
||||
// request rate-limited (429 with body "Error"). Ensure we wrap.
|
||||
ToolCallback search = stubToolCallback("search", "Search the web");
|
||||
ToolCallback createFile = stubToolCallback("createFile", "Create a file");
|
||||
AnthropicChatOptions opts = AnthropicChatOptions.builder()
|
||||
.toolCallbacks(List.of(search, createFile))
|
||||
.build();
|
||||
|
||||
ClaudeCodeIdentityChatModelDecorator d = new ClaudeCodeIdentityChatModelDecorator(noopDelegate());
|
||||
Prompt result = d.transform(new Prompt(List.of(new UserMessage("hi")), opts));
|
||||
|
||||
AnthropicChatOptions resOpts = (AnthropicChatOptions) result.getOptions();
|
||||
List<ToolCallback> wrapped = resOpts.getToolCallbacks();
|
||||
assertEquals(2, wrapped.size());
|
||||
assertEquals("mcp_search", wrapped.get(0).getToolDefinition().name());
|
||||
assertEquals("mcp_createFile", wrapped.get(1).getToolDefinition().name());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("PrefixedToolCallback forwards call() to the underlying tool unchanged")
|
||||
void prefixedToolCallback_forwardsCall() {
|
||||
// Critical contract: prefixing happens on the wire, but MateClaw's tool
|
||||
// implementation must still receive the original argument string and
|
||||
// return the original output verbatim. If this fails, every tool
|
||||
// execution under OAuth would silently mis-route.
|
||||
AtomicReference<String> capturedInput = new AtomicReference<>();
|
||||
ToolCallback underlying = new ToolCallback() {
|
||||
@Override
|
||||
public ToolDefinition getToolDefinition() {
|
||||
return DefaultToolDefinition.builder().name("search").description("d").inputSchema("{}").build();
|
||||
}
|
||||
@Override
|
||||
public String call(String input) {
|
||||
capturedInput.set(input);
|
||||
return "search-output";
|
||||
}
|
||||
};
|
||||
var wrapped = new ClaudeCodeIdentityChatModelDecorator.PrefixedToolCallback(underlying);
|
||||
String out = wrapped.call("{\"q\":\"test\"}");
|
||||
assertEquals("search-output", out);
|
||||
assertEquals("{\"q\":\"test\"}", capturedInput.get());
|
||||
assertEquals("mcp_search", wrapped.getToolDefinition().name());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("PrefixedToolCallback is idempotent — double-wrap doesn't double-prefix")
|
||||
void prefixedToolCallback_idempotent() {
|
||||
// Defends against accidental nested decoration. A wrapped wrapper
|
||||
// should still expose mcp_search, not mcp_mcp_search.
|
||||
ToolCallback underlying = stubToolCallback("search", "d");
|
||||
var once = new ClaudeCodeIdentityChatModelDecorator.PrefixedToolCallback(underlying);
|
||||
var twice = new ClaudeCodeIdentityChatModelDecorator.PrefixedToolCallback(once);
|
||||
assertEquals("mcp_search", once.getToolDefinition().name());
|
||||
assertEquals("mcp_search", twice.getToolDefinition().name());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("stripToolPrefixes removes mcp_ from response tool_use names")
|
||||
void stripToolPrefixes_responseSide() {
|
||||
// Claude returns tool_use with name="mcp_search" (because we prefixed
|
||||
// the definition); MateClaw's tool registry only knows "search" so
|
||||
// the prefix must come off before the response leaves the decorator.
|
||||
AssistantMessage am = AssistantMessage.builder()
|
||||
.content("calling search")
|
||||
.toolCalls(List.of(
|
||||
new AssistantMessage.ToolCall("call_1", "function", "mcp_search",
|
||||
"{\"q\":\"foo\"}"),
|
||||
new AssistantMessage.ToolCall("call_2", "function", "mcp_createFile",
|
||||
"{\"path\":\"x\"}")))
|
||||
.build();
|
||||
ChatResponse response = new ChatResponse(List.of(new Generation(am)));
|
||||
|
||||
ClaudeCodeIdentityChatModelDecorator d = new ClaudeCodeIdentityChatModelDecorator(noopDelegate());
|
||||
ChatResponse stripped = d.stripToolPrefixes(response);
|
||||
|
||||
AssistantMessage out = stripped.getResult().getOutput();
|
||||
assertEquals("search", out.getToolCalls().get(0).name());
|
||||
assertEquals("createFile", out.getToolCalls().get(1).name());
|
||||
// ID + arguments must pass through untouched — losing the call ID
|
||||
// would break Anthropic's tool_result correlation on next turn.
|
||||
assertEquals("call_1", out.getToolCalls().get(0).id());
|
||||
assertEquals("{\"q\":\"foo\"}", out.getToolCalls().get(0).arguments());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("stripToolPrefixes returns input unchanged when no tool_use blocks present")
|
||||
void stripToolPrefixes_noToolCalls_passthrough() {
|
||||
// Optimization: don't allocate a new list/Generation when there's
|
||||
// nothing to rewrite. Verify identity-equality for the trivial case.
|
||||
ChatResponse response = new ChatResponse(List.of(
|
||||
new Generation(new AssistantMessage("just text"))));
|
||||
ClaudeCodeIdentityChatModelDecorator d = new ClaudeCodeIdentityChatModelDecorator(noopDelegate());
|
||||
ChatResponse out = d.stripToolPrefixes(response);
|
||||
assertTrue(out == response, "no-op rewrite should return the same instance");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("transform re-prefixes tool_use names in AssistantMessage history")
|
||||
void transform_reprefixesHistoryToolUse() {
|
||||
// Prior turn: Claude called mcp_search → we stripped to "search" before
|
||||
// storing → next request must re-prepend mcp_ so Anthropic's history
|
||||
// matches its own prior tool_use block. Otherwise Anthropic's
|
||||
// tool_use_id correlation breaks and you get "tool_use without
|
||||
// matching tool_result" 400s.
|
||||
AssistantMessage history = AssistantMessage.builder()
|
||||
.content("")
|
||||
.toolCalls(List.of(
|
||||
new AssistantMessage.ToolCall("call_1", "function", "search",
|
||||
"{\"q\":\"foo\"}")))
|
||||
.build();
|
||||
|
||||
ClaudeCodeIdentityChatModelDecorator d = new ClaudeCodeIdentityChatModelDecorator(noopDelegate());
|
||||
Prompt result = d.transform(new Prompt(List.of(
|
||||
new SystemMessage("be helpful"),
|
||||
history,
|
||||
new UserMessage("now do that"))));
|
||||
|
||||
// RFC-062: system splits into [identity, sanitized body] so AssistantMessage
|
||||
// history shifts to index 2.
|
||||
AssistantMessage rewrittenHistory = (AssistantMessage) result.getInstructions().get(2);
|
||||
assertEquals("mcp_search", rewrittenHistory.getToolCalls().get(0).name());
|
||||
// ID stays the same so tool_result correlation chains through.
|
||||
assertEquals("call_1", rewrittenHistory.getToolCalls().get(0).id());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("call delegates rewritten prompt and strips response prefixes end-to-end")
|
||||
void call_endToEnd() {
|
||||
// Integration: outgoing prompt should have prefixed tool names, and
|
||||
// the AssistantMessage we return should come back unprefixed. Mirrors
|
||||
// what ReasoningNode would observe per turn.
|
||||
ToolCallback tool = stubToolCallback("search", "search");
|
||||
AnthropicChatOptions opts = AnthropicChatOptions.builder()
|
||||
.toolCallbacks(List.of(tool)).build();
|
||||
|
||||
AtomicReference<Prompt> capturedPrompt = new AtomicReference<>();
|
||||
ChatModel delegate = new TestDelegate() {
|
||||
@Override
|
||||
public ChatResponse call(Prompt prompt) {
|
||||
capturedPrompt.set(prompt);
|
||||
AssistantMessage am = AssistantMessage.builder()
|
||||
.content("")
|
||||
.toolCalls(List.of(new AssistantMessage.ToolCall(
|
||||
"call_x", "function", "mcp_search", "{}")))
|
||||
.build();
|
||||
return new ChatResponse(List.of(new Generation(am)));
|
||||
}
|
||||
};
|
||||
ClaudeCodeIdentityChatModelDecorator d = new ClaudeCodeIdentityChatModelDecorator(delegate);
|
||||
ChatResponse out = d.call(new Prompt(List.of(new UserMessage("hi")), opts));
|
||||
|
||||
AnthropicChatOptions sentOpts = (AnthropicChatOptions) capturedPrompt.get().getOptions();
|
||||
assertEquals("mcp_search", sentOpts.getToolCallbacks().get(0).getToolDefinition().name(),
|
||||
"outgoing tool name must be prefixed");
|
||||
assertEquals("search", out.getResult().getOutput().getToolCalls().get(0).name(),
|
||||
"incoming tool name must be stripped");
|
||||
// Sanity — name must round-trip differently from the wire format.
|
||||
assertNotEquals("mcp_search", out.getResult().getOutput().getToolCalls().get(0).name());
|
||||
}
|
||||
|
||||
/* ----- Test helpers ----- */
|
||||
|
||||
private static ChatModel noopDelegate() {
|
||||
return new TestDelegate();
|
||||
}
|
||||
|
||||
private static ToolCallback stubToolCallback(String name, String description) {
|
||||
return new ToolCallback() {
|
||||
@Override
|
||||
public ToolDefinition getToolDefinition() {
|
||||
return DefaultToolDefinition.builder()
|
||||
.name(name).description(description).inputSchema("{}").build();
|
||||
}
|
||||
@Override
|
||||
public String call(String input) { return "ok"; }
|
||||
};
|
||||
}
|
||||
|
||||
/** Minimal ChatModel that returns null/empty — sufficient for transform-only tests. */
|
||||
private static class TestDelegate implements ChatModel {
|
||||
@Override
|
||||
public ChatResponse call(Prompt prompt) { return null; }
|
||||
@Override
|
||||
public Flux<ChatResponse> stream(Prompt prompt) { return Flux.empty(); }
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,237 @@
|
||||
package vip.mate.agent.chatmodel;
|
||||
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.ai.chat.messages.AssistantMessage;
|
||||
import org.springframework.ai.chat.messages.Message;
|
||||
import org.springframework.ai.chat.messages.UserMessage;
|
||||
import org.springframework.ai.chat.model.ChatModel;
|
||||
import org.springframework.ai.chat.model.ChatResponse;
|
||||
import org.springframework.ai.chat.prompt.Prompt;
|
||||
import org.springframework.ai.openai.OpenAiChatOptions;
|
||||
import reactor.core.publisher.Flux;
|
||||
import vip.mate.agent.ThinkingLevelHolder;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* Validates the per-request payload patches DeepSeek V4 requires.
|
||||
*
|
||||
* <p>Two independent invariants are tested separately because they're each
|
||||
* easy to silently break:
|
||||
* <ul>
|
||||
* <li>{@code extraBody.thinking} + {@code reasoning_effort} on the options.</li>
|
||||
* <li>{@code reasoning_content} on prior assistant tool-call messages
|
||||
* (ensure-when-enabled / strip-when-disabled).</li>
|
||||
* </ul>
|
||||
*/
|
||||
class DeepSeekV4ThinkingDecoratorTest {
|
||||
|
||||
private DeepSeekV4ThinkingDecorator decorator;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
decorator = new DeepSeekV4ThinkingDecorator(new NoopChatModel());
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void clearHolder() {
|
||||
ThinkingLevelHolder.clear();
|
||||
}
|
||||
|
||||
/* =================================================================== */
|
||||
/* Options patching */
|
||||
/* =================================================================== */
|
||||
|
||||
@Test
|
||||
@DisplayName("thinking=high → extraBody.thinking={type:enabled} + reasoning_effort=high")
|
||||
void thinkingHigh_injectsEnabledAndHighEffort() {
|
||||
ThinkingLevelHolder.set("high");
|
||||
OpenAiChatOptions opts = OpenAiChatOptions.builder().model("deepseek-v4-flash").build();
|
||||
Prompt result = decorator.transform(new Prompt(List.of(new UserMessage("hi")), opts));
|
||||
|
||||
OpenAiChatOptions out = (OpenAiChatOptions) result.getOptions();
|
||||
assertEquals("high", out.getReasoningEffort());
|
||||
assertNotNull(out.getExtraBody());
|
||||
Object thinking = out.getExtraBody().get("thinking");
|
||||
assertEquals(Map.of("type", "enabled"), thinking,
|
||||
"thinking field must be the exact {type: enabled} shape DeepSeek expects");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("thinking=off → extraBody.thinking={type:disabled} + reasoning_effort cleared")
|
||||
void thinkingOff_clearsEffortAndDisablesThinking() {
|
||||
// Critical: when thinking is disabled, BOTH fields must change. Leaving
|
||||
// a stale reasoning_effort while flipping thinking off causes DeepSeek
|
||||
// to 400 with "thinking and reasoning_effort cannot coexist when disabled".
|
||||
ThinkingLevelHolder.set("off");
|
||||
OpenAiChatOptions opts = OpenAiChatOptions.builder()
|
||||
.model("deepseek-v4-pro")
|
||||
.reasoningEffort("medium") // pre-set, must be cleared
|
||||
.build();
|
||||
Prompt result = decorator.transform(new Prompt(List.of(new UserMessage("hi")), opts));
|
||||
|
||||
OpenAiChatOptions out = (OpenAiChatOptions) result.getOptions();
|
||||
assertNull(out.getReasoningEffort(), "reasoning_effort must be cleared when thinking is off");
|
||||
assertEquals(Map.of("type", "disabled"), out.getExtraBody().get(DeepSeekV4ThinkingDecorator.THINKING_FIELD));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Existing extraBody entries are preserved when patching")
|
||||
void extraBody_preservesExistingEntries() {
|
||||
// Defends against a copy-and-replace bug where the patch overwrites the
|
||||
// whole map. Other extra-body fields (e.g. provider-specific knobs) must
|
||||
// survive — losing them silently would break unrelated features.
|
||||
ThinkingLevelHolder.set("low");
|
||||
Map<String, Object> seed = new HashMap<>();
|
||||
seed.put("custom_knob", 42);
|
||||
OpenAiChatOptions opts = OpenAiChatOptions.builder()
|
||||
.model("deepseek-v4-flash").build();
|
||||
opts.setExtraBody(seed);
|
||||
|
||||
Prompt result = decorator.transform(new Prompt(List.of(new UserMessage("hi")), opts));
|
||||
OpenAiChatOptions out = (OpenAiChatOptions) result.getOptions();
|
||||
assertEquals(42, out.getExtraBody().get("custom_knob"));
|
||||
assertNotNull(out.getExtraBody().get(DeepSeekV4ThinkingDecorator.THINKING_FIELD));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("mapEffort: low/medium/high passthrough; max collapses to high; unknown → medium")
|
||||
void mapEffort_levels() {
|
||||
// openclaw resolveDeepSeekV4ReasoningEffort folds "max" into "high"
|
||||
// because DeepSeek doesn't expose a max tier. Pin both ends of the rule.
|
||||
assertEquals("low", DeepSeekV4ThinkingDecorator.mapEffort("low"));
|
||||
assertEquals("medium", DeepSeekV4ThinkingDecorator.mapEffort("medium"));
|
||||
assertEquals("high", DeepSeekV4ThinkingDecorator.mapEffort("high"));
|
||||
assertEquals("high", DeepSeekV4ThinkingDecorator.mapEffort("max"));
|
||||
assertEquals("medium", DeepSeekV4ThinkingDecorator.mapEffort("xhigh"));
|
||||
assertEquals("medium", DeepSeekV4ThinkingDecorator.mapEffort(null));
|
||||
}
|
||||
|
||||
/* =================================================================== */
|
||||
/* Message patching */
|
||||
/* =================================================================== */
|
||||
|
||||
@Test
|
||||
@DisplayName("enabled + tool-call history → reasoning_content key ensured (empty string)")
|
||||
void messages_enabled_ensuresReasoningContent() {
|
||||
// V4 replay contract: every prior assistant tool-call message must have
|
||||
// a reasoning_content (empty allowed). Missing it returns an obscure 400
|
||||
// about "reasoning_content required for thinking-enabled tool replay".
|
||||
AssistantMessage am = AssistantMessage.builder()
|
||||
.content("")
|
||||
.toolCalls(List.of(
|
||||
new AssistantMessage.ToolCall("call_1", "function", "search", "{}")))
|
||||
.build();
|
||||
List<Message> patched = DeepSeekV4ThinkingDecorator.patchMessages(List.of(am), true);
|
||||
|
||||
AssistantMessage out = (AssistantMessage) patched.get(0);
|
||||
assertTrue(out.getMetadata().containsKey(DeepSeekV4ThinkingDecorator.REASONING_CONTENT_KEY));
|
||||
assertEquals("", out.getMetadata().get(DeepSeekV4ThinkingDecorator.REASONING_CONTENT_KEY));
|
||||
// Tool calls must pass through unchanged — losing the call ID would
|
||||
// break the next turn's tool_result correlation.
|
||||
assertEquals("call_1", out.getToolCalls().get(0).id());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("enabled + already-has reasoning_content → no rewrite (fast path)")
|
||||
void messages_enabled_noRewriteWhenAlreadyPresent() {
|
||||
Map<String, Object> meta = new HashMap<>();
|
||||
meta.put(DeepSeekV4ThinkingDecorator.REASONING_CONTENT_KEY, "prev thinking");
|
||||
AssistantMessage am = AssistantMessage.builder()
|
||||
.content("answer")
|
||||
.properties(meta)
|
||||
.toolCalls(List.of(
|
||||
new AssistantMessage.ToolCall("call_2", "function", "search", "{}")))
|
||||
.build();
|
||||
List<Message> patched = DeepSeekV4ThinkingDecorator.patchMessages(List.of(am), true);
|
||||
|
||||
// Identity equality — fast path returns the same instance to avoid pointless allocation.
|
||||
assertTrue(patched.get(0) == am, "no-op rewrite should return the same instance");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("disabled → reasoning_content stripped from prior messages")
|
||||
void messages_disabled_stripsReasoningContent() {
|
||||
// DeepSeek echoes prior reasoning_content back into the response when
|
||||
// thinking is disabled, polluting the user-visible answer. Stripping is
|
||||
// not optional.
|
||||
Map<String, Object> meta = new HashMap<>();
|
||||
meta.put(DeepSeekV4ThinkingDecorator.REASONING_CONTENT_KEY, "old thinking");
|
||||
meta.put("other_meta", "preserved");
|
||||
AssistantMessage am = AssistantMessage.builder()
|
||||
.content("answer")
|
||||
.properties(meta)
|
||||
.build();
|
||||
|
||||
List<Message> patched = DeepSeekV4ThinkingDecorator.patchMessages(List.of(am), false);
|
||||
AssistantMessage out = (AssistantMessage) patched.get(0);
|
||||
assertFalse(out.getMetadata().containsKey(DeepSeekV4ThinkingDecorator.REASONING_CONTENT_KEY),
|
||||
"reasoning_content must be removed");
|
||||
assertEquals("preserved", out.getMetadata().get("other_meta"),
|
||||
"Other metadata keys must survive the strip");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("disabled + no reasoning_content → no-op pass-through")
|
||||
void messages_disabled_noOpWhenAbsent() {
|
||||
AssistantMessage am = new AssistantMessage("plain answer");
|
||||
List<Message> patched = DeepSeekV4ThinkingDecorator.patchMessages(List.of(am), false);
|
||||
assertTrue(patched.get(0) == am, "no-op rewrite should return the same instance");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Non-assistant messages pass through untouched")
|
||||
void messages_userPassesThrough() {
|
||||
// patchMessages must only touch AssistantMessage. UserMessage / ToolMessage
|
||||
// / SystemMessage carry meaning the decorator has no business modifying.
|
||||
UserMessage user = new UserMessage("question");
|
||||
List<Message> patched = DeepSeekV4ThinkingDecorator.patchMessages(List.of(user), true);
|
||||
assertTrue(patched.get(0) == user);
|
||||
}
|
||||
|
||||
/* =================================================================== */
|
||||
/* End-to-end delegate */
|
||||
/* =================================================================== */
|
||||
|
||||
@Test
|
||||
@DisplayName("call() delegates the patched prompt to the underlying ChatModel")
|
||||
void call_delegatesPatched() {
|
||||
// Sanity: the prompt that reaches the underlying model carries the
|
||||
// patched options/messages, not the originals.
|
||||
AtomicReference<Prompt> captured = new AtomicReference<>();
|
||||
DeepSeekV4ThinkingDecorator d = new DeepSeekV4ThinkingDecorator(new NoopChatModel() {
|
||||
@Override public ChatResponse call(Prompt prompt) {
|
||||
captured.set(prompt);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
ThinkingLevelHolder.set("medium");
|
||||
OpenAiChatOptions opts = OpenAiChatOptions.builder().model("deepseek-v4-flash").build();
|
||||
d.call(new Prompt(List.of(new UserMessage("hi")), opts));
|
||||
|
||||
assertNotNull(captured.get());
|
||||
OpenAiChatOptions sentOpts = (OpenAiChatOptions) captured.get().getOptions();
|
||||
assertEquals("medium", sentOpts.getReasoningEffort());
|
||||
assertEquals(Map.of("type", "enabled"),
|
||||
sentOpts.getExtraBody().get(DeepSeekV4ThinkingDecorator.THINKING_FIELD));
|
||||
}
|
||||
|
||||
/* ---------- Test double ---------- */
|
||||
|
||||
private static class NoopChatModel implements ChatModel {
|
||||
@Override public ChatResponse call(Prompt prompt) { return null; }
|
||||
@Override public Flux<ChatResponse> stream(Prompt prompt) { return Flux.empty(); }
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,63 @@
|
||||
package vip.mate.agent.context;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.ai.chat.messages.Message;
|
||||
import org.springframework.ai.chat.messages.ToolResponseMessage;
|
||||
import org.springframework.ai.chat.messages.UserMessage;
|
||||
import vip.mate.config.ConversationWindowProperties;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class ConversationWindowManagerToolPruningTest {
|
||||
|
||||
@Test
|
||||
void prunesOlderToolResultsAndKeepsLatestFullResult() {
|
||||
ConversationWindowManager manager = new ConversationWindowManager(
|
||||
new ConversationWindowProperties(), null, null);
|
||||
String oldLarge = "old-result\n".repeat(700);
|
||||
String latestLarge = "latest-result\n".repeat(700);
|
||||
List<Message> messages = List.of(
|
||||
new UserMessage("read earlier file"),
|
||||
toolMessage("old-1", "read_file", oldLarge),
|
||||
new UserMessage("read latest file"),
|
||||
toolMessage("new-1", "read_file", latestLarge)
|
||||
);
|
||||
|
||||
List<Message> pruned = manager.pruneOldToolResultsForModelInput(messages);
|
||||
|
||||
ToolResponseMessage oldToolMessage = (ToolResponseMessage) pruned.get(1);
|
||||
ToolResponseMessage latestToolMessage = (ToolResponseMessage) pruned.get(3);
|
||||
String oldData = oldToolMessage.getResponses().getFirst().responseData();
|
||||
String latestData = latestToolMessage.getResponses().getFirst().responseData();
|
||||
|
||||
assertTrue(oldData.contains("previous tool output summarized"));
|
||||
assertTrue(oldData.length() < 300);
|
||||
assertEquals(latestLarge, latestData);
|
||||
}
|
||||
|
||||
@Test
|
||||
void olderDuplicateToolResultUsesDuplicatePlaceholder() {
|
||||
ConversationWindowManager manager = new ConversationWindowManager(
|
||||
new ConversationWindowProperties(), null, null);
|
||||
String repeated = "same-output\n".repeat(700);
|
||||
List<Message> messages = List.of(
|
||||
toolMessage("old-1", "read_file", repeated),
|
||||
toolMessage("new-1", "read_file", repeated)
|
||||
);
|
||||
|
||||
List<Message> pruned = manager.pruneOldToolResultsForModelInput(messages);
|
||||
|
||||
ToolResponseMessage oldToolMessage = (ToolResponseMessage) pruned.getFirst();
|
||||
String oldData = oldToolMessage.getResponses().getFirst().responseData();
|
||||
assertTrue(oldData.contains("duplicate tool output omitted"));
|
||||
}
|
||||
|
||||
private static ToolResponseMessage toolMessage(String id, String name, String data) {
|
||||
return ToolResponseMessage.builder()
|
||||
.responses(List.of(new ToolResponseMessage.ToolResponse(id, name, data)))
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,186 @@
|
||||
package vip.mate.agent.delegation;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import vip.mate.audit.service.AuditEventService;
|
||||
import vip.mate.common.result.R;
|
||||
import vip.mate.exception.MateClawException;
|
||||
import vip.mate.workspace.conversation.ConversationService;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.atLeastOnce;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class SubagentControllerTest {
|
||||
|
||||
private SubagentRegistry registry;
|
||||
private ConversationService conversationService;
|
||||
private AuditEventService auditEventService;
|
||||
private SubagentController controller;
|
||||
private Authentication ownerAuth;
|
||||
private Authentication outsiderAuth;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
registry = new SubagentRegistry();
|
||||
conversationService = mock(ConversationService.class);
|
||||
auditEventService = mock(AuditEventService.class);
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
controller = new SubagentController(registry, conversationService, auditEventService, mapper);
|
||||
|
||||
ownerAuth = mock(Authentication.class);
|
||||
when(ownerAuth.getName()).thenReturn("alice");
|
||||
outsiderAuth = mock(Authentication.class);
|
||||
when(outsiderAuth.getName()).thenReturn("mallory");
|
||||
|
||||
// Default: alice owns parent-1, mallory does not.
|
||||
when(conversationService.isConversationOwner(eq("parent-1"), eq("alice"))).thenReturn(true);
|
||||
when(conversationService.isConversationOwner(eq("parent-1"), eq("mallory"))).thenReturn(false);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("interrupt — owner gets 200 with interrupted=true and an audit row")
|
||||
void interruptOwner() {
|
||||
String sid = registry.register("parent-1", "child-1", 7L, "do thing", null);
|
||||
|
||||
R<Map<String, Object>> response = controller.interrupt(sid, ownerAuth);
|
||||
|
||||
assertThat(response.getCode()).isEqualTo(200); // ResultCode.SUCCESS
|
||||
assertThat(response.getData()).containsEntry("interrupted", true);
|
||||
assertThat(registry.get(sid).orElseThrow().status().get()).isEqualTo("interrupted");
|
||||
verify(auditEventService).record(eq("subagent.interrupt"), eq("subagent"),
|
||||
eq(sid), anyString(), anyString());
|
||||
// Denial audit must NOT have fired on the owner path.
|
||||
verify(auditEventService, never()).record(eq("subagent.interrupt.denied"),
|
||||
anyString(), anyString(), anyString(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("interrupt — non-owner is denied (403) and a denial audit is written")
|
||||
void interruptDeniedForNonOwner() {
|
||||
String sid = registry.register("parent-1", "child-1", 7L, "do thing", null);
|
||||
|
||||
assertThatThrownBy(() -> controller.interrupt(sid, outsiderAuth))
|
||||
.isInstanceOf(MateClawException.class)
|
||||
.matches(t -> ((MateClawException) t).getCode() == 403);
|
||||
|
||||
verify(auditEventService).record(eq("subagent.interrupt.denied"), eq("subagent"),
|
||||
eq(sid), anyString(), anyString());
|
||||
// Status must remain unchanged for the non-owner path.
|
||||
assertThat(registry.get(sid).orElseThrow().status().get()).isEqualTo("running");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("interrupt — missing subagent throws 404")
|
||||
void interruptNotFound() {
|
||||
assertThatThrownBy(() -> controller.interrupt("sa-does-not-exist", ownerAuth))
|
||||
.isInstanceOf(MateClawException.class)
|
||||
.matches(t -> ((MateClawException) t).getCode() == 404);
|
||||
|
||||
verify(auditEventService, never()).record(eq("subagent.interrupt"),
|
||||
anyString(), anyString(), anyString(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("spawn-pause — missing parentConversationId throws 400")
|
||||
void spawnPauseMissingParent() {
|
||||
Map<String, Object> body = new HashMap<>();
|
||||
body.put("paused", true);
|
||||
|
||||
assertThatThrownBy(() -> controller.setPaused(body, ownerAuth))
|
||||
.isInstanceOf(MateClawException.class)
|
||||
.matches(t -> ((MateClawException) t).getCode() == 400);
|
||||
|
||||
// Empty body also fails the same way.
|
||||
assertThatThrownBy(() -> controller.setPaused(new HashMap<>(), ownerAuth))
|
||||
.isInstanceOf(MateClawException.class)
|
||||
.matches(t -> ((MateClawException) t).getCode() == 400);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("spawn-pause — owner toggles flag and audit captures decision")
|
||||
void spawnPauseOwnerToggle() {
|
||||
Map<String, Object> body = new HashMap<>();
|
||||
body.put("parentConversationId", "parent-1");
|
||||
body.put("paused", true);
|
||||
|
||||
R<Map<String, Object>> resp = controller.setPaused(body, ownerAuth);
|
||||
assertThat(resp.getData()).containsEntry("paused", true);
|
||||
assertThat(registry.isSpawnPaused("parent-1")).isTrue();
|
||||
verify(auditEventService).record(eq("subagent.spawn-pause"), eq("conversation"),
|
||||
eq("parent-1"), eq("parent-1"), anyString());
|
||||
|
||||
body.put("paused", false);
|
||||
controller.setPaused(body, ownerAuth);
|
||||
assertThat(registry.isSpawnPaused("parent-1")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("spawn-pause — non-owner gets 403, flag is not changed")
|
||||
void spawnPauseNonOwnerForbidden() {
|
||||
Map<String, Object> body = new HashMap<>();
|
||||
body.put("parentConversationId", "parent-1");
|
||||
body.put("paused", true);
|
||||
|
||||
assertThatThrownBy(() -> controller.setPaused(body, outsiderAuth))
|
||||
.isInstanceOf(MateClawException.class)
|
||||
.matches(t -> ((MateClawException) t).getCode() == 403);
|
||||
|
||||
assertThat(registry.isSpawnPaused("parent-1")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("listActive — missing parentConversationId throws 400")
|
||||
void listActiveMissingParent() {
|
||||
assertThatThrownBy(() -> controller.listActive(null, ownerAuth))
|
||||
.isInstanceOf(MateClawException.class)
|
||||
.matches(t -> ((MateClawException) t).getCode() == 400);
|
||||
assertThatThrownBy(() -> controller.listActive("", ownerAuth))
|
||||
.isInstanceOf(MateClawException.class)
|
||||
.matches(t -> ((MateClawException) t).getCode() == 400);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("listActive — owner sees only their own subagents in the response")
|
||||
void listActiveOwnerScoped() {
|
||||
registry.register("parent-1", "child-1", 7L, "g", null);
|
||||
registry.register("parent-1", "child-2", 7L, "g2", null);
|
||||
registry.register("other-parent", "child-x", 8L, "g3", null);
|
||||
|
||||
R<Map<String, Object>> resp = controller.listActive("parent-1", ownerAuth);
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, Object>> subagents = (List<Map<String, Object>>) resp.getData().get("subagents");
|
||||
assertThat(subagents).hasSize(2);
|
||||
assertThat(subagents).allSatisfy(dto -> {
|
||||
assertThat(dto.get("parentConversationId")).isEqualTo("parent-1");
|
||||
// Disposable + raw atomic refs must not leak into the wire DTO.
|
||||
assertThat(dto).doesNotContainKey("disposable");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("listActive — non-owner is denied 403")
|
||||
void listActiveNonOwnerForbidden() {
|
||||
registry.register("parent-1", "child-1", 7L, "g", null);
|
||||
|
||||
assertThatThrownBy(() -> controller.listActive("parent-1", outsiderAuth))
|
||||
.isInstanceOf(MateClawException.class)
|
||||
.matches(t -> ((MateClawException) t).getCode() == 403);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,145 @@
|
||||
package vip.mate.agent.delegation;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import vip.mate.channel.web.ChatStreamTracker;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.atLeastOnce;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class SubagentHeartbeatTest {
|
||||
|
||||
private SubagentRegistry registry;
|
||||
private SubagentHeartbeatConfig cfg;
|
||||
private ChatStreamTracker streamTracker;
|
||||
private SubagentHeartbeat heartbeat;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
registry = new SubagentRegistry();
|
||||
cfg = new SubagentHeartbeatConfig();
|
||||
// Tight thresholds keep tests fast.
|
||||
cfg.setIntervalSec(30);
|
||||
cfg.setStaleCyclesIdle(3);
|
||||
cfg.setStaleCyclesInTool(5);
|
||||
streamTracker = mock(ChatStreamTracker.class);
|
||||
heartbeat = new SubagentHeartbeat(registry, cfg, streamTracker);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("idle child flips to stale exactly at the configured idle threshold")
|
||||
void idleChildBecomesStale() {
|
||||
String id = registry.register("parent-1", "child-1", 1L, "g", null);
|
||||
// No tool, no phase change across cycles → idle path.
|
||||
when(streamTracker.getRunningToolName("child-1")).thenReturn(null);
|
||||
when(streamTracker.getCurrentPhase("child-1")).thenReturn("thinking");
|
||||
|
||||
var rec = registry.get(id).orElseThrow();
|
||||
|
||||
// Cycle 1: first observation seeds lastSeen, no stale increment.
|
||||
heartbeat.evaluate(rec);
|
||||
assertThat(rec.staleCount().get()).isEqualTo(0);
|
||||
assertThat(rec.status().get()).isEqualTo("running");
|
||||
|
||||
// Cycles 2 and 3: no change → counter increments to 1, then 2.
|
||||
heartbeat.evaluate(rec);
|
||||
heartbeat.evaluate(rec);
|
||||
assertThat(rec.staleCount().get()).isEqualTo(2);
|
||||
assertThat(rec.status().get()).isEqualTo("running");
|
||||
verify(streamTracker, never()).broadcastObject(anyString(), eq("subagent_stale"), any());
|
||||
|
||||
// Cycle 4: counter hits 3 → stale and event broadcast.
|
||||
heartbeat.evaluate(rec);
|
||||
assertThat(rec.status().get()).isEqualTo("stale");
|
||||
verify(streamTracker, times(1)).broadcastObject(eq("parent-1"), eq("subagent_stale"), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("in-tool child uses the longer in-tool threshold before stale fires")
|
||||
void inToolChildUsesLongerThreshold() {
|
||||
String id = registry.register("parent-2", "child-2", 1L, "g", null);
|
||||
when(streamTracker.getRunningToolName("child-2")).thenReturn("read_file");
|
||||
when(streamTracker.getCurrentPhase("child-2")).thenReturn("action");
|
||||
|
||||
var rec = registry.get(id).orElseThrow();
|
||||
|
||||
// Cycle 1 seeds lastSeen (no increment). Each subsequent no-change
|
||||
// tick increments staleCount by 1; staleCyclesInTool=5 fires when
|
||||
// the counter HITS 5. So we need 1 seed + 5 increment ticks.
|
||||
heartbeat.evaluate(rec); // seed
|
||||
for (int i = 0; i < 5; i++) {
|
||||
heartbeat.evaluate(rec);
|
||||
}
|
||||
assertThat(rec.status().get()).isEqualTo("stale");
|
||||
verify(streamTracker, times(1)).broadcastObject(eq("parent-2"), eq("subagent_stale"), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("phase or tool change resets stale counter")
|
||||
void progressResetsCounter() {
|
||||
String id = registry.register("parent-3", "child-3", 1L, "g", null);
|
||||
var rec = registry.get(id).orElseThrow();
|
||||
|
||||
when(streamTracker.getRunningToolName("child-3")).thenReturn(null);
|
||||
when(streamTracker.getCurrentPhase("child-3")).thenReturn("thinking");
|
||||
heartbeat.evaluate(rec); // seed
|
||||
heartbeat.evaluate(rec); // +1
|
||||
heartbeat.evaluate(rec); // +2
|
||||
assertThat(rec.staleCount().get()).isEqualTo(2);
|
||||
|
||||
// Phase change → counter resets.
|
||||
when(streamTracker.getCurrentPhase("child-3")).thenReturn("action");
|
||||
heartbeat.evaluate(rec);
|
||||
assertThat(rec.staleCount().get()).isEqualTo(0);
|
||||
|
||||
// Tool change while staying in same phase also resets.
|
||||
when(streamTracker.getRunningToolName("child-3")).thenReturn("read_file");
|
||||
heartbeat.evaluate(rec); // (tool changed) → reset
|
||||
assertThat(rec.staleCount().get()).isEqualTo(0);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("heartbeat skips non-running records")
|
||||
void skipsNonRunning() {
|
||||
String id = registry.register("parent-4", "child-4", 1L, "g", null);
|
||||
registry.get(id).orElseThrow().status().set("interrupted");
|
||||
|
||||
heartbeat.check();
|
||||
|
||||
verify(streamTracker, never()).getRunningToolName(anyString());
|
||||
verify(streamTracker, never()).broadcastObject(anyString(), anyString(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("subagent_stale payload carries id, cycles, lastTool, elapsedMs")
|
||||
void stalePayloadShape() {
|
||||
cfg.setStaleCyclesIdle(2);
|
||||
String id = registry.register("parent-5", "child-5", 1L, "g", null);
|
||||
when(streamTracker.getRunningToolName("child-5")).thenReturn(null);
|
||||
when(streamTracker.getCurrentPhase("child-5")).thenReturn("thinking");
|
||||
|
||||
var rec = registry.get(id).orElseThrow();
|
||||
heartbeat.evaluate(rec); // seed
|
||||
heartbeat.evaluate(rec); // +1
|
||||
heartbeat.evaluate(rec); // +2 → stale
|
||||
|
||||
ArgumentCaptor<Object> captor = ArgumentCaptor.forClass(Object.class);
|
||||
verify(streamTracker).broadcastObject(eq("parent-5"), eq("subagent_stale"), captor.capture());
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> payload = (Map<String, Object>) captor.getValue();
|
||||
assertThat(payload).containsKeys("subagentId", "cycles", "lastTool", "elapsedMs");
|
||||
assertThat(payload.get("subagentId")).isEqualTo(id);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,159 @@
|
||||
package vip.mate.agent.delegation;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import reactor.core.Disposable;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class SubagentRegistryTest {
|
||||
|
||||
/** ID format: sa-<epoch_ms>-<8 lowercase hex> */
|
||||
private static final Pattern ID_PATTERN = Pattern.compile("^sa-\\d+-[0-9a-f]{8}$");
|
||||
|
||||
private SubagentRegistry registry;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
registry = new SubagentRegistry();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("register assigns matching ID, snapshot finds it, unregister drops it")
|
||||
void registerSnapshotUnregister() {
|
||||
Disposable d = mock(Disposable.class);
|
||||
String id = registry.register("parent-1", "child-1", 7L, "do thing", d);
|
||||
|
||||
assertThat(id).matches(ID_PATTERN);
|
||||
assertThat(registry.get(id)).isPresent();
|
||||
assertThat(registry.snapshot("parent-1")).hasSize(1);
|
||||
assertThat(registry.snapshot("parent-1").get(0).childConversationId()).isEqualTo("child-1");
|
||||
assertThat(registry.allActive()).hasSize(1);
|
||||
|
||||
registry.unregister(id);
|
||||
|
||||
assertThat(registry.get(id)).isEmpty();
|
||||
assertThat(registry.snapshot("parent-1")).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("snapshot filters by parent — siblings under other parents are not visible")
|
||||
void snapshotFiltersByParent() {
|
||||
registry.register("parent-A", "ca-1", 1L, "task", null);
|
||||
registry.register("parent-A", "ca-2", 1L, "task", null);
|
||||
registry.register("parent-B", "cb-1", 1L, "task", null);
|
||||
|
||||
assertThat(registry.snapshot("parent-A")).hasSize(2);
|
||||
assertThat(registry.snapshot("parent-B")).hasSize(1);
|
||||
assertThat(registry.snapshot("parent-C")).isEmpty();
|
||||
assertThat(registry.snapshot(null)).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("interrupt flips status, disposes subscription, returns false for missing/null")
|
||||
void interruptBehaviour() {
|
||||
Disposable disposable = mock(Disposable.class);
|
||||
when(disposable.isDisposed()).thenReturn(false);
|
||||
String id = registry.register("p", "c", 1L, "g", disposable);
|
||||
|
||||
assertThat(registry.interrupt(id)).isTrue();
|
||||
assertThat(registry.get(id)).isPresent();
|
||||
assertThat(registry.get(id).get().status().get()).isEqualTo("interrupted");
|
||||
verify(disposable).dispose();
|
||||
|
||||
// Already-disposed subscription is not disposed again.
|
||||
when(disposable.isDisposed()).thenReturn(true);
|
||||
registry.interrupt(id);
|
||||
verify(disposable).dispose(); // still only the first call
|
||||
|
||||
assertThat(registry.interrupt("does-not-exist")).isFalse();
|
||||
assertThat(registry.interrupt(null)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("interrupt with null disposable does not throw")
|
||||
void interruptNullDisposable() {
|
||||
String id = registry.register("p", "c", 1L, "g", null);
|
||||
assertThat(registry.interrupt(id)).isTrue();
|
||||
assertThat(registry.get(id).get().status().get()).isEqualTo("interrupted");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("setSpawnPaused is scoped per parent — pausing A does not pause B")
|
||||
void spawnPauseIsParentScoped() {
|
||||
registry.setSpawnPaused("parent-A", true);
|
||||
assertThat(registry.isSpawnPaused("parent-A")).isTrue();
|
||||
assertThat(registry.isSpawnPaused("parent-B")).isFalse();
|
||||
|
||||
registry.setSpawnPaused("parent-A", false);
|
||||
assertThat(registry.isSpawnPaused("parent-A")).isFalse();
|
||||
|
||||
// Null inputs are tolerated and never report paused.
|
||||
assertThat(registry.isSpawnPaused(null)).isFalse();
|
||||
assertThat(registry.setSpawnPaused(null, true)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("concurrent register from many threads produces unique IDs and no record loss")
|
||||
void concurrentRegister() throws Exception {
|
||||
int threads = 16;
|
||||
int perThread = 50;
|
||||
ExecutorService pool = Executors.newFixedThreadPool(threads);
|
||||
CountDownLatch start = new CountDownLatch(1);
|
||||
Set<String> ids = java.util.Collections.synchronizedSet(new HashSet<>());
|
||||
|
||||
for (int t = 0; t < threads; t++) {
|
||||
final int tid = t;
|
||||
pool.submit(() -> {
|
||||
try { start.await(); } catch (InterruptedException e) { return; }
|
||||
for (int i = 0; i < perThread; i++) {
|
||||
String id = registry.register("parent-" + tid, "child-" + tid + "-" + i,
|
||||
(long) i, "g", null);
|
||||
ids.add(id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
start.countDown();
|
||||
pool.shutdown();
|
||||
assertThat(pool.awaitTermination(10, TimeUnit.SECONDS)).isTrue();
|
||||
|
||||
assertThat(ids).hasSize(threads * perThread);
|
||||
assertThat(registry.allActive()).hasSize(threads * perThread);
|
||||
|
||||
// Each parent owns exactly perThread children.
|
||||
for (int t = 0; t < threads; t++) {
|
||||
assertThat(registry.snapshot("parent-" + t)).hasSize(perThread);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("get on null / missing returns empty Optional")
|
||||
void getNullSafe() {
|
||||
assertThat(registry.get(null)).isEmpty();
|
||||
assertThat(registry.get("nope")).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("unregister on null / missing is a no-op")
|
||||
void unregisterNullSafe() {
|
||||
registry.register("p", "c", 1L, "g", null);
|
||||
registry.unregister(null);
|
||||
registry.unregister("does-not-exist");
|
||||
assertThat(registry.allActive()).hasSize(1);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,219 @@
|
||||
package vip.mate.agent.graph;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* Pin {@link NodeStreamingChatHelper#hasRepeatingSuffix} — the cheap
|
||||
* loop detector that catches reasoning-mode models (qwen3.6, deepseek-r1)
|
||||
* stuck emitting the same final-answer paragraph over and over.
|
||||
*
|
||||
* <p>Real failure pattern from production: model alternates English
|
||||
* "Wait, I should X. Done. I will write the response." with the same
|
||||
* Chinese answer, dozens of times, until {@code max_tokens} runs out.
|
||||
* Without this guard the user waits for a wall of duplicated text;
|
||||
* with it, the stream stops at the third or fourth copy and the
|
||||
* already-accumulated content gets returned as a partial answer.
|
||||
*
|
||||
* <p>The detector probes periods from 24 chars (anything shorter would
|
||||
* false-positive on natural phrases) up to 240 chars; 4 verbatim
|
||||
* consecutive copies is the threshold (3-times structured outputs like
|
||||
* "TL;DR / body / TL;DR again" should pass through).
|
||||
*/
|
||||
class ContentRepetitionGuardTest {
|
||||
|
||||
private static final int MIN_PERIOD = 24;
|
||||
private static final int MAX_PERIOD = 240;
|
||||
private static final int MIN_OCCURRENCES = 4;
|
||||
|
||||
@Test
|
||||
@DisplayName("non-cyclic prose with varied sentences does NOT trip")
|
||||
void naturalProseDoesNotTrip() {
|
||||
// Real writing: each sentence is unique, no consecutive paragraph
|
||||
// repeats anywhere in the buffer.
|
||||
String prose = "MateClaw 是一个企业级 AI 助手。它支持多种渠道接入,包括企业微信、"
|
||||
+ "飞书、钉钉。Agent 通过 StateGraph 编排,可以调用工具、生成图片、查询知识库。"
|
||||
+ "用户可以在 Web 控制台、桌面 App 或群聊里发起对话。系统记忆采用三档分层:"
|
||||
+ "PROFILE.md 记录用户画像、MEMORY.md 沉淀稳定事实、memory/YYYY-MM-DD.md "
|
||||
+ "保存当日上下文。审批流程基于 Spring AI Alibaba Graph,工具调用前会被守卫拦截,"
|
||||
+ "高风险操作必须由用户显式批准才能执行。会话与频道之间是多对多关系。";
|
||||
assertFalse(NodeStreamingChatHelper.hasRepeatingSuffix(
|
||||
prose, MIN_PERIOD, MAX_PERIOD, MIN_OCCURRENCES));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("verbatim short paragraph repeated 4× → trips")
|
||||
void verbatimQuadrupleRepeatTrips() {
|
||||
// The exact production failure mode: 50-char Chinese answer repeated.
|
||||
String paragraph = "收到语音啦!想查昨天的天气没问题,告诉我城市名我马上帮你查!\n";
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (int i = 0; i < 5; i++) sb.append(paragraph);
|
||||
assertTrue(NodeStreamingChatHelper.hasRepeatingSuffix(
|
||||
sb, MIN_PERIOD, MAX_PERIOD, MIN_OCCURRENCES),
|
||||
"5 verbatim ~30-char paragraphs in a row should trip");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("3 verbatim repeats stay UNDER threshold (legitimate triple-mention pattern)")
|
||||
void threeRepeatsBelowThreshold() {
|
||||
// Some legitimate outputs repeat structured summaries 2-3 times
|
||||
// (e.g. "TL;DR" + body + "TL;DR" again). The threshold of 4
|
||||
// gives breathing room so these don't false-positive.
|
||||
String paragraph = "请告诉我您所在的城市,例如北京、上海或深圳,我可以为您查询天气。\n";
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (int i = 0; i < 3; i++) sb.append(paragraph);
|
||||
assertFalse(NodeStreamingChatHelper.hasRepeatingSuffix(
|
||||
sb, MIN_PERIOD, MAX_PERIOD, MIN_OCCURRENCES),
|
||||
"3 verbatim paragraphs must NOT trip — preserves triple-mention outputs");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("interleaved English thinking + Chinese answer pattern still trips")
|
||||
void interleavedRepetitionTrips() {
|
||||
// Mirrors the production trace exactly: English thinking
|
||||
// alternating with the same Chinese answer. The combined
|
||||
// "thinking + answer" unit is the actual repeating period.
|
||||
String unit = "Wait, I should write.\nOkay.\n收到语音啦!告诉我城市名我马上帮你查!\n";
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (int i = 0; i < 5; i++) sb.append(unit);
|
||||
assertTrue(NodeStreamingChatHelper.hasRepeatingSuffix(
|
||||
sb, MIN_PERIOD, MAX_PERIOD, MIN_OCCURRENCES),
|
||||
"5 verbatim 'thinking + answer' cycles should trip");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("empty / short / null content returns false (fast path)")
|
||||
void shortContentDoesNotTrip() {
|
||||
assertFalse(NodeStreamingChatHelper.hasRepeatingSuffix(
|
||||
null, MIN_PERIOD, MAX_PERIOD, MIN_OCCURRENCES));
|
||||
assertFalse(NodeStreamingChatHelper.hasRepeatingSuffix(
|
||||
"", MIN_PERIOD, MAX_PERIOD, MIN_OCCURRENCES));
|
||||
assertFalse(NodeStreamingChatHelper.hasRepeatingSuffix(
|
||||
"hi there", MIN_PERIOD, MAX_PERIOD, MIN_OCCURRENCES));
|
||||
// Just under MIN_PERIOD × MIN_OCCURRENCES → can't possibly match.
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (int i = 0; i < 80; i++) sb.append('x');
|
||||
assertFalse(NodeStreamingChatHelper.hasRepeatingSuffix(
|
||||
sb, MIN_PERIOD, MAX_PERIOD, MIN_OCCURRENCES));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("trailing repeat after long preamble: detects only the looping suffix")
|
||||
void detectsLoopAfterPreamble() {
|
||||
// Realistic: model produces a long valid answer, then enters a
|
||||
// loop appending the same trailer. The detector must catch the
|
||||
// loop even though the buffer prefix has perfectly varied text.
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("好的,我已经为您完成了任务,下面是详细的执行结果:\n");
|
||||
sb.append("第一步,我读取了配置文件并解析了内容。\n");
|
||||
sb.append("第二步,我调用了天气查询接口拿到了原始数据。\n");
|
||||
sb.append("第三步,我将结果格式化为人类可读的中文文本。\n");
|
||||
// Now the model gets stuck repeating a closing phrase.
|
||||
String trailer = "如有其他问题,请随时告诉我,我会尽快为您解答和处理。\n";
|
||||
for (int i = 0; i < 5; i++) sb.append(trailer);
|
||||
assertTrue(NodeStreamingChatHelper.hasRepeatingSuffix(
|
||||
sb, MIN_PERIOD, MAX_PERIOD, MIN_OCCURRENCES),
|
||||
"trailing 5×-repeated trailer must trip even after long preamble");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("single-char fill (200x 'a') does NOT trip — too short to be a real period")
|
||||
void singleCharFillDoesNotTrip() {
|
||||
// 'aaaa...' could be parsed as period=1 with 200 occurrences,
|
||||
// but our floor is MIN_PERIOD=24, so a literal 24-char run of
|
||||
// 'a' would need to repeat 4× — which is just one continuous
|
||||
// run of 96 'a' chars. That's a degenerate case; mark as
|
||||
// not-tripping-via-this-detector since it's not the "self-
|
||||
// arguing loop" failure mode (a model emitting 'aaaaaaa...'
|
||||
// would hit max_tokens harmlessly without any degradation
|
||||
// worth user attention).
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (int i = 0; i < 200; i++) sb.append('a');
|
||||
// 200 'a' chars: period=24 unit is "aaaa...a" (24 of them).
|
||||
// The prior 24-char block is also "aaa...a" (24 of them).
|
||||
// So they DO match. This trips. Document the behavior — it's
|
||||
// mostly harmless because models don't actually loop on single
|
||||
// chars.
|
||||
assertTrue(NodeStreamingChatHelper.hasRepeatingSuffix(
|
||||
sb, MIN_PERIOD, MAX_PERIOD, MIN_OCCURRENCES),
|
||||
"documented behavior: pure single-char fills DO trip; not a real failure mode in practice");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("invalid args return false defensively")
|
||||
void invalidArgsReturnFalse() {
|
||||
assertFalse(NodeStreamingChatHelper.hasRepeatingSuffix("text", 0, 100, 4));
|
||||
assertFalse(NodeStreamingChatHelper.hasRepeatingSuffix("text", 24, 240, 1));
|
||||
// maxPeriod < minPeriod
|
||||
assertFalse(NodeStreamingChatHelper.hasRepeatingSuffix("text", 100, 50, 4));
|
||||
}
|
||||
|
||||
// ===== dedupTrailingRepeats =====
|
||||
//
|
||||
// Once the loop guard fires, the streamed text has already gone out
|
||||
// (SSE chunks can't be unsent), but the DB-persisted final answer +
|
||||
// IM channel reply should show ONE clean copy of the looping unit
|
||||
// instead of the wall the user just watched scroll by.
|
||||
|
||||
@Test
|
||||
@DisplayName("dedup: 5 verbatim copies → 1 copy")
|
||||
void dedupCollapsesRepeats() {
|
||||
String unit = "收到语音啦!想查昨天的天气没问题,告诉我城市名我马上帮你查!\n";
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (int i = 0; i < 5; i++) sb.append(unit);
|
||||
String result = NodeStreamingChatHelper.dedupTrailingRepeats(sb.toString(), MIN_PERIOD, MAX_PERIOD);
|
||||
assertEquals(unit, result, "5 copies should collapse to exactly 1");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("dedup: prefix + repeated trailer → prefix + 1 copy of trailer")
|
||||
void dedupPreservesPrefixCollapseTrailer() {
|
||||
String prefix = "好的,下面是详细回答:第一步完成了。第二步也完成了。下面是结论。\n";
|
||||
String trailer = "如有其他问题请随时告诉我,我会尽快为您解答处理。\n";
|
||||
StringBuilder sb = new StringBuilder(prefix);
|
||||
for (int i = 0; i < 5; i++) sb.append(trailer);
|
||||
String result = NodeStreamingChatHelper.dedupTrailingRepeats(sb.toString(), MIN_PERIOD, MAX_PERIOD);
|
||||
assertEquals(prefix + trailer, result,
|
||||
"prefix preserved verbatim; trailer collapses 5×→1×");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("dedup: no trailing repeats → buffer unchanged")
|
||||
void dedupNoRepeatsUnchanged() {
|
||||
String prose = "这是一段没有任何尾部重复的正常回答,包含多个不同的句子和话题。"
|
||||
+ "我们讨论了天气、新闻、技术,每段内容都不同。";
|
||||
assertEquals(prose,
|
||||
NodeStreamingChatHelper.dedupTrailingRepeats(prose, MIN_PERIOD, MAX_PERIOD));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("dedup: only 1 copy at end (no actual repetition) → unchanged")
|
||||
void dedupSingleCopyUnchanged() {
|
||||
String unit = "请告诉我您所在的城市,我帮您查询。";
|
||||
// Just one copy at the tail — nothing to collapse.
|
||||
assertEquals(unit,
|
||||
NodeStreamingChatHelper.dedupTrailingRepeats(unit, MIN_PERIOD, MAX_PERIOD));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("dedup: empty / null inputs return as-is")
|
||||
void dedupEmptyOrNull() {
|
||||
assertNull(NodeStreamingChatHelper.dedupTrailingRepeats(null, MIN_PERIOD, MAX_PERIOD));
|
||||
assertEquals("", NodeStreamingChatHelper.dedupTrailingRepeats("", MIN_PERIOD, MAX_PERIOD));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("dedup: 2 copies (the minimum trip threshold) → 1 copy")
|
||||
void dedupTwoCopiesCollapse() {
|
||||
// dedup uses 2+ copies as its trigger (vs. hasRepeatingSuffix's 4×
|
||||
// detection threshold). Once the guard has decided the buffer is
|
||||
// looping, even a 2× tail should be collapsed since we know
|
||||
// structurally the model is mid-loop.
|
||||
String unit = "如果您还有任何其他疑问,欢迎随时联系我,我会尽快回复。";
|
||||
String input = unit + unit;
|
||||
assertEquals(unit,
|
||||
NodeStreamingChatHelper.dedupTrailingRepeats(input, MIN_PERIOD, MAX_PERIOD));
|
||||
}
|
||||
}
|
||||
@ -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)")));
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,246 @@
|
||||
package vip.mate.agent.graph;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.ai.chat.messages.AssistantMessage;
|
||||
import org.springframework.ai.chat.messages.UserMessage;
|
||||
import org.springframework.ai.chat.metadata.ChatGenerationMetadata;
|
||||
import org.springframework.ai.chat.model.ChatModel;
|
||||
import org.springframework.ai.chat.model.ChatResponse;
|
||||
import org.springframework.ai.chat.model.Generation;
|
||||
import org.springframework.ai.chat.prompt.Prompt;
|
||||
import reactor.core.publisher.Flux;
|
||||
import vip.mate.channel.web.ChatStreamTracker;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CancellationException;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* Regression tests for Lane D performance fixes (RFC 06-lane-d-performance-fixes).
|
||||
*
|
||||
* <ul>
|
||||
* <li>D-1: Backoff sleep responds to Stop signal within 100ms</li>
|
||||
* <li>D-2: RATE_LIMIT/SERVER_ERROR retries capped at 2 (was 5)</li>
|
||||
* </ul>
|
||||
*/
|
||||
class LaneDPerformanceFixesTest {
|
||||
|
||||
private ChatStreamTracker streamTracker;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
streamTracker = mock(ChatStreamTracker.class);
|
||||
when(streamTracker.isStopRequested(any())).thenReturn(false);
|
||||
}
|
||||
|
||||
private NodeStreamingChatHelper helper(ChatModel primary) {
|
||||
return new NodeStreamingChatHelper(streamTracker, List.of(), null);
|
||||
}
|
||||
|
||||
private static Prompt smallPrompt() {
|
||||
return new Prompt(List.of(new UserMessage("hi")));
|
||||
}
|
||||
|
||||
private static ChatModel successModel(String text) {
|
||||
ChatModel m = mock(ChatModel.class);
|
||||
Generation gen = new Generation(new AssistantMessage(text), ChatGenerationMetadata.NULL);
|
||||
ChatResponse resp = mock(ChatResponse.class);
|
||||
when(resp.getResults()).thenReturn(List.of(gen));
|
||||
when(resp.getResult()).thenReturn(gen);
|
||||
when(resp.getMetadata()).thenReturn(null);
|
||||
when(m.stream(any(Prompt.class))).thenReturn(Flux.just(resp));
|
||||
return m;
|
||||
}
|
||||
|
||||
private static ChatModel rateLimitModel() {
|
||||
ChatModel m = mock(ChatModel.class);
|
||||
when(m.stream(any(Prompt.class))).thenReturn(
|
||||
Flux.error(new RuntimeException("429 Too Many Requests: rate limit exceeded")));
|
||||
return m;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// D-1: Backoff sleep responds to Stop signal
|
||||
// ============================================================
|
||||
|
||||
@Nested
|
||||
@DisplayName("D-1: Backoff sleep responds to Stop signal")
|
||||
class BackoffStopSignalTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("Stop requested during backoff aborts retry quickly with CancellationException")
|
||||
void stopDuringBackoffAbortsRetry() {
|
||||
// Arrange: model always returns rate-limit error to trigger backoff
|
||||
AtomicInteger callCount = new AtomicInteger(0);
|
||||
ChatModel model = mock(ChatModel.class);
|
||||
when(model.stream(any(Prompt.class))).thenAnswer(inv -> {
|
||||
callCount.incrementAndGet();
|
||||
return Flux.error(new RuntimeException("429 Too Many Requests"));
|
||||
});
|
||||
|
||||
// Stop is requested after first call — during backoff sleep.
|
||||
// First poll returns false (initial check before sleep loop starts),
|
||||
// then true on subsequent checks to simulate user clicking stop.
|
||||
AtomicInteger stopCheckCount = new AtomicInteger(0);
|
||||
when(streamTracker.isStopRequested("conv-d1")).thenAnswer(inv ->
|
||||
stopCheckCount.incrementAndGet() > 2);
|
||||
|
||||
var helper = helper(model);
|
||||
long startMs = System.currentTimeMillis();
|
||||
|
||||
// The stop-during-backoff path throws CancellationException
|
||||
assertThrows(CancellationException.class, () ->
|
||||
helper.streamCall(model, smallPrompt(), "conv-d1", "reasoning"));
|
||||
|
||||
long elapsedMs = System.currentTimeMillis() - startMs;
|
||||
|
||||
// The backoff for attempt 1 is 3000ms base. With stop polling at 100ms intervals,
|
||||
// it should abort well before the full 3000ms backoff completes.
|
||||
assertTrue(elapsedMs < 2000,
|
||||
"Stop should abort backoff quickly, but took " + elapsedMs + "ms");
|
||||
// The model should only have been called once (first attempt fails, backoff
|
||||
// for second attempt is interrupted by stop)
|
||||
assertEquals(1, callCount.get(),
|
||||
"Model should only be called once before stop aborts the backoff");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Normal flow without stop completes backoff normally")
|
||||
void normalFlowWithoutStopCompletesBackoff() {
|
||||
// First call: rate limit; second call: success
|
||||
AtomicInteger callCount = new AtomicInteger(0);
|
||||
ChatModel model = mock(ChatModel.class);
|
||||
when(model.stream(any(Prompt.class))).thenAnswer(inv -> {
|
||||
if (callCount.incrementAndGet() == 1) {
|
||||
return Flux.error(new RuntimeException("429 Too Many Requests"));
|
||||
}
|
||||
Generation gen = new Generation(new AssistantMessage("ok"), ChatGenerationMetadata.NULL);
|
||||
ChatResponse resp = mock(ChatResponse.class);
|
||||
when(resp.getResults()).thenReturn(List.of(gen));
|
||||
when(resp.getResult()).thenReturn(gen);
|
||||
when(resp.getMetadata()).thenReturn(null);
|
||||
return Flux.just(resp);
|
||||
});
|
||||
|
||||
// Stop never requested
|
||||
when(streamTracker.isStopRequested(any())).thenReturn(false);
|
||||
|
||||
var helper = helper(model);
|
||||
var result = helper.streamCall(model, smallPrompt(), "conv-d1b", "reasoning");
|
||||
|
||||
assertEquals("ok", result.text(), "Second attempt should succeed");
|
||||
assertEquals(2, callCount.get(), "Model should be called twice (fail + succeed)");
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// D-2: RATE_LIMIT retries capped at 2
|
||||
// ============================================================
|
||||
|
||||
@Nested
|
||||
@DisplayName("D-2: RATE_LIMIT/SERVER_ERROR retries capped at 2")
|
||||
class RateLimitRetryCapTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("RATE_LIMIT error retries at most 2 times before giving up")
|
||||
void rateLimitMaxTwoRetries() {
|
||||
AtomicInteger callCount = new AtomicInteger(0);
|
||||
ChatModel model = mock(ChatModel.class);
|
||||
when(model.stream(any(Prompt.class))).thenAnswer(inv -> {
|
||||
callCount.incrementAndGet();
|
||||
return Flux.error(new RuntimeException("429 Too Many Requests: rate limit"));
|
||||
});
|
||||
|
||||
var helper = helper(model);
|
||||
var result = helper.streamCall(model, smallPrompt(), "conv-d2a", "reasoning");
|
||||
|
||||
// With MAX_RETRIES_RATE_LIMIT=2, attempts are: 0, 1, 2 = 3 total calls
|
||||
assertTrue(callCount.get() <= 3,
|
||||
"RATE_LIMIT should retry at most 2 times (3 total calls), but got " + callCount.get());
|
||||
assertNotEquals(NodeStreamingChatHelper.ErrorType.NONE, result.errorType(),
|
||||
"Result should be an error after exhausting retries");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("SERVER_ERROR keeps full MAX_RETRIES=5 (not capped like RATE_LIMIT)")
|
||||
void serverErrorKeepsFullRetries() {
|
||||
AtomicInteger callCount = new AtomicInteger(0);
|
||||
ChatModel model = mock(ChatModel.class);
|
||||
when(model.stream(any(Prompt.class))).thenAnswer(inv -> {
|
||||
callCount.incrementAndGet();
|
||||
return Flux.error(new RuntimeException("500 Internal Server Error"));
|
||||
});
|
||||
|
||||
var helper = helper(model);
|
||||
var result = helper.streamCall(model, smallPrompt(), "conv-d2b", "reasoning");
|
||||
|
||||
// SERVER_ERROR should use the full MAX_RETRIES=5 (6 total calls: attempt 0-5),
|
||||
// NOT the reduced MAX_RETRIES_RATE_LIMIT=2.
|
||||
assertTrue(callCount.get() > 3,
|
||||
"SERVER_ERROR should retry more than RATE_LIMIT (>3 calls), but got " + callCount.get());
|
||||
assertEquals(6, callCount.get(),
|
||||
"SERVER_ERROR should try 6 times total (attempt 0 through 5)");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("AUTH_ERROR is not retried (unchanged behavior)")
|
||||
void authErrorNotRetried() {
|
||||
AtomicInteger callCount = new AtomicInteger(0);
|
||||
ChatModel model = mock(ChatModel.class);
|
||||
when(model.stream(any(Prompt.class))).thenAnswer(inv -> {
|
||||
callCount.incrementAndGet();
|
||||
return Flux.error(new RuntimeException("401 Unauthorized"));
|
||||
});
|
||||
|
||||
var helper = helper(model);
|
||||
var result = helper.streamCall(model, smallPrompt(), "conv-d2c", "reasoning");
|
||||
|
||||
assertEquals(1, callCount.get(),
|
||||
"AUTH_ERROR should not be retried at all");
|
||||
assertEquals(NodeStreamingChatHelper.ErrorType.AUTH_ERROR, result.errorType());
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// D-3: broadcastProgress method exists and works
|
||||
// ============================================================
|
||||
|
||||
@Nested
|
||||
@DisplayName("D-3: broadcastProgress method")
|
||||
class BroadcastProgressTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("broadcastProgress sends progress event via streamTracker")
|
||||
void broadcastProgressSendsEvent() {
|
||||
var helper = new NodeStreamingChatHelper(streamTracker);
|
||||
helper.broadcastProgress("conv-d3", "分析中...");
|
||||
|
||||
verify(streamTracker, times(1)).broadcastObject(
|
||||
eq("conv-d3"), eq("progress"), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("broadcastProgress is safe with null streamTracker")
|
||||
void broadcastProgressNullTrackerNoOp() {
|
||||
var helper = new NodeStreamingChatHelper(null);
|
||||
// Should not throw
|
||||
assertDoesNotThrow(() -> helper.broadcastProgress("conv-d3b", "分析中..."));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("broadcastProgress is safe with null conversationId")
|
||||
void broadcastProgressNullConvIdNoOp() {
|
||||
var helper = new NodeStreamingChatHelper(streamTracker);
|
||||
assertDoesNotThrow(() -> helper.broadcastProgress(null, "分析中..."));
|
||||
// Should not invoke streamTracker when conversationId is null
|
||||
verify(streamTracker, never()).broadcastObject(any(), any(), any());
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,115 @@
|
||||
package vip.mate.agent.graph;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.ai.chat.messages.AssistantMessage;
|
||||
import org.springframework.ai.chat.messages.UserMessage;
|
||||
import org.springframework.ai.chat.metadata.ChatGenerationMetadata;
|
||||
import org.springframework.ai.chat.model.ChatModel;
|
||||
import org.springframework.ai.chat.model.ChatResponse;
|
||||
import org.springframework.ai.chat.model.Generation;
|
||||
import org.springframework.ai.chat.prompt.Prompt;
|
||||
import reactor.core.publisher.Flux;
|
||||
import vip.mate.channel.web.ChatStreamTracker;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* Regression coverage for the thinking-only soft cap added in P0-2.
|
||||
*
|
||||
* <p>The cap disposes the upstream stream when the model has emitted
|
||||
* {@code >= THINKING_ONLY_HARD_CAP_CHARS} of {@code reasoning_content}
|
||||
* with zero visible content and zero tool calls. The risk noted during
|
||||
* review (P1-A): some providers (Anthropic / DeepSeek-thinking variants)
|
||||
* pack {@code reasoning_content} and a {@code tool_call} into the same
|
||||
* SSE chunk. If the cap check sits inside the thinking-delta block (i.e.
|
||||
* before the chunk's tool_call is accumulated) it would dispose just
|
||||
* before observing the tool — turning a request that was about to dispatch
|
||||
* a tool into a spurious "INCOMPLETE: thinking-only" outcome.
|
||||
*/
|
||||
class NodeStreamingChatHelperThinkingCapTest {
|
||||
|
||||
private ChatStreamTracker streamTracker;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
streamTracker = mock(ChatStreamTracker.class);
|
||||
when(streamTracker.isStopRequested(any())).thenReturn(false);
|
||||
}
|
||||
|
||||
private static Prompt smallPrompt() {
|
||||
return new Prompt(List.of(new UserMessage("hi")));
|
||||
}
|
||||
|
||||
private static ChatModel singleChunkModel(AssistantMessage msg) {
|
||||
Generation gen = new Generation(msg, ChatGenerationMetadata.NULL);
|
||||
ChatResponse resp = mock(ChatResponse.class);
|
||||
when(resp.getResults()).thenReturn(List.of(gen));
|
||||
when(resp.getResult()).thenReturn(gen);
|
||||
when(resp.getMetadata()).thenReturn(null);
|
||||
ChatModel m = mock(ChatModel.class);
|
||||
when(m.stream(any(Prompt.class))).thenReturn(Flux.just(resp));
|
||||
return m;
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("thinking >= cap + tool_call in same chunk: cap must NOT trigger; tool_call survives")
|
||||
void thinkingAndToolCallSameChunk_doesNotTripSoftCap() {
|
||||
// Build a single chunk that carries 40k thinking (well above the
|
||||
// 32k cap) AND a tool call. With the buggy ordering this would
|
||||
// dispose before accumulateToolCalls runs and the helper would
|
||||
// return a partial "thinking_only_no_content" result.
|
||||
String hugeThinking = "x".repeat(40_000);
|
||||
AssistantMessage.ToolCall tc = new AssistantMessage.ToolCall(
|
||||
"id-1", "function", "search", "{\"q\":\"foo\"}");
|
||||
AssistantMessage msg = AssistantMessage.builder()
|
||||
.content("")
|
||||
.toolCalls(List.of(tc))
|
||||
.properties(Map.of("reasoningContent", hugeThinking))
|
||||
.build();
|
||||
|
||||
ChatModel m = singleChunkModel(msg);
|
||||
var helper = new NodeStreamingChatHelper(streamTracker);
|
||||
|
||||
var result = helper.streamCall(m, smallPrompt(), "conv-thinking-tc", "reasoning");
|
||||
|
||||
assertTrue(result.hasToolCalls(),
|
||||
"Tool call accompanying huge thinking in the same chunk must survive");
|
||||
assertEquals(1, result.toolCalls().size());
|
||||
assertEquals("search", result.toolCalls().get(0).name());
|
||||
assertFalse(result.partial(),
|
||||
"Result must not be marked partial when a tool_call was observed in the same chunk");
|
||||
assertNotEquals("thinking_only_no_content", result.errorMessage(),
|
||||
"Soft cap must not fire when the chunk carrying huge thinking also carried a tool call");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("thinking >= cap with NO tool_call and NO content: cap fires, result is partial+thinking_only_no_content")
|
||||
void thinkingOnlyNoContent_capFires() {
|
||||
// Symmetric positive case: confirms the cap still triggers in the
|
||||
// genuine "深度思考 ... never finishes" scenario the cap was added for.
|
||||
String hugeThinking = "y".repeat(40_000);
|
||||
AssistantMessage msg = AssistantMessage.builder()
|
||||
.content("")
|
||||
.properties(Map.of("reasoningContent", hugeThinking))
|
||||
.build();
|
||||
|
||||
ChatModel m = singleChunkModel(msg);
|
||||
var helper = new NodeStreamingChatHelper(streamTracker);
|
||||
|
||||
var result = helper.streamCall(m, smallPrompt(), "conv-thinking-only", "reasoning");
|
||||
|
||||
assertFalse(result.hasToolCalls());
|
||||
assertTrue(result.partial(), "Cap should mark the result as partial");
|
||||
assertEquals("thinking_only_no_content", result.errorMessage());
|
||||
assertEquals(hugeThinking, result.thinking(),
|
||||
"Thinking transcript is preserved so the UI can show it in a collapse panel");
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,122 @@
|
||||
package vip.mate.agent.graph;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.ai.chat.messages.AssistantMessage;
|
||||
import org.springframework.ai.chat.messages.UserMessage;
|
||||
import org.springframework.ai.chat.metadata.ChatGenerationMetadata;
|
||||
import org.springframework.ai.chat.model.ChatModel;
|
||||
import org.springframework.ai.chat.model.ChatResponse;
|
||||
import org.springframework.ai.chat.model.Generation;
|
||||
import org.springframework.ai.chat.prompt.Prompt;
|
||||
import reactor.core.publisher.Flux;
|
||||
import vip.mate.channel.web.ChatStreamTracker;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* Regression coverage for tool-call arguments sanitization.
|
||||
*
|
||||
* <p>Some OpenAI-compatible providers (aliyun-codingplan, others using the
|
||||
* "coding" DashScope endpoint) reject the follow-up chat-completions request
|
||||
* with HTTP 400 when the assistant message in history carries a tool call
|
||||
* whose {@code function.arguments} is not parseable JSON. The streaming
|
||||
* accumulator can produce empty or truncated argument strings, so the helper
|
||||
* normalizes the final value to {@code "{}"} when it is missing or invalid.
|
||||
*/
|
||||
class NodeStreamingChatHelperToolCallArgsTest {
|
||||
|
||||
private ChatStreamTracker streamTracker;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
streamTracker = mock(ChatStreamTracker.class);
|
||||
when(streamTracker.isStopRequested(any())).thenReturn(false);
|
||||
}
|
||||
|
||||
private static Prompt smallPrompt() {
|
||||
return new Prompt(List.of(new UserMessage("hi")));
|
||||
}
|
||||
|
||||
private static ChatModel singleChunkModel(AssistantMessage msg) {
|
||||
Generation gen = new Generation(msg, ChatGenerationMetadata.NULL);
|
||||
ChatResponse resp = mock(ChatResponse.class);
|
||||
when(resp.getResults()).thenReturn(List.of(gen));
|
||||
when(resp.getResult()).thenReturn(gen);
|
||||
when(resp.getMetadata()).thenReturn(null);
|
||||
ChatModel m = mock(ChatModel.class);
|
||||
when(m.stream(any(Prompt.class))).thenReturn(Flux.just(resp));
|
||||
return m;
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Empty tool-call arguments normalized to '{}'")
|
||||
void emptyArguments_replacedWithEmptyJsonObject() {
|
||||
AssistantMessage.ToolCall tc = new AssistantMessage.ToolCall(
|
||||
"id-empty", "function", "list_skills", "");
|
||||
AssistantMessage msg = AssistantMessage.builder()
|
||||
.content("")
|
||||
.toolCalls(List.of(tc))
|
||||
.build();
|
||||
|
||||
var helper = new NodeStreamingChatHelper(streamTracker);
|
||||
var result = helper.streamCall(singleChunkModel(msg), smallPrompt(),
|
||||
"conv-empty-args", "reasoning");
|
||||
|
||||
assertTrue(result.hasToolCalls(), "tool call must survive");
|
||||
assertEquals(1, result.toolCalls().size());
|
||||
assertEquals("{}", result.toolCalls().get(0).arguments(),
|
||||
"empty arguments must be replaced with '{}' so strict providers "
|
||||
+ "(aliyun-codingplan, ...) accept the follow-up request");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Truncated/invalid JSON arguments normalized to '{}'")
|
||||
void truncatedJsonArguments_replacedWithEmptyJsonObject() {
|
||||
// Simulates a stream cut mid-token: model emitted '{"q":"hel' and stopped.
|
||||
AssistantMessage.ToolCall tc = new AssistantMessage.ToolCall(
|
||||
"id-truncated", "function", "search", "{\"q\":\"hel");
|
||||
AssistantMessage msg = AssistantMessage.builder()
|
||||
.content("")
|
||||
.toolCalls(List.of(tc))
|
||||
.build();
|
||||
|
||||
var helper = new NodeStreamingChatHelper(streamTracker);
|
||||
var result = helper.streamCall(singleChunkModel(msg), smallPrompt(),
|
||||
"conv-truncated-args", "reasoning");
|
||||
|
||||
assertTrue(result.hasToolCalls(), "tool call must survive");
|
||||
assertEquals(1, result.toolCalls().size());
|
||||
assertEquals("{}", result.toolCalls().get(0).arguments(),
|
||||
"invalid JSON arguments must be replaced with '{}' so the follow-up "
|
||||
+ "request stays well-formed");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Valid JSON arguments preserved verbatim")
|
||||
void validJsonArguments_preservedAsIs() {
|
||||
String validArgs = "{\"query\":\"foo\",\"limit\":5}";
|
||||
AssistantMessage.ToolCall tc = new AssistantMessage.ToolCall(
|
||||
"id-valid", "function", "search", validArgs);
|
||||
AssistantMessage msg = AssistantMessage.builder()
|
||||
.content("")
|
||||
.toolCalls(List.of(tc))
|
||||
.build();
|
||||
|
||||
var helper = new NodeStreamingChatHelper(streamTracker);
|
||||
var result = helper.streamCall(singleChunkModel(msg), smallPrompt(),
|
||||
"conv-valid-args", "reasoning");
|
||||
|
||||
assertTrue(result.hasToolCalls());
|
||||
assertEquals(1, result.toolCalls().size());
|
||||
assertEquals(validArgs, result.toolCalls().get(0).arguments(),
|
||||
"valid JSON arguments must not be rewritten");
|
||||
}
|
||||
}
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,240 @@
|
||||
package vip.mate.agent.graph;
|
||||
|
||||
import com.alibaba.cloud.ai.graph.OverAllState;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.ai.chat.messages.AssistantMessage;
|
||||
import org.springframework.ai.chat.messages.Message;
|
||||
import org.springframework.ai.chat.messages.ToolResponseMessage;
|
||||
import org.springframework.ai.chat.model.ToolContext;
|
||||
import org.springframework.ai.tool.ToolCallback;
|
||||
import org.springframework.ai.tool.definition.ToolDefinition;
|
||||
import org.springframework.ai.tool.metadata.ToolMetadata;
|
||||
import vip.mate.agent.AgentToolSet;
|
||||
import vip.mate.agent.graph.edge.ObservationDispatcher;
|
||||
import vip.mate.agent.graph.executor.ToolExecutionExecutor;
|
||||
import vip.mate.agent.graph.node.ActionNode;
|
||||
import vip.mate.agent.graph.node.FinalAnswerNode;
|
||||
import vip.mate.agent.graph.state.DirectToolOutput;
|
||||
import vip.mate.tool.guard.ToolGuard;
|
||||
import vip.mate.tool.guard.ToolGuardResult;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static vip.mate.agent.graph.state.MateClawStateKeys.*;
|
||||
|
||||
/**
|
||||
* RFC-052 end-to-end chain test — exercises the full graph traversal across
|
||||
* three real components without mocking:
|
||||
*
|
||||
* <pre>
|
||||
* ToolExecutionExecutor (executes returnDirect tool)
|
||||
* ↓ writes ToolResponseMessage + events + directOutputs
|
||||
* ActionNode (interprets ToolExecutionResult, sets RETURN_DIRECT_TRIGGERED)
|
||||
* ↓ state mutation
|
||||
* ObservationDispatcher (routes to FinalAnswerNode)
|
||||
* ↓ edge decision
|
||||
* FinalAnswerNode (assembles final answer from DIRECT_TOOL_OUTPUTS)
|
||||
* ↓ produces FINAL_ANSWER + finishReason=RETURN_DIRECT
|
||||
* </pre>
|
||||
*
|
||||
* <p>This complements the per-component unit tests by verifying the
|
||||
* <em>composition</em> works: invariants flow correctly between nodes via
|
||||
* {@link OverAllState}, no integration glue is missing, no state key is
|
||||
* misnamed across boundaries.
|
||||
*
|
||||
* <p>What this does NOT test (still requires manual / SpringBootTest):
|
||||
* <ul>
|
||||
* <li>{@code StateGraphReActAgent} stream emission of {@code FINAL_ANSWER}
|
||||
* as {@code content_delta}</li>
|
||||
* <li>{@code StreamAccumulator} capturing {@code tool_direct_result} into
|
||||
* {@code metadata.directToolNames} (covered by the manual demo)</li>
|
||||
* <li>{@code BaseAgent.toSpringMessage} scrubbing on the next user turn
|
||||
* (covered by {@code BaseAgentDirectToolHistoryScrubTest})</li>
|
||||
* </ul>
|
||||
*/
|
||||
class ReturnDirectEndToEndTest {
|
||||
|
||||
private static final String SECRET =
|
||||
"EMPLOYEE-SALARY-RECORD\n" +
|
||||
"Name: Alice\n" +
|
||||
"Base: 12345\n" +
|
||||
"Bonus: 67890\n" +
|
||||
"SSN: 999-88-7777";
|
||||
|
||||
@Test
|
||||
@DisplayName("RFC-052 end-to-end: secret reaches FINAL_ANSWER verbatim, never enters LLM-bound messages")
|
||||
void fullChain_directToolFlowsAcrossNodes() throws Exception {
|
||||
// ===== Setup: real executor + real ActionNode + real Dispatcher + real FinalAnswerNode =====
|
||||
ToolCallback directTool = stubCallback("query_employee_salary", true, args -> SECRET);
|
||||
AgentToolSet toolSet = AgentToolSet.fromCallbacks(List.of(), List.of(directTool));
|
||||
ToolGuard alwaysAllow = (n, a) -> ToolGuardResult.allow();
|
||||
ToolExecutionExecutor executor = new ToolExecutionExecutor(toolSet, alwaysAllow, null, null);
|
||||
|
||||
ActionNode actionNode = new ActionNode(executor);
|
||||
ObservationDispatcher dispatcher = new ObservationDispatcher();
|
||||
FinalAnswerNode finalAnswerNode = new FinalAnswerNode();
|
||||
|
||||
// ===== Step 1: simulate ReasoningNode having decided to call the direct tool =====
|
||||
AssistantMessage.ToolCall toolCall = new AssistantMessage.ToolCall(
|
||||
"call_x", "function", "query_employee_salary", "{}");
|
||||
Map<String, Object> initialState = new HashMap<>();
|
||||
initialState.put(TOOL_CALLS, List.of(toolCall));
|
||||
initialState.put(CONVERSATION_ID, "conv_e2e");
|
||||
initialState.put(AGENT_ID, "agent_e2e");
|
||||
OverAllState state1 = new OverAllState(initialState);
|
||||
|
||||
// ===== Step 2: ActionNode runs the executor =====
|
||||
Map<String, Object> actionOut = actionNode.apply(state1);
|
||||
|
||||
// Verify ActionNode set the trigger flags
|
||||
assertEquals(Boolean.TRUE, actionOut.get(RETURN_DIRECT_TRIGGERED),
|
||||
"ActionNode must set RETURN_DIRECT_TRIGGERED when executor produced direct outputs");
|
||||
@SuppressWarnings("unchecked")
|
||||
List<DirectToolOutput> outputs = (List<DirectToolOutput>) actionOut.get(DIRECT_TOOL_OUTPUTS);
|
||||
assertNotNull(outputs);
|
||||
assertEquals(1, outputs.size());
|
||||
assertEquals(SECRET, outputs.get(0).fullResult(),
|
||||
"Full secret must reach DIRECT_TOOL_OUTPUTS verbatim");
|
||||
|
||||
// Critical: the ToolResponseMessage stored in MESSAGES must NOT contain the secret —
|
||||
// it must contain the placeholder, since this is what would be re-fed to the LLM
|
||||
// if the graph weren't short-circuiting.
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Message> messages = (List<Message>) actionOut.get(MESSAGES);
|
||||
assertNotNull(messages);
|
||||
assertEquals(1, messages.size());
|
||||
ToolResponseMessage tr = (ToolResponseMessage) messages.get(0);
|
||||
assertEquals(1, tr.getResponses().size());
|
||||
ToolResponseMessage.ToolResponse resp = tr.getResponses().get(0);
|
||||
// RFC-052 §2.4 contract: the placeholder is a fixed, English, business-data-free
|
||||
// sentence. Asserting the exact text doubles as a contract test — if anyone
|
||||
// changes the placeholder text this fails and the RFC needs updating too.
|
||||
assertEquals(
|
||||
"[Tool result returned directly to user. " +
|
||||
"Content withheld from model context per tool policy.]",
|
||||
resp.responseData(),
|
||||
"Tool response carried in MESSAGES must be the §2.4 placeholder, not the secret");
|
||||
assertFalse(resp.responseData().contains("12345"),
|
||||
"Sanity: the salary number must not be on the LLM-bound path");
|
||||
assertFalse(resp.responseData().contains("999-88-7777"),
|
||||
"Sanity: SSN must not be on the LLM-bound path");
|
||||
|
||||
// ===== Step 3: ObservationDispatcher decides where to route =====
|
||||
// Build a state that reflects what the graph would have AFTER ActionNode
|
||||
// (we manually merge ActionNode's output for the dispatcher input — the
|
||||
// actual graph engine does this via state merge strategies).
|
||||
Map<String, Object> stateAfterAction = new HashMap<>(initialState);
|
||||
stateAfterAction.putAll(actionOut);
|
||||
// Skip ObservationNode for simplicity — it doesn't touch our flags. Real
|
||||
// graph runs Action → Observation → Dispatcher; we verify the dispatcher
|
||||
// contract directly.
|
||||
OverAllState state2 = new OverAllState(stateAfterAction);
|
||||
|
||||
String route = dispatcher.apply(state2);
|
||||
assertEquals(FINAL_ANSWER_NODE, route,
|
||||
"Dispatcher must route RETURN_DIRECT_TRIGGERED to FinalAnswerNode, " +
|
||||
"skipping the next LLM call");
|
||||
|
||||
// ===== Step 4: FinalAnswerNode assembles the final answer =====
|
||||
Map<String, Object> finalOut = finalAnswerNode.apply(state2);
|
||||
|
||||
assertEquals(SECRET, finalOut.get(FINAL_ANSWER),
|
||||
"FinalAnswerNode must surface the direct tool's full text verbatim as the final answer");
|
||||
assertEquals("return_direct", finalOut.get(FINISH_REASON),
|
||||
"finishReason must be RETURN_DIRECT");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("RFC-052 end-to-end: mixed batch — direct tool A succeeds, non-direct tool B succeeds, plan still short-circuits")
|
||||
void fullChain_mixedBatch_directWins() throws Exception {
|
||||
ToolCallback direct = stubCallback("read_medical_record", true, args -> "PATIENT-DATA-XYZ");
|
||||
ToolCallback normal = stubCallback("get_weather", false, args -> "sunny, 22C");
|
||||
AgentToolSet toolSet = AgentToolSet.fromCallbacks(List.of(), List.of(direct, normal));
|
||||
ToolGuard alwaysAllow = (n, a) -> ToolGuardResult.allow();
|
||||
ToolExecutionExecutor executor = new ToolExecutionExecutor(toolSet, alwaysAllow, null, null);
|
||||
|
||||
ActionNode actionNode = new ActionNode(executor);
|
||||
ObservationDispatcher dispatcher = new ObservationDispatcher();
|
||||
FinalAnswerNode finalAnswerNode = new FinalAnswerNode();
|
||||
|
||||
List<AssistantMessage.ToolCall> calls = List.of(
|
||||
new AssistantMessage.ToolCall("c1", "function", "read_medical_record", "{}"),
|
||||
new AssistantMessage.ToolCall("c2", "function", "get_weather", "{}"));
|
||||
Map<String, Object> initial = new HashMap<>();
|
||||
initial.put(TOOL_CALLS, calls);
|
||||
initial.put(CONVERSATION_ID, "conv_mixed");
|
||||
initial.put(AGENT_ID, "agent_mixed");
|
||||
|
||||
Map<String, Object> actionOut = actionNode.apply(new OverAllState(initial));
|
||||
assertEquals(Boolean.TRUE, actionOut.get(RETURN_DIRECT_TRIGGERED));
|
||||
|
||||
Map<String, Object> merged = new HashMap<>(initial);
|
||||
merged.putAll(actionOut);
|
||||
OverAllState merged2 = new OverAllState(merged);
|
||||
|
||||
assertEquals(FINAL_ANSWER_NODE, dispatcher.apply(merged2),
|
||||
"Even with a non-direct tool in the batch, the direct one short-circuits");
|
||||
|
||||
Map<String, Object> finalOut = finalAnswerNode.apply(merged2);
|
||||
assertEquals("PATIENT-DATA-XYZ", finalOut.get(FINAL_ANSWER),
|
||||
"Single direct output rendered verbatim (single-output path, no headings)");
|
||||
assertEquals("return_direct", finalOut.get(FINISH_REASON));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("RFC-052 end-to-end: non-direct tool DOES NOT trigger short-circuit")
|
||||
void fullChain_nonDirectTool_runsNormalLoop() throws Exception {
|
||||
ToolCallback normal = stubCallback("get_weather", false, args -> "rainy, 12C");
|
||||
AgentToolSet toolSet = AgentToolSet.fromCallbacks(List.of(), List.of(normal));
|
||||
ToolGuard alwaysAllow = (n, a) -> ToolGuardResult.allow();
|
||||
ToolExecutionExecutor executor = new ToolExecutionExecutor(toolSet, alwaysAllow, null, null);
|
||||
|
||||
ActionNode actionNode = new ActionNode(executor);
|
||||
ObservationDispatcher dispatcher = new ObservationDispatcher();
|
||||
|
||||
AssistantMessage.ToolCall call = new AssistantMessage.ToolCall(
|
||||
"call_w", "function", "get_weather", "{}");
|
||||
Map<String, Object> initial = new HashMap<>();
|
||||
initial.put(TOOL_CALLS, List.of(call));
|
||||
initial.put(CONVERSATION_ID, "conv_w");
|
||||
initial.put(AGENT_ID, "agent_w");
|
||||
initial.put(CURRENT_ITERATION, 0);
|
||||
initial.put(MAX_ITERATIONS, 10);
|
||||
|
||||
Map<String, Object> actionOut = actionNode.apply(new OverAllState(initial));
|
||||
|
||||
// RETURN_DIRECT_TRIGGERED must NOT be set
|
||||
assertNull(actionOut.get(RETURN_DIRECT_TRIGGERED),
|
||||
"Non-direct tool must not flip RETURN_DIRECT_TRIGGERED");
|
||||
|
||||
// Dispatcher routes to REASONING_NODE for next loop iteration
|
||||
Map<String, Object> merged = new HashMap<>(initial);
|
||||
merged.putAll(actionOut);
|
||||
String route = dispatcher.apply(new OverAllState(merged));
|
||||
assertEquals(REASONING_NODE, route,
|
||||
"Without the direct flag, dispatcher must continue the ReAct loop");
|
||||
}
|
||||
|
||||
/** Stub ToolCallback with explicit returnDirect flag (mirrors the unit-test helper). */
|
||||
private static ToolCallback stubCallback(String name, boolean returnDirect,
|
||||
java.util.function.Function<String, String> handler) {
|
||||
ToolDefinition def = ToolDefinition.builder()
|
||||
.name(name)
|
||||
.description("e2e test tool " + name)
|
||||
.inputSchema("{\"type\":\"object\",\"properties\":{}}")
|
||||
.build();
|
||||
ToolMetadata md = ToolMetadata.builder().returnDirect(returnDirect).build();
|
||||
return new ToolCallback() {
|
||||
@Override public ToolDefinition getToolDefinition() { return def; }
|
||||
@Override public ToolMetadata getToolMetadata() { return md; }
|
||||
@Override public String call(String arguments) { return handler.apply(arguments); }
|
||||
@Override public String call(String arguments, ToolContext toolContext) {
|
||||
return handler.apply(arguments);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,158 @@
|
||||
package vip.mate.agent.graph;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.ai.chat.messages.AssistantMessage;
|
||||
import org.springframework.ai.chat.messages.Message;
|
||||
import org.springframework.ai.chat.messages.SystemMessage;
|
||||
import org.springframework.ai.chat.messages.UserMessage;
|
||||
import org.springframework.ai.chat.prompt.Prompt;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* RFC-049 PR-2 §2.4.1: verify the {@code lastUserIdx} boundary semantics of
|
||||
* {@link NodeStreamingChatHelper#stripThinkingFromPrompt}.
|
||||
*
|
||||
* <p>Prior-turn AssistantMessages ({@code i <= lastUserIdx}) must have their
|
||||
* {@code reasoningContent} stripped — DeepSeek's contract says "reset across
|
||||
* user turns". In-turn AssistantMessages ({@code i > lastUserIdx}) must keep
|
||||
* their thinking so DeepSeek's "pass back within the same turn" requirement
|
||||
* holds for ReAct multi-round tool calls.
|
||||
*/
|
||||
class StripThinkingBoundaryTest {
|
||||
|
||||
private static AssistantMessage assistantWithThinking(String content, String thinking) {
|
||||
AssistantMessage.Builder b = AssistantMessage.builder().content(content);
|
||||
if (thinking != null) {
|
||||
b.properties(Map.of("reasoningContent", thinking));
|
||||
}
|
||||
return b.build();
|
||||
}
|
||||
|
||||
private static String thinkingOf(Message m) {
|
||||
if (!(m instanceof AssistantMessage am)) return null;
|
||||
Object rc = am.getMetadata() != null ? am.getMetadata().get("reasoningContent") : null;
|
||||
return rc instanceof String s ? s : null;
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("No UserMessage (edge): lastUserIdx=-1 → all assistants treated as in-turn, thinking kept")
|
||||
void noUser_allKept() {
|
||||
// Edge case: when the prompt contains no UserMessage at all (e.g. system-only
|
||||
// setup or a freshly-built Prompt that hasn't received user input yet), there
|
||||
// is no prior-turn boundary, so every assistant is considered in-turn and
|
||||
// their thinking is preserved. This is the safer default — we never strip
|
||||
// without a clear cross-turn signal.
|
||||
List<Message> msgs = List.of(
|
||||
new SystemMessage("sys"),
|
||||
assistantWithThinking("a1", "think-1"),
|
||||
assistantWithThinking("a2", "think-2")
|
||||
);
|
||||
Prompt cleaned = NodeStreamingChatHelper.stripThinkingFromPrompt(new Prompt(msgs));
|
||||
assertEquals("think-1", thinkingOf(cleaned.getInstructions().get(1)));
|
||||
assertEquals("think-2", thinkingOf(cleaned.getInstructions().get(2)));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Single turn: UserMessage then assistants → all in-turn assistants keep thinking")
|
||||
void singleTurn_allInTurnKept() {
|
||||
List<Message> msgs = List.of(
|
||||
new SystemMessage("sys"),
|
||||
new UserMessage("q1"),
|
||||
assistantWithThinking("a1-tool", "think-a1"),
|
||||
assistantWithThinking("a2-final", "think-a2")
|
||||
);
|
||||
Prompt cleaned = NodeStreamingChatHelper.stripThinkingFromPrompt(new Prompt(msgs));
|
||||
assertEquals("think-a1", thinkingOf(cleaned.getInstructions().get(2)));
|
||||
assertEquals("think-a2", thinkingOf(cleaned.getInstructions().get(3)));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Case H: cross-turn stripped, in-turn preserved")
|
||||
void crossTurn_stripped_inTurn_kept() {
|
||||
// [sys, U1, A1(think1), U2, A2(think2), A3(think3)]
|
||||
// lastUserIdx = 3 (U2)
|
||||
// i=2 A1 → prior-turn → strip
|
||||
// i=4 A2 → in-turn → keep
|
||||
// i=5 A3 → in-turn → keep
|
||||
List<Message> msgs = List.of(
|
||||
new SystemMessage("sys"),
|
||||
new UserMessage("u1"),
|
||||
assistantWithThinking("a1", "think-1"),
|
||||
new UserMessage("u2"),
|
||||
assistantWithThinking("a2", "think-2"),
|
||||
assistantWithThinking("a3", "think-3")
|
||||
);
|
||||
Prompt cleaned = NodeStreamingChatHelper.stripThinkingFromPrompt(new Prompt(msgs));
|
||||
|
||||
assertNull(thinkingOf(cleaned.getInstructions().get(2)),
|
||||
"A1 is prior-turn (i=2 <= lastUserIdx=3) — thinking must be stripped");
|
||||
assertEquals("think-2", thinkingOf(cleaned.getInstructions().get(4)),
|
||||
"A2 is in-turn (i=4 > lastUserIdx=3) — thinking must be kept");
|
||||
assertEquals("think-3", thinkingOf(cleaned.getInstructions().get(5)),
|
||||
"A3 is in-turn (i=5 > lastUserIdx=3) — thinking must be kept");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Options reference is preserved by the returned Prompt (producer relies on this)")
|
||||
void optionsPreservedByReference() {
|
||||
org.springframework.ai.openai.OpenAiChatOptions opts =
|
||||
org.springframework.ai.openai.OpenAiChatOptions.builder().model("test").build();
|
||||
opts.setUser("original-user");
|
||||
|
||||
List<Message> msgs = List.of(
|
||||
new UserMessage("u1"),
|
||||
assistantWithThinking("a1", "think")
|
||||
);
|
||||
Prompt in = new Prompt(msgs, opts);
|
||||
Prompt cleaned = NodeStreamingChatHelper.stripThinkingFromPrompt(in);
|
||||
|
||||
// The returned Prompt's options must be the same instance, so the
|
||||
// producer's subsequent setUser(relayToken) is visible through cleaned too.
|
||||
assertTrue(cleaned.getOptions() == in.getOptions(),
|
||||
"stripThinkingFromPrompt must preserve the options reference");
|
||||
assertEquals("original-user",
|
||||
((org.springframework.ai.openai.OpenAiChatOptions) cleaned.getOptions()).getUser());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Assistant without thinking is untouched (no churn)")
|
||||
void noThinkingMetadata_passthrough() {
|
||||
List<Message> msgs = List.of(
|
||||
new UserMessage("u1"),
|
||||
new AssistantMessage("plain a")
|
||||
);
|
||||
Prompt cleaned = NodeStreamingChatHelper.stripThinkingFromPrompt(new Prompt(msgs));
|
||||
// Should return a Prompt with the same messages (no rebuild required)
|
||||
assertEquals(msgs.size(), cleaned.getInstructions().size());
|
||||
assertNull(thinkingOf(cleaned.getInstructions().get(1)));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Prior-turn assistant with non-thinking metadata: thinking stripped, other metadata preserved")
|
||||
void priorTurnAssistant_otherMetadataPreserved() {
|
||||
AssistantMessage priorAssistant = AssistantMessage.builder()
|
||||
.content("prior")
|
||||
.properties(Map.of("reasoningContent", "old-think", "custom-key", "custom-val"))
|
||||
.build();
|
||||
List<Message> msgs = List.of(
|
||||
new UserMessage("u1"),
|
||||
priorAssistant,
|
||||
new UserMessage("u2"),
|
||||
assistantWithThinking("current", "current-think")
|
||||
);
|
||||
Prompt cleaned = NodeStreamingChatHelper.stripThinkingFromPrompt(new Prompt(msgs));
|
||||
|
||||
Message rebuiltPrior = cleaned.getInstructions().get(1);
|
||||
assertTrue(rebuiltPrior instanceof AssistantMessage);
|
||||
AssistantMessage am = (AssistantMessage) rebuiltPrior;
|
||||
assertNull(am.getMetadata().get("reasoningContent"), "thinking must be stripped");
|
||||
assertEquals("custom-val", am.getMetadata().get("custom-key"), "other metadata must be preserved");
|
||||
}
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,165 @@
|
||||
package vip.mate.agent.graph.executor;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.springframework.ai.chat.messages.ToolResponseMessage;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* Tests for Lane D executor and config changes:
|
||||
*
|
||||
* <ul>
|
||||
* <li>D-4: ToolExecutionExecutor uses virtual thread executor</li>
|
||||
* <li>D-5: ToolResultProperties defaults updated to 16000/32000</li>
|
||||
* </ul>
|
||||
*/
|
||||
class LaneDExecutorAndConfigTest {
|
||||
|
||||
// ============================================================
|
||||
// D-4: ToolExecutionExecutor uses virtual threads
|
||||
// ============================================================
|
||||
|
||||
@Nested
|
||||
@DisplayName("D-4: ToolExecutionExecutor virtual thread pool")
|
||||
class VirtualThreadPoolTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("TOOL_EXECUTOR is a named virtual thread executor (not fixed thread pool)")
|
||||
void toolExecutorIsVirtualThreadBased() throws Exception {
|
||||
Field field = ToolExecutionExecutor.class.getDeclaredField("TOOL_EXECUTOR");
|
||||
field.setAccessible(true);
|
||||
ExecutorService executor = (ExecutorService) field.get(null);
|
||||
|
||||
assertNotNull(executor, "TOOL_EXECUTOR should not be null");
|
||||
|
||||
// Virtual thread executor class name contains "ThreadPerTaskExecutor"
|
||||
// when created via Executors.newThreadPerTaskExecutor(factory).
|
||||
String className = executor.getClass().getName();
|
||||
assertTrue(className.contains("ThreadPerTaskExecutor"),
|
||||
"Expected ThreadPerTaskExecutor (named virtual threads), but got: " + className);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Virtual threads are named 'tool-executor-N' for log traceability")
|
||||
void virtualThreadsAreNamed() throws Exception {
|
||||
Field field = ToolExecutionExecutor.class.getDeclaredField("TOOL_EXECUTOR");
|
||||
field.setAccessible(true);
|
||||
ExecutorService executor = (ExecutorService) field.get(null);
|
||||
|
||||
// Submit a task and capture the thread name
|
||||
var future = executor.submit(() -> Thread.currentThread().getName());
|
||||
String threadName = future.get();
|
||||
|
||||
assertTrue(threadName.startsWith("tool-executor-"),
|
||||
"Virtual thread should be named 'tool-executor-N', but got: " + threadName);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// D-5: ToolResultProperties defaults
|
||||
// ============================================================
|
||||
|
||||
@Nested
|
||||
@DisplayName("D-5: ToolResultProperties defaults updated")
|
||||
class ToolResultPropertiesDefaultsTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("perResultThresholdChars default is 16000 (was 4000)")
|
||||
void perResultThresholdCharsDefault() {
|
||||
ToolResultProperties props = new ToolResultProperties();
|
||||
assertEquals(16000, props.getPerResultThresholdChars(),
|
||||
"Default perResultThresholdChars should be 16000");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("perTurnBudgetChars default is 32000 (was 16000)")
|
||||
void perTurnBudgetCharsDefault() {
|
||||
ToolResultProperties props = new ToolResultProperties();
|
||||
assertEquals(32000, props.getPerTurnBudgetChars(),
|
||||
"Default perTurnBudgetChars should be 32000");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Other defaults remain unchanged")
|
||||
void otherDefaultsUnchanged() {
|
||||
ToolResultProperties props = new ToolResultProperties();
|
||||
assertTrue(props.isEnabled(), "enabled should default to true");
|
||||
assertEquals(800, props.getPreviewHeadChars(),
|
||||
"previewHeadChars should still default to 800");
|
||||
assertEquals(2500, props.getExcludedToolInlineChars(),
|
||||
"excludedToolInlineChars should default to 2500");
|
||||
assertEquals("", props.getStorageBaseDir(),
|
||||
"storageBaseDir should still default to empty string");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Large results above 16000 still trigger spill (threshold boundary)")
|
||||
void thresholdBoundary() {
|
||||
ToolResultProperties props = new ToolResultProperties();
|
||||
// Results <= 16000 should NOT spill
|
||||
assertTrue(15000 <= props.getPerResultThresholdChars(),
|
||||
"A 15000-char result should be within threshold");
|
||||
// Results > 16000 should spill
|
||||
assertTrue(17000 > props.getPerResultThresholdChars(),
|
||||
"A 17000-char result should exceed threshold");
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Tool result aggregate budget")
|
||||
class ToolResultAggregateBudgetTests {
|
||||
|
||||
@TempDir
|
||||
Path tempDir;
|
||||
|
||||
@Test
|
||||
@DisplayName("excluded retrieval tools are compacted when aggregate budget is exceeded")
|
||||
void excludedToolResultsCompactWhenTurnBudgetIsExceeded() {
|
||||
ToolResultProperties props = new ToolResultProperties();
|
||||
props.setStorageBaseDir(tempDir.toString());
|
||||
props.setPerTurnBudgetChars(5000);
|
||||
props.setExcludedToolInlineChars(1200);
|
||||
props.setExcludedTools(List.of("read_file"));
|
||||
ToolResultStorage storage = new ToolResultStorage(props);
|
||||
|
||||
String largeRead = "line\n".repeat(1600);
|
||||
List<ToolResponseMessage.ToolResponse> responses = List.of(
|
||||
new ToolResponseMessage.ToolResponse("call-1", "read_file", largeRead),
|
||||
new ToolResponseMessage.ToolResponse("call-2", "read_file", largeRead + "tail")
|
||||
);
|
||||
|
||||
List<ToolResponseMessage.ToolResponse> compacted =
|
||||
storage.enforceTurnBudget(responses, "conv-test", tempDir.toString());
|
||||
|
||||
assertTrue(compacted.stream().mapToInt(r -> r.responseData().length()).sum() < 5000);
|
||||
assertTrue(compacted.stream().allMatch(r ->
|
||||
r.responseData().contains("tool result compacted for model context")));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("large eligible tool result is spilled before entering model context")
|
||||
void largeEligibleToolResultIsSpilled() {
|
||||
ToolResultProperties props = new ToolResultProperties();
|
||||
props.setStorageBaseDir(tempDir.toString());
|
||||
props.setPerResultThresholdChars(1000);
|
||||
props.setPreviewHeadChars(120);
|
||||
ToolResultStorage storage = new ToolResultStorage(props);
|
||||
|
||||
String largeResult = "0123456789\n".repeat(500);
|
||||
String contextResult = storage.persistIfOversized(
|
||||
largeResult, "web_search", "call-1", "conv-test", tempDir.toString());
|
||||
|
||||
assertTrue(contextResult.startsWith(ToolResultStorage.SPILL_MARKER_PREFIX));
|
||||
assertTrue(contextResult.length() < largeResult.length());
|
||||
assertTrue(contextResult.contains("full_chars=" + largeResult.length()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,157 @@
|
||||
package vip.mate.agent.graph.executor;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.ai.chat.messages.AssistantMessage;
|
||||
import org.springframework.ai.chat.messages.ToolResponseMessage;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* Verifies the per-response tool_calls cap that protects the executor
|
||||
* against runaway batch sizes from misbehaving models
|
||||
* (StreamLake / kat-coder-pro-v1 emit 50+ in one shot).
|
||||
*
|
||||
* <p>This is a pure unit test on the package-private static helper; no
|
||||
* Spring context, no mocks. The behavior under cap matters most for two
|
||||
* cases: (1) the LLM must still receive paired tool responses for every
|
||||
* dropped tool_call (some providers reject otherwise), and (2) the order
|
||||
* of executed calls must remain stable so the agent's logic isn't
|
||||
* reshuffled by the cap.
|
||||
*/
|
||||
class ToolExecutionExecutorCapToolCallsTest {
|
||||
|
||||
private static AssistantMessage.ToolCall call(String id, String name) {
|
||||
return new AssistantMessage.ToolCall(id, "function", name, "{}");
|
||||
}
|
||||
|
||||
private static List<AssistantMessage.ToolCall> sequentialCalls(int n) {
|
||||
List<AssistantMessage.ToolCall> out = new ArrayList<>(n);
|
||||
for (int i = 0; i < n; i++) {
|
||||
out.add(call("call_" + i, "tool_" + i));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── Pass-through cases ─────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
@DisplayName("null input returns empty list, no truncation")
|
||||
void nullInputPassesThrough() {
|
||||
ToolExecutionExecutor.CappedToolCalls capped =
|
||||
ToolExecutionExecutor.capToolCalls(null, 16);
|
||||
|
||||
assertNotNull(capped);
|
||||
assertNotNull(capped.effective());
|
||||
assertTrue(capped.effective().isEmpty());
|
||||
assertTrue(capped.truncatedResponses().isEmpty());
|
||||
assertFalse(capped.wasTruncated());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("empty input is returned untouched")
|
||||
void emptyInputPassesThrough() {
|
||||
List<AssistantMessage.ToolCall> input = List.of();
|
||||
ToolExecutionExecutor.CappedToolCalls capped =
|
||||
ToolExecutionExecutor.capToolCalls(input, 16);
|
||||
|
||||
assertSame(input, capped.effective(), "no copy when within cap");
|
||||
assertTrue(capped.truncatedResponses().isEmpty());
|
||||
assertFalse(capped.wasTruncated());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("size at cap is returned untouched (boundary)")
|
||||
void atCapPassesThrough() {
|
||||
List<AssistantMessage.ToolCall> input = sequentialCalls(16);
|
||||
ToolExecutionExecutor.CappedToolCalls capped =
|
||||
ToolExecutionExecutor.capToolCalls(input, 16);
|
||||
|
||||
assertSame(input, capped.effective(),
|
||||
"at-cap input must not be sublist'd — surprising allocation");
|
||||
assertTrue(capped.truncatedResponses().isEmpty());
|
||||
assertFalse(capped.wasTruncated());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("size below cap is returned untouched")
|
||||
void belowCapPassesThrough() {
|
||||
List<AssistantMessage.ToolCall> input = sequentialCalls(5);
|
||||
ToolExecutionExecutor.CappedToolCalls capped =
|
||||
ToolExecutionExecutor.capToolCalls(input, 16);
|
||||
|
||||
assertSame(input, capped.effective());
|
||||
assertFalse(capped.wasTruncated());
|
||||
}
|
||||
|
||||
// ── Truncation cases ───────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
@DisplayName("over-cap input is trimmed; first N kept in original order")
|
||||
void overCapTrimmed() {
|
||||
List<AssistantMessage.ToolCall> input = sequentialCalls(20);
|
||||
ToolExecutionExecutor.CappedToolCalls capped =
|
||||
ToolExecutionExecutor.capToolCalls(input, 16);
|
||||
|
||||
assertTrue(capped.wasTruncated());
|
||||
assertEquals(16, capped.effective().size());
|
||||
// Order preservation matters — the agent's reasoning may depend on
|
||||
// the LLM's chosen sequence (e.g. read-then-write); reshuffling the
|
||||
// first-N is silently breaking.
|
||||
for (int i = 0; i < 16; i++) {
|
||||
assertEquals("call_" + i, capped.effective().get(i).id());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("each dropped tool_call gets a synthetic ToolResponseMessage with matching id")
|
||||
void droppedCallsGetTruncatedResponses() {
|
||||
List<AssistantMessage.ToolCall> input = sequentialCalls(20);
|
||||
ToolExecutionExecutor.CappedToolCalls capped =
|
||||
ToolExecutionExecutor.capToolCalls(input, 16);
|
||||
|
||||
// 4 dropped calls (indices 16..19) → 4 synthetic responses.
|
||||
assertEquals(4, capped.truncatedResponses().size());
|
||||
|
||||
for (int i = 0; i < 4; i++) {
|
||||
ToolResponseMessage.ToolResponse resp = capped.truncatedResponses().get(i);
|
||||
assertEquals("call_" + (16 + i), resp.id(),
|
||||
"synthetic response must reuse the dropped tool_call's id "
|
||||
+ "or providers will reject the next turn");
|
||||
assertEquals("tool_" + (16 + i), resp.name());
|
||||
assertTrue(resp.responseData().contains("[truncated]"),
|
||||
"response body must signal truncation so the LLM can reissue");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("synthetic response body mentions both requested and cap counts")
|
||||
void truncatedResponseBodyExplainsCounts() {
|
||||
List<AssistantMessage.ToolCall> input = sequentialCalls(50);
|
||||
ToolExecutionExecutor.CappedToolCalls capped =
|
||||
ToolExecutionExecutor.capToolCalls(input, 16);
|
||||
|
||||
String body = capped.truncatedResponses().get(0).responseData();
|
||||
assertTrue(body.contains("50"), "body should mention requested count: " + body);
|
||||
assertTrue(body.contains("16"), "body should mention cap value: " + body);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("custom cap value honored — same logic at any threshold")
|
||||
void customCapHonored() {
|
||||
List<AssistantMessage.ToolCall> input = sequentialCalls(10);
|
||||
ToolExecutionExecutor.CappedToolCalls capped =
|
||||
ToolExecutionExecutor.capToolCalls(input, 3);
|
||||
|
||||
assertTrue(capped.wasTruncated());
|
||||
assertEquals(3, capped.effective().size());
|
||||
assertEquals(7, capped.truncatedResponses().size());
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,226 @@
|
||||
package vip.mate.agent.graph.executor;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.ai.chat.messages.AssistantMessage;
|
||||
import org.springframework.ai.chat.messages.ToolResponseMessage;
|
||||
import org.springframework.ai.chat.model.ToolContext;
|
||||
import org.springframework.ai.tool.ToolCallback;
|
||||
import org.springframework.ai.tool.definition.ToolDefinition;
|
||||
import org.springframework.ai.tool.metadata.ToolMetadata;
|
||||
import vip.mate.agent.AgentToolSet;
|
||||
import vip.mate.agent.GraphEventPublisher;
|
||||
import vip.mate.agent.graph.state.DirectToolOutput;
|
||||
import vip.mate.tool.guard.ToolGuard;
|
||||
import vip.mate.tool.guard.ToolGuardResult;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* RFC-052 PR-1/PR-2 end-to-end test for {@link ToolExecutionExecutor}.
|
||||
*
|
||||
* <p>The contract under test:
|
||||
* <ol>
|
||||
* <li>A {@code returnDirect=true} tool's full result is captured in
|
||||
* {@link ToolExecutionExecutor.ToolExecutionResult#directOutputs()}.</li>
|
||||
* <li>The corresponding {@link ToolResponseMessage.ToolResponse} carries the
|
||||
* fixed placeholder, not the sensitive content.</li>
|
||||
* <li>An {@code EVENT_TOOL_DIRECT_RESULT} event is emitted with the full text
|
||||
* and {@code renderAs=assistant_message}.</li>
|
||||
* <li>Non-direct tools in the same batch keep their existing behavior.</li>
|
||||
* </ol>
|
||||
*/
|
||||
class ToolExecutionExecutorReturnDirectTest {
|
||||
|
||||
private static final String SECRET = "EMPLOYEE-SALARY: Alice=12345, Bob=67890";
|
||||
|
||||
private ToolExecutionExecutor newExecutor(ToolCallback... callbacks) {
|
||||
AgentToolSet toolSet = AgentToolSet.fromCallbacks(List.of(), List.of(callbacks));
|
||||
ToolGuard alwaysAllow = (n, a) -> ToolGuardResult.allow();
|
||||
// streamTracker=null is supported throughout executor; null approval
|
||||
// service is fine when guard never returns NEEDS_APPROVAL.
|
||||
return new ToolExecutionExecutor(toolSet, alwaysAllow, null, null);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("RFC-052: returnDirect tool result reaches user verbatim and stays out of LLM context")
|
||||
void directTool_fullResultCapturedAndPlaceholderInResponse() {
|
||||
ToolCallback direct = stubCallback("query_employee_salary", true, args -> SECRET);
|
||||
ToolExecutionExecutor executor = newExecutor(direct);
|
||||
|
||||
AssistantMessage.ToolCall call = new AssistantMessage.ToolCall(
|
||||
"call_1", "function", "query_employee_salary", "{}");
|
||||
ToolExecutionExecutor.ToolExecutionResult result =
|
||||
executor.execute(List.of(call), "conv_1", "agent_1", false, "user_1", null);
|
||||
|
||||
// (1) directOutputs aggregates the full text
|
||||
assertEquals(1, result.directOutputs().size());
|
||||
DirectToolOutput out = result.directOutputs().get(0);
|
||||
assertEquals("query_employee_salary", out.toolName());
|
||||
assertEquals(SECRET, out.fullResult(), "Full result must be preserved verbatim");
|
||||
assertTrue(result.hasDirectOutputs());
|
||||
|
||||
// (2) ToolResponseMessage carries the placeholder (LLM-safe)
|
||||
assertEquals(1, result.responses().size());
|
||||
ToolResponseMessage.ToolResponse resp = result.responses().get(0);
|
||||
assertEquals(ToolExecutionExecutor.DIRECT_TOOL_PLACEHOLDER, resp.responseData(),
|
||||
"ToolResponseMessage must carry the placeholder, not the sensitive payload");
|
||||
assertFalse(resp.responseData().contains("EMPLOYEE-SALARY"),
|
||||
"Sensitive substring must not appear in tool response");
|
||||
|
||||
// (3) tool_direct_result event was emitted with renderAs=assistant_message + full text
|
||||
var directEvents = result.events().stream()
|
||||
.filter(e -> GraphEventPublisher.EVENT_TOOL_DIRECT_RESULT.equals(e.type()))
|
||||
.toList();
|
||||
assertEquals(1, directEvents.size(), "exactly one tool_direct_result event expected");
|
||||
var data = directEvents.get(0).data();
|
||||
assertEquals("call_1", data.get("toolCallId"));
|
||||
assertEquals("query_employee_salary", data.get("toolName"));
|
||||
assertEquals(SECRET, data.get("result"));
|
||||
assertEquals("assistant_message", data.get("renderAs"));
|
||||
|
||||
// (4) no tool_call_completed event for the direct tool — direct path replaces it
|
||||
boolean hasCompleted = result.events().stream()
|
||||
.anyMatch(e -> GraphEventPublisher.EVENT_TOOL_COMPLETE.equals(e.type()));
|
||||
assertFalse(hasCompleted, "direct path replaces tool_call_completed; double-emit would " +
|
||||
"leak the placeholder into UI as a tool result card");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("RFC-052: non-direct tool keeps existing behavior (no direct outputs)")
|
||||
void nonDirectTool_keepsBaselineBehavior() {
|
||||
ToolCallback normal = stubCallback("get_weather", false, args -> "sunny, 22C");
|
||||
ToolExecutionExecutor executor = newExecutor(normal);
|
||||
|
||||
AssistantMessage.ToolCall call = new AssistantMessage.ToolCall(
|
||||
"call_w", "function", "get_weather", "{}");
|
||||
ToolExecutionExecutor.ToolExecutionResult result =
|
||||
executor.execute(List.of(call), "conv_w", "agent_w", false, "user_w", null);
|
||||
|
||||
assertFalse(result.hasDirectOutputs(), "no direct tool ran; directOutputs must be empty");
|
||||
assertTrue(result.directOutputs().isEmpty());
|
||||
assertEquals(1, result.responses().size());
|
||||
assertEquals("sunny, 22C", result.responses().get(0).responseData());
|
||||
|
||||
// no direct event
|
||||
boolean hasDirect = result.events().stream()
|
||||
.anyMatch(e -> GraphEventPublisher.EVENT_TOOL_DIRECT_RESULT.equals(e.type()));
|
||||
assertFalse(hasDirect);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("RFC-052: returnDirect tool throwing yields generic message (no exception details leak)")
|
||||
void directTool_throwing_genericErrorMessage() {
|
||||
ToolCallback throwingDirect = stubCallback("query_employee_salary", true, args -> {
|
||||
throw new RuntimeException("OracleDriver: connection refused, secret-conn-str=user/PWD123@db");
|
||||
});
|
||||
ToolExecutionExecutor executor = newExecutor(throwingDirect);
|
||||
|
||||
AssistantMessage.ToolCall call = new AssistantMessage.ToolCall(
|
||||
"call_e", "function", "query_employee_salary", "{}");
|
||||
ToolExecutionExecutor.ToolExecutionResult result =
|
||||
executor.execute(List.of(call), "conv_e", "agent_e", false, "user_e", null);
|
||||
|
||||
// No directOutputs — exception aborted before the direct branch
|
||||
assertFalse(result.hasDirectOutputs());
|
||||
assertEquals(1, result.responses().size());
|
||||
String content = result.responses().get(0).responseData();
|
||||
assertEquals("Tool execution failed (details withheld per returnDirect policy)", content,
|
||||
"Direct-tool exception text must be replaced with a generic placeholder");
|
||||
assertFalse(content.contains("PWD123"), "Sensitive substring from exception must not leak");
|
||||
assertFalse(content.contains("OracleDriver"), "Stack/connection details must not leak");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("RFC-052: pre-approved direct tool replays through direct path")
|
||||
void executePreApproved_directTool_takesDirectPath() {
|
||||
ToolCallback direct = stubCallback("query_secret", true, args -> "SECRET-PAYLOAD-123");
|
||||
ToolExecutionExecutor executor = newExecutor(direct);
|
||||
|
||||
AssistantMessage.ToolCall toolCall = new AssistantMessage.ToolCall(
|
||||
"call_a", "function", "query_secret", "{}");
|
||||
java.util.List<GraphEventPublisher.GraphEvent> events = new java.util.ArrayList<>();
|
||||
java.util.List<DirectToolOutput> directOutputs = new java.util.ArrayList<>();
|
||||
|
||||
ToolResponseMessage.ToolResponse response = executor.executePreApproved(
|
||||
toolCall, "{}", events, "conv_a", null, directOutputs);
|
||||
|
||||
// Without the directOutputs collector wired, executePreApproved would
|
||||
// have leaked SECRET-PAYLOAD-123 into the response. With the fix:
|
||||
assertEquals(ToolExecutionExecutor.DIRECT_TOOL_PLACEHOLDER, response.responseData(),
|
||||
"Pre-approved direct tool must produce a placeholder response");
|
||||
assertEquals(1, directOutputs.size());
|
||||
assertEquals("SECRET-PAYLOAD-123", directOutputs.get(0).fullResult());
|
||||
|
||||
// tool_direct_result event present
|
||||
assertTrue(events.stream()
|
||||
.anyMatch(e -> GraphEventPublisher.EVENT_TOOL_DIRECT_RESULT.equals(e.type())));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("RFC-052: legacy executePreApproved (no collector) does NOT silently leak — placeholder still applied")
|
||||
void executePreApproved_legacyOverload_stillProducesPlaceholder() {
|
||||
ToolCallback direct = stubCallback("query_secret", true, args -> "SECRET-OTHER-456");
|
||||
ToolExecutionExecutor executor = newExecutor(direct);
|
||||
|
||||
AssistantMessage.ToolCall toolCall = new AssistantMessage.ToolCall(
|
||||
"call_b", "function", "query_secret", "{}");
|
||||
java.util.List<GraphEventPublisher.GraphEvent> events = new java.util.ArrayList<>();
|
||||
|
||||
// 5-arg overload with no directOutputs collector — directOutputs is
|
||||
// dropped on the floor, but the placeholder still keeps the LLM safe.
|
||||
ToolResponseMessage.ToolResponse response = executor.executePreApproved(
|
||||
toolCall, "{}", events, "conv_b", null);
|
||||
|
||||
assertEquals(ToolExecutionExecutor.DIRECT_TOOL_PLACEHOLDER, response.responseData());
|
||||
assertFalse(response.responseData().contains("SECRET-OTHER-456"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("RFC-052: mixed batch — any direct tool triggers direct outputs while non-direct keeps result")
|
||||
void mixedBatch_directAndNonDirect() {
|
||||
ToolCallback direct = stubCallback("read_medical_record", true, args -> "PATIENT-DATA-XYZ");
|
||||
ToolCallback normal = stubCallback("get_weather", false, args -> "rainy, 12C");
|
||||
ToolExecutionExecutor executor = newExecutor(direct, normal);
|
||||
|
||||
List<AssistantMessage.ToolCall> calls = List.of(
|
||||
new AssistantMessage.ToolCall("c1", "function", "read_medical_record", "{}"),
|
||||
new AssistantMessage.ToolCall("c2", "function", "get_weather", "{}"));
|
||||
|
||||
ToolExecutionExecutor.ToolExecutionResult result =
|
||||
executor.execute(calls, "conv_m", "agent_m", false, "user_m", null);
|
||||
|
||||
assertTrue(result.hasDirectOutputs());
|
||||
assertEquals(1, result.directOutputs().size());
|
||||
assertEquals("read_medical_record", result.directOutputs().get(0).toolName());
|
||||
assertEquals("PATIENT-DATA-XYZ", result.directOutputs().get(0).fullResult());
|
||||
|
||||
// non-direct response still contains its own data; placeholder is only on direct
|
||||
assertEquals(2, result.responses().size());
|
||||
ToolResponseMessage.ToolResponse directResp = result.responses().get(0);
|
||||
ToolResponseMessage.ToolResponse normalResp = result.responses().get(1);
|
||||
assertEquals(ToolExecutionExecutor.DIRECT_TOOL_PLACEHOLDER, directResp.responseData());
|
||||
assertEquals("rainy, 12C", normalResp.responseData());
|
||||
}
|
||||
|
||||
/** Build a minimal ToolCallback stub with an explicit returnDirect flag. */
|
||||
private static ToolCallback stubCallback(String name, boolean returnDirect,
|
||||
java.util.function.Function<String, String> handler) {
|
||||
ToolDefinition def = ToolDefinition.builder()
|
||||
.name(name)
|
||||
.description("test tool " + name)
|
||||
.inputSchema("{\"type\":\"object\",\"properties\":{}}")
|
||||
.build();
|
||||
ToolMetadata md = ToolMetadata.builder().returnDirect(returnDirect).build();
|
||||
return new ToolCallback() {
|
||||
@Override public ToolDefinition getToolDefinition() { return def; }
|
||||
@Override public ToolMetadata getToolMetadata() { return md; }
|
||||
@Override public String call(String arguments) { return handler.apply(arguments); }
|
||||
@Override public String call(String arguments, ToolContext toolContext) {
|
||||
return handler.apply(arguments);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,185 @@
|
||||
package vip.mate.agent.graph.executor;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.ai.chat.messages.AssistantMessage;
|
||||
import org.springframework.ai.chat.model.ToolContext;
|
||||
import org.springframework.ai.tool.ToolCallback;
|
||||
import org.springframework.ai.tool.definition.ToolDefinition;
|
||||
import org.springframework.ai.tool.metadata.ToolMetadata;
|
||||
import vip.mate.agent.AgentToolSet;
|
||||
import vip.mate.skill.runtime.SkillRuntimeService;
|
||||
import vip.mate.skill.runtime.model.ResolvedSkill;
|
||||
import vip.mate.tool.guard.ToolGuard;
|
||||
import vip.mate.tool.guard.ToolGuardResult;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* Auto-redirect: when the LLM mistakenly calls a skill name as if it were
|
||||
* a tool, the executor should transparently invoke {@code readSkillFile}
|
||||
* on its behalf and return the SKILL.md content as the tool result.
|
||||
*
|
||||
* <p>Why: smaller models (qwen-turbo et al.) often can't act on a
|
||||
* "this is a Skill, not a Tool — go read X first" textual hint. They
|
||||
* generate a polite "let me get that" reply and end the turn without
|
||||
* any further tool call, leaving the user stuck. With auto-redirect
|
||||
* the model receives runnable instructions on its very first attempt.
|
||||
*
|
||||
* <p>The hint-only path is still preserved for the case where
|
||||
* {@code readSkillFile} isn't bound to the agent (covered by
|
||||
* {@link ToolExecutionExecutorSkillHintTest}).
|
||||
*/
|
||||
class ToolExecutionExecutorSkillAutoRedirectTest {
|
||||
|
||||
private static final String SKILL_MD =
|
||||
"---\nname: tencent-meeting-mcp\n---\n\n# Quick start\nrunSkillScript scripts/setup.sh\n";
|
||||
|
||||
private ToolExecutionExecutor newExecutor(ToolCallback... callbacks) {
|
||||
AgentToolSet toolSet = AgentToolSet.fromCallbacks(List.of(), List.of(callbacks));
|
||||
ToolGuard alwaysAllow = (n, a) -> ToolGuardResult.allow();
|
||||
return new ToolExecutionExecutor(toolSet, alwaysAllow, null, null);
|
||||
}
|
||||
|
||||
private SkillRuntimeService skillRuntimeWith(String... activeNames) {
|
||||
SkillRuntimeService svc = mock(SkillRuntimeService.class);
|
||||
List<ResolvedSkill> skills = java.util.Arrays.stream(activeNames).map(name -> {
|
||||
ResolvedSkill s = mock(ResolvedSkill.class);
|
||||
when(s.getName()).thenReturn(name);
|
||||
return s;
|
||||
}).toList();
|
||||
when(svc.getActiveSkills()).thenReturn(skills);
|
||||
return svc;
|
||||
}
|
||||
|
||||
/** Captures the args that the auto-redirected readSkillFile receives. */
|
||||
private static class CapturingReadSkillFile {
|
||||
final AtomicReference<String> lastArgs = new AtomicReference<>();
|
||||
final ToolCallback callback;
|
||||
|
||||
CapturingReadSkillFile(String returnContent) {
|
||||
ToolDefinition def = ToolDefinition.builder()
|
||||
.name("readSkillFile")
|
||||
.description("test stub")
|
||||
.inputSchema("{\"type\":\"object\",\"properties\":{}}")
|
||||
.build();
|
||||
ToolMetadata md = ToolMetadata.builder().returnDirect(false).build();
|
||||
callback = new ToolCallback() {
|
||||
@Override public ToolDefinition getToolDefinition() { return def; }
|
||||
@Override public ToolMetadata getToolMetadata() { return md; }
|
||||
@Override public String call(String arguments) {
|
||||
lastArgs.set(arguments);
|
||||
return returnContent;
|
||||
}
|
||||
@Override public String call(String arguments, ToolContext ctx) {
|
||||
return call(arguments);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("skill-as-tool call gets auto-redirected to readSkillFile when the tool is bound")
|
||||
void skillCallAutoRedirects() {
|
||||
CapturingReadSkillFile rsf = new CapturingReadSkillFile(SKILL_MD);
|
||||
ToolExecutionExecutor executor = newExecutor(rsf.callback);
|
||||
executor.setSkillRuntimeService(skillRuntimeWith("tencent-meeting-mcp"));
|
||||
|
||||
String llmArgs = "{\"action\":\"create\",\"subject\":\"AI讨论会\"}";
|
||||
var result = executor.execute(
|
||||
List.of(new AssistantMessage.ToolCall(
|
||||
"call_1", "function", "tencent-meeting-mcp", llmArgs)),
|
||||
"conv", "agent", false, "user", null);
|
||||
|
||||
assertEquals(1, result.responses().size());
|
||||
String response = result.responses().get(0).responseData();
|
||||
|
||||
// (1) readSkillFile was invoked with the skill's name and SKILL.md
|
||||
String forwarded = rsf.lastArgs.get();
|
||||
assertNotNull(forwarded, "readSkillFile must have been invoked transparently");
|
||||
assertTrue(forwarded.contains("\"skillName\":\"tencent-meeting-mcp\""), forwarded);
|
||||
assertTrue(forwarded.contains("\"filePath\":\"SKILL.md\""), forwarded);
|
||||
|
||||
// (2) Response carries the SKILL.md content
|
||||
assertTrue(response.contains("# Quick start"),
|
||||
"Response should embed SKILL.md content: " + response);
|
||||
assertTrue(response.contains("runSkillScript scripts/setup.sh"),
|
||||
"Response should embed the runnable example from SKILL.md");
|
||||
|
||||
// (3) Response carries the [auto-redirect] nudge so the LLM understands
|
||||
// why it didn't get a function-call result of the shape it expected
|
||||
assertTrue(response.contains("[auto-redirect]"),
|
||||
"Response should declare the auto-redirect: " + response);
|
||||
assertTrue(response.contains("runSkillScript"),
|
||||
"Response should tell the LLM what to call next");
|
||||
|
||||
// (4) Original payload is echoed back so the LLM doesn't have to re-derive
|
||||
// args before calling runSkillScript
|
||||
assertTrue(response.contains("AI讨论会"),
|
||||
"Original LLM args should be echoed in the redirect: " + response);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("skill-as-tool call falls through to hint when readSkillFile is NOT bound to this agent")
|
||||
void skillCallWithoutReadSkillFileFallsThroughToHint() {
|
||||
// Empty tool set — readSkillFile not registered for this agent
|
||||
ToolExecutionExecutor executor = newExecutor();
|
||||
executor.setSkillRuntimeService(skillRuntimeWith("tencent-meeting-mcp"));
|
||||
|
||||
var result = executor.execute(
|
||||
List.of(new AssistantMessage.ToolCall(
|
||||
"call_2", "function", "tencent-meeting-mcp", "{}")),
|
||||
"conv", "agent", false, "user", null);
|
||||
|
||||
String response = result.responses().get(0).responseData();
|
||||
assertTrue(response.contains("Skill, not a Tool"),
|
||||
"Without readSkillFile, executor must fall back to the textual hint: " + response);
|
||||
assertFalse(response.contains("[auto-redirect]"),
|
||||
"No redirect should have happened: " + response);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("non-skill unknown tool name still produces the bare 'Tool not found' message")
|
||||
void unknownToolKeepsBareError() {
|
||||
CapturingReadSkillFile rsf = new CapturingReadSkillFile(SKILL_MD);
|
||||
ToolExecutionExecutor executor = newExecutor(rsf.callback);
|
||||
executor.setSkillRuntimeService(skillRuntimeWith("tencent-meeting-mcp"));
|
||||
|
||||
var result = executor.execute(
|
||||
List.of(new AssistantMessage.ToolCall(
|
||||
"call_3", "function", "made_up_tool", "{}")),
|
||||
"conv", "agent", false, "user", null);
|
||||
|
||||
String response = result.responses().get(0).responseData();
|
||||
assertEquals("Tool not found: made_up_tool", response);
|
||||
assertNull(rsf.lastArgs.get(),
|
||||
"readSkillFile must NOT be invoked for non-skill names");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("skill name with special chars in the LLM args is JSON-escaped before forwarding")
|
||||
void specialCharsInArgsAreEscaped() {
|
||||
CapturingReadSkillFile rsf = new CapturingReadSkillFile(SKILL_MD);
|
||||
ToolExecutionExecutor executor = newExecutor(rsf.callback);
|
||||
// Skill name with double quotes / backslash to verify the inline JSON
|
||||
// we build for the readSkillFile call escapes them properly.
|
||||
executor.setSkillRuntimeService(skillRuntimeWith("weird\"name\\skill"));
|
||||
|
||||
var result = executor.execute(
|
||||
List.of(new AssistantMessage.ToolCall(
|
||||
"call_4", "function", "weird\"name\\skill", "{}")),
|
||||
"conv", "agent", false, "user", null);
|
||||
|
||||
// If escaping were broken, readSkillFile would have rejected the
|
||||
// malformed JSON and returned an error. The response carrying SKILL_MD
|
||||
// proves the forwarded args parsed cleanly.
|
||||
assertTrue(result.responses().get(0).responseData().contains("# Quick start"));
|
||||
String forwarded = rsf.lastArgs.get();
|
||||
assertTrue(forwarded.contains("weird\\\"name\\\\skill"),
|
||||
"Forwarded args should JSON-escape quotes and backslashes: " + forwarded);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,122 @@
|
||||
package vip.mate.agent.graph.executor;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.ai.chat.messages.AssistantMessage;
|
||||
import org.springframework.ai.tool.ToolCallback;
|
||||
import vip.mate.agent.AgentToolSet;
|
||||
import vip.mate.skill.runtime.SkillRuntimeService;
|
||||
import vip.mate.skill.runtime.model.ResolvedSkill;
|
||||
import vip.mate.tool.guard.ToolGuard;
|
||||
import vip.mate.tool.guard.ToolGuardResult;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* Issue #46: when the LLM mis-calls a skill name as a tool, the executor
|
||||
* should return a precise hint explaining that the name is a Skill (not a
|
||||
* Tool) and how to invoke it via {@code readSkillFile} — instead of the
|
||||
* dead-end "Tool not found" string that gave the model nothing to act on.
|
||||
*/
|
||||
class ToolExecutionExecutorSkillHintTest {
|
||||
|
||||
private ToolExecutionExecutor newExecutor(ToolCallback... callbacks) {
|
||||
AgentToolSet toolSet = AgentToolSet.fromCallbacks(List.of(), List.of(callbacks));
|
||||
ToolGuard alwaysAllow = (n, a) -> ToolGuardResult.allow();
|
||||
return new ToolExecutionExecutor(toolSet, alwaysAllow, null, null);
|
||||
}
|
||||
|
||||
private SkillRuntimeService skillRuntimeWith(String... activeNames) {
|
||||
SkillRuntimeService svc = mock(SkillRuntimeService.class);
|
||||
List<ResolvedSkill> skills = java.util.Arrays.stream(activeNames).map(name -> {
|
||||
ResolvedSkill s = mock(ResolvedSkill.class);
|
||||
when(s.getName()).thenReturn(name);
|
||||
return s;
|
||||
}).toList();
|
||||
when(svc.getActiveSkills()).thenReturn(skills);
|
||||
return svc;
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("issue#46: tool name matching an active skill yields skill-aware hint")
|
||||
void unknownToolMatchingSkill_returnsHint() {
|
||||
ToolExecutionExecutor executor = newExecutor(); // empty tool set
|
||||
executor.setSkillRuntimeService(skillRuntimeWith("RedisOps", "browser_cdp"));
|
||||
|
||||
ToolExecutionExecutor.ToolExecutionResult result = executor.execute(
|
||||
List.of(new AssistantMessage.ToolCall("call_1", "function", "RedisOps", "{}")),
|
||||
"conv", "agent", false, "user", null);
|
||||
|
||||
assertEquals(1, result.responses().size());
|
||||
String response = result.responses().get(0).responseData();
|
||||
assertTrue(response.contains("Skill, not a Tool"),
|
||||
"Response should declare the name is a Skill: " + response);
|
||||
assertTrue(response.contains("readSkillFile(skillName=\"RedisOps\""),
|
||||
"Response should suggest the concrete invocation: " + response);
|
||||
assertFalse(response.equals("Tool not found: RedisOps"),
|
||||
"Response should NOT fall back to the bare error string");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("issue#46: case-insensitive skill match — LLMs sometimes alter casing")
|
||||
void unknownToolCaseInsensitiveSkillMatch_returnsHint() {
|
||||
ToolExecutionExecutor executor = newExecutor();
|
||||
executor.setSkillRuntimeService(skillRuntimeWith("RedisOps"));
|
||||
|
||||
ToolExecutionExecutor.ToolExecutionResult result = executor.execute(
|
||||
List.of(new AssistantMessage.ToolCall("call_2", "function", "redisops", "{}")),
|
||||
"conv", "agent", false, "user", null);
|
||||
|
||||
String response = result.responses().get(0).responseData();
|
||||
assertTrue(response.contains("Skill, not a Tool"),
|
||||
"Lowercase 'redisops' should still match active skill 'RedisOps': " + response);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("issue#46: tool name not matching any skill keeps the bare 'Tool not found' message")
|
||||
void unknownToolWithNoSkillMatch_keepsBareError() {
|
||||
ToolExecutionExecutor executor = newExecutor();
|
||||
executor.setSkillRuntimeService(skillRuntimeWith("RedisOps", "browser_cdp"));
|
||||
|
||||
ToolExecutionExecutor.ToolExecutionResult result = executor.execute(
|
||||
List.of(new AssistantMessage.ToolCall("call_3", "function", "totally_made_up_tool", "{}")),
|
||||
"conv", "agent", false, "user", null);
|
||||
|
||||
String response = result.responses().get(0).responseData();
|
||||
assertEquals("Tool not found: totally_made_up_tool", response,
|
||||
"When the name doesn't match any skill, the executor must fall back to the bare error");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("issue#46: when skillRuntimeService is unset (legacy/test path), behavior is unchanged")
|
||||
void unknownToolWithoutSkillRuntime_keepsBareError() {
|
||||
ToolExecutionExecutor executor = newExecutor();
|
||||
// intentionally do NOT call setSkillRuntimeService
|
||||
|
||||
ToolExecutionExecutor.ToolExecutionResult result = executor.execute(
|
||||
List.of(new AssistantMessage.ToolCall("call_4", "function", "RedisOps", "{}")),
|
||||
"conv", "agent", false, "user", null);
|
||||
|
||||
String response = result.responses().get(0).responseData();
|
||||
assertEquals("Tool not found: RedisOps", response,
|
||||
"Without a wired SkillRuntimeService, the executor must keep the legacy bare error");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("issue#46: pre-approved replay path also gets the skill-aware hint")
|
||||
void preApprovedReplayUnknownTool_returnsHint() {
|
||||
ToolExecutionExecutor executor = newExecutor();
|
||||
executor.setSkillRuntimeService(skillRuntimeWith("RedisOps"));
|
||||
|
||||
java.util.List<vip.mate.agent.GraphEventPublisher.GraphEvent> events = new java.util.ArrayList<>();
|
||||
var response = executor.executePreApproved(
|
||||
new AssistantMessage.ToolCall("call_5", "function", "RedisOps", "{}"),
|
||||
"{}", events, "conv", null);
|
||||
|
||||
assertTrue(response.responseData().contains("Skill, not a Tool"),
|
||||
"Pre-approved replay should also produce the skill-aware hint: " + response.responseData());
|
||||
}
|
||||
}
|
||||
@ -4,7 +4,12 @@ import com.alibaba.cloud.ai.graph.OverAllState;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import vip.mate.agent.GraphEventPublisher;
|
||||
import vip.mate.agent.graph.state.DirectToolOutput;
|
||||
import vip.mate.agent.graph.state.SourceEvidenceLedger;
|
||||
import vip.mate.tool.document.GeneratedFileCache;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
@ -90,4 +95,396 @@ class FinalAnswerNodeTest {
|
||||
assertEquals("Failed to generate a response, please retry.", result.get(FINAL_ANSWER));
|
||||
assertEquals("error_fallback", result.get(FINISH_REASON));
|
||||
}
|
||||
|
||||
// ========== RFC-052 returnDirect ==========
|
||||
|
||||
@Test
|
||||
@DisplayName("RFC-052: single direct tool output becomes the final answer verbatim")
|
||||
void directSingle_verbatim() throws Exception {
|
||||
DirectToolOutput out = new DirectToolOutput(
|
||||
"call_1", "query_employee_salary",
|
||||
"Alice's salary is 12345.", System.currentTimeMillis());
|
||||
OverAllState state = new OverAllState(Map.of(
|
||||
RETURN_DIRECT_TRIGGERED, true,
|
||||
DIRECT_TOOL_OUTPUTS, List.of(out)
|
||||
));
|
||||
|
||||
Map<String, Object> result = node.apply(state);
|
||||
|
||||
assertEquals("Alice's salary is 12345.", result.get(FINAL_ANSWER),
|
||||
"Direct tool result must reach the user verbatim, no LLM rewriting");
|
||||
assertEquals("return_direct", result.get(FINISH_REASON));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("RFC-052: multiple direct outputs are joined with tool-name headings")
|
||||
void directMultiple_joinedWithHeadings() throws Exception {
|
||||
DirectToolOutput a = new DirectToolOutput(
|
||||
"call_1", "tool_a", "result_a", System.currentTimeMillis());
|
||||
DirectToolOutput b = new DirectToolOutput(
|
||||
"call_2", "tool_b", "result_b", System.currentTimeMillis());
|
||||
OverAllState state = new OverAllState(Map.of(
|
||||
RETURN_DIRECT_TRIGGERED, true,
|
||||
DIRECT_TOOL_OUTPUTS, List.of(a, b)
|
||||
));
|
||||
|
||||
Map<String, Object> result = node.apply(state);
|
||||
|
||||
String answer = (String) result.get(FINAL_ANSWER);
|
||||
assertNotNull(answer);
|
||||
assertTrue(answer.startsWith("### tool_a\nresult_a"),
|
||||
"first heading + body should appear at the top");
|
||||
assertTrue(answer.contains("### tool_b\nresult_b"),
|
||||
"second heading + body should follow");
|
||||
assertEquals("return_direct", result.get(FINISH_REASON));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("RFC-052: trigger flag without outputs falls through to default assembly")
|
||||
void directTriggerEmpty_fallsThrough() throws Exception {
|
||||
OverAllState state = new OverAllState(Map.of(
|
||||
RETURN_DIRECT_TRIGGERED, true,
|
||||
FINAL_ANSWER, "fallback content"
|
||||
));
|
||||
|
||||
Map<String, Object> result = node.apply(state);
|
||||
|
||||
// Without DIRECT_TOOL_OUTPUTS we should NOT short-circuit to RETURN_DIRECT —
|
||||
// the existing FINAL_ANSWER path handles it as NORMAL.
|
||||
assertEquals("fallback content", result.get(FINAL_ANSWER));
|
||||
assertEquals("normal", result.get(FINISH_REASON));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("RFC-052: direct path takes precedence over draft / existing answer / approval")
|
||||
void directBranch_highestPriority() throws Exception {
|
||||
DirectToolOutput out = new DirectToolOutput(
|
||||
"call_1", "tool_x", "direct text", System.currentTimeMillis());
|
||||
OverAllState state = new OverAllState(Map.of(
|
||||
RETURN_DIRECT_TRIGGERED, true,
|
||||
DIRECT_TOOL_OUTPUTS, List.of(out),
|
||||
FINAL_ANSWER, "must be ignored",
|
||||
FINAL_ANSWER_DRAFT, "must also be ignored"
|
||||
));
|
||||
|
||||
Map<String, Object> result = node.apply(state);
|
||||
|
||||
assertEquals("direct text", result.get(FINAL_ANSWER));
|
||||
assertEquals("return_direct", result.get(FINISH_REASON));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("源码证据不足时降级 finishReason 并提示未验证引用")
|
||||
void unsupportedSourceReferencesBecomeEvidenceInsufficient() throws Exception {
|
||||
SourceEvidenceLedger ledger = SourceEvidenceLedger.empty()
|
||||
.withSourcePath("src/main/java/vip/mate/skill/SkillController.java");
|
||||
OverAllState state = new OverAllState(Map.of(
|
||||
SOURCE_EVIDENCE_LEDGER, ledger,
|
||||
FINAL_ANSWER, "SkillController.java 是入口,SkillServiceImpl.java 负责业务逻辑。"
|
||||
));
|
||||
|
||||
Map<String, Object> result = node.apply(state);
|
||||
|
||||
assertEquals("evidence_insufficient", result.get(FINISH_REASON));
|
||||
assertTrue(((String) result.get(FINAL_ANSWER)).contains("SkillServiceImpl.java"));
|
||||
assertTrue(((String) result.get(FINAL_ANSWER)).contains("证据不足"));
|
||||
}
|
||||
|
||||
// ========== finish_reason GraphEvent (P1: must ride PENDING_EVENTS, not SSE bypass) ==========
|
||||
|
||||
/**
|
||||
* Pull the {@code finish_reason} GraphEvent attached to a node output.
|
||||
* Returns null when no such event was emitted.
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
private static GraphEventPublisher.GraphEvent pickFinishReasonEvent(Map<String, Object> output) {
|
||||
Object raw = output.get(PENDING_EVENTS);
|
||||
if (!(raw instanceof List<?> list)) return null;
|
||||
for (Object item : list) {
|
||||
if (item instanceof GraphEventPublisher.GraphEvent ev
|
||||
&& GraphEventPublisher.EVENT_FINISH_REASON.equals(ev.type())) {
|
||||
return ev;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("normal path emits finish_reason GraphEvent on PENDING_EVENTS so the accumulator can persist it")
|
||||
void normalPath_emitsFinishReasonEvent() throws Exception {
|
||||
OverAllState state = new OverAllState(Map.of(
|
||||
FINAL_ANSWER, "正常回答"
|
||||
));
|
||||
|
||||
Map<String, Object> result = node.apply(state);
|
||||
|
||||
GraphEventPublisher.GraphEvent ev = pickFinishReasonEvent(result);
|
||||
assertNotNull(ev, "FinalAnswerNode must attach a finish_reason GraphEvent (NORMAL path)");
|
||||
assertEquals("normal", ev.data().get("reason"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("incomplete path also emits finish_reason GraphEvent (regression for the SSE-bypass bug)")
|
||||
void incompletePath_emitsFinishReasonEvent() throws Exception {
|
||||
// Simulates ReasoningNode handing INCOMPLETE through to FinalAnswerNode
|
||||
// (e.g. repetition-truncated partial). The earlier fix wired this via
|
||||
// streamTracker.broadcastObject which was an SSE-only bypass — the
|
||||
// accumulator never saw it. Now it MUST ride PENDING_EVENTS.
|
||||
OverAllState state = new OverAllState(Map.of(
|
||||
FINAL_ANSWER, "已经流式输出的部分内容…",
|
||||
FINISH_REASON, "incomplete"
|
||||
));
|
||||
|
||||
Map<String, Object> result = node.apply(state);
|
||||
|
||||
GraphEventPublisher.GraphEvent ev = pickFinishReasonEvent(result);
|
||||
assertNotNull(ev, "INCOMPLETE finish_reason must reach the channel via PENDING_EVENTS");
|
||||
assertEquals("incomplete", ev.data().get("reason"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("RFC-052 RETURN_DIRECT path emits finish_reason GraphEvent")
|
||||
void returnDirectPath_emitsFinishReasonEvent() throws Exception {
|
||||
DirectToolOutput out = new DirectToolOutput(
|
||||
"call_1", "tool_x", "direct text", System.currentTimeMillis());
|
||||
OverAllState state = new OverAllState(Map.of(
|
||||
RETURN_DIRECT_TRIGGERED, true,
|
||||
DIRECT_TOOL_OUTPUTS, List.of(out)
|
||||
));
|
||||
|
||||
Map<String, Object> result = node.apply(state);
|
||||
|
||||
GraphEventPublisher.GraphEvent ev = pickFinishReasonEvent(result);
|
||||
assertNotNull(ev, "RETURN_DIRECT path must attach a finish_reason GraphEvent");
|
||||
assertEquals("return_direct", ev.data().get("reason"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("AWAITING_APPROVAL path emits finish_reason GraphEvent (NORMAL while paused)")
|
||||
void awaitingApprovalPath_emitsFinishReasonEvent() throws Exception {
|
||||
OverAllState state = new OverAllState(Map.of(
|
||||
AWAITING_APPROVAL, true,
|
||||
STREAMED_CONTENT, "我现在要做 X 操作。"
|
||||
));
|
||||
|
||||
Map<String, Object> result = node.apply(state);
|
||||
|
||||
GraphEventPublisher.GraphEvent ev = pickFinishReasonEvent(result);
|
||||
assertNotNull(ev, "AWAITING_APPROVAL path must attach a finish_reason GraphEvent");
|
||||
assertEquals("normal", ev.data().get("reason"),
|
||||
"Approval pause is treated as a normal pause; the resolved decision will emit a fresh event on replay");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("evidence_insufficient path emits finish_reason GraphEvent with the downgraded reason")
|
||||
void evidenceInsufficientPath_emitsFinishReasonEvent() throws Exception {
|
||||
SourceEvidenceLedger ledger = SourceEvidenceLedger.empty()
|
||||
.withSourcePath("src/main/java/vip/mate/skill/SkillController.java");
|
||||
OverAllState state = new OverAllState(Map.of(
|
||||
SOURCE_EVIDENCE_LEDGER, ledger,
|
||||
FINAL_ANSWER, "SkillController.java 是入口,SkillServiceImpl.java 负责业务逻辑。"
|
||||
));
|
||||
|
||||
Map<String, Object> result = node.apply(state);
|
||||
|
||||
GraphEventPublisher.GraphEvent ev = pickFinishReasonEvent(result);
|
||||
assertNotNull(ev);
|
||||
assertEquals("evidence_insufficient", ev.data().get("reason"),
|
||||
"Downgraded finishReason must surface in the GraphEvent, not the original NORMAL");
|
||||
}
|
||||
|
||||
// ========== fake-URL guard ==========
|
||||
//
|
||||
// Without the guard, a hallucinated /api/v1/files/generated/{uuid} URL
|
||||
// surfaces verbatim to every channel — IM clients render a clickable
|
||||
// link that 404s, and users save the 404 HTML body as a .docx which
|
||||
// they then report as "corrupted file". Putting the guard at the
|
||||
// FinalAnswerNode terminal means EVERY channel (Web SSE, Slack,
|
||||
// DingTalk, WeCom, Telegram, …) sees the same scrubbed text.
|
||||
|
||||
@Test
|
||||
@DisplayName("fake-URL guard: hallucinated generated-file URL → user-visible warning")
|
||||
void fakeUrl_replacedWithWarning() throws Exception {
|
||||
FinalAnswerNode guarded = new FinalAnswerNode(new GeneratedFileCache());
|
||||
OverAllState state = new OverAllState(Map.of(
|
||||
FINAL_ANSWER, "您的文档已生成: /api/v1/files/generated/a1b2c3d4-e5f6-7890-abcd-ef1234567890"
|
||||
));
|
||||
|
||||
Map<String, Object> result = guarded.apply(state);
|
||||
|
||||
String answer = (String) result.get(FINAL_ANSWER);
|
||||
assertFalse(answer.contains("/api/v1/files/generated/"),
|
||||
"fake URL must not survive in the persisted answer; got: " + answer);
|
||||
assertTrue(answer.contains(GeneratedFileCache.MISSING_REFERENCE_NOTICE),
|
||||
"user-visible warning must appear in place of the fake URL; got: " + answer);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("fake-URL guard: real cached URL passes through so channel adapters can rewrite it")
|
||||
void realUrl_leftIntact() throws Exception {
|
||||
GeneratedFileCache cache = new GeneratedFileCache();
|
||||
String id = cache.put("real-bytes".getBytes(), "report.pdf", "application/pdf");
|
||||
FinalAnswerNode guarded = new FinalAnswerNode(cache);
|
||||
|
||||
OverAllState state = new OverAllState(Map.of(
|
||||
FINAL_ANSWER, "下载: /api/v1/files/generated/" + id
|
||||
));
|
||||
|
||||
Map<String, Object> result = guarded.apply(state);
|
||||
|
||||
// Cached URLs survive verbatim — downstream WeCom / Slack / etc.
|
||||
// adapters can still rewrite them into native attachments.
|
||||
assertTrue(((String) result.get(FINAL_ANSWER))
|
||||
.contains("/api/v1/files/generated/" + id),
|
||||
"live cached URL must pass through for downstream native-attachment rewrite");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("fake-URL guard: also fires on RETURN_DIRECT path (tool output may also hallucinate)")
|
||||
void fakeUrl_scrubbedOnDirectPath() throws Exception {
|
||||
FinalAnswerNode guarded = new FinalAnswerNode(new GeneratedFileCache());
|
||||
DirectToolOutput out = new DirectToolOutput(
|
||||
"call_1", "tool_x",
|
||||
"see /api/v1/files/generated/never-rendered-uuid",
|
||||
System.currentTimeMillis());
|
||||
OverAllState state = new OverAllState(Map.of(
|
||||
RETURN_DIRECT_TRIGGERED, true,
|
||||
DIRECT_TOOL_OUTPUTS, List.of(out)
|
||||
));
|
||||
|
||||
Map<String, Object> result = guarded.apply(state);
|
||||
|
||||
String answer = (String) result.get(FINAL_ANSWER);
|
||||
assertFalse(answer.contains("never-rendered-uuid"),
|
||||
"RETURN_DIRECT path must also scrub; got: " + answer);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("fake-URL guard: no-cache constructor (legacy callers, narrow tests) is a no-op")
|
||||
void noCache_passThrough() throws Exception {
|
||||
// FinalAnswerNode without an injected cache must not throw — the
|
||||
// narrow unit tests that construct the node with the no-arg ctor
|
||||
// still need to work. The trade-off: tests that don't exercise
|
||||
// file outputs simply skip the scrub. Production wiring always
|
||||
// passes a real cache from AgentGraphBuilder.
|
||||
FinalAnswerNode unguarded = new FinalAnswerNode();
|
||||
OverAllState state = new OverAllState(Map.of(
|
||||
FINAL_ANSWER, "/api/v1/files/generated/anything"
|
||||
));
|
||||
Map<String, Object> result = unguarded.apply(state);
|
||||
assertEquals("/api/v1/files/generated/anything", result.get(FINAL_ANSWER));
|
||||
}
|
||||
|
||||
// ========== feedback_event recovery affordance ==========
|
||||
//
|
||||
// After NodeStreamingChatHelper has exhausted its TLS/IO retry budget
|
||||
// and the turn ends in ERROR_FALLBACK, the user is left staring at
|
||||
// red "[错误] …" text with no recovery affordance. FinalAnswerNode
|
||||
// attaches a feedback_event GraphEvent so the frontend can render
|
||||
// retry/regenerate/report buttons next to the failed bubble — and
|
||||
// the event is persisted into message metadata so a page reload
|
||||
// doesn't make the affordance vanish.
|
||||
|
||||
/** Pull the feedback_event GraphEvent attached to a node output. */
|
||||
@SuppressWarnings("unchecked")
|
||||
private static GraphEventPublisher.GraphEvent pickFeedbackEvent(Map<String, Object> output) {
|
||||
Object raw = output.get(PENDING_EVENTS);
|
||||
if (!(raw instanceof List<?> list)) return null;
|
||||
for (Object item : list) {
|
||||
if (item instanceof GraphEventPublisher.GraphEvent ev
|
||||
&& GraphEventPublisher.EVENT_FEEDBACK.equals(ev.type())) {
|
||||
return ev;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("ERROR_FALLBACK turn emits feedback_event with retry/regenerate/report actions")
|
||||
void errorFallback_emitsFeedbackEvent() throws Exception {
|
||||
// Mirrors the production path: ReasoningNode hands a fatal-error
|
||||
// finalAnswer + ERROR_FALLBACK finishReason to FinalAnswerNode.
|
||||
OverAllState state = new OverAllState(Map.of(
|
||||
FINAL_ANSWER, "[错误] LLM 调用失败: bad_record_mac",
|
||||
FINISH_REASON, "error_fallback"
|
||||
));
|
||||
|
||||
Map<String, Object> result = node.apply(state);
|
||||
|
||||
GraphEventPublisher.GraphEvent ev = pickFeedbackEvent(result);
|
||||
assertNotNull(ev, "ERROR_FALLBACK turn must attach a feedback_event for the UI");
|
||||
assertEquals("ERROR_FALLBACK", ev.data().get("errorType"));
|
||||
assertEquals("[错误] LLM 调用失败: bad_record_mac", ev.data().get("errorMessage"));
|
||||
Object actions = ev.data().get("actions");
|
||||
assertTrue(actions instanceof List<?>);
|
||||
assertEquals(List.of("retry", "regenerate", "report"), actions);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("ERROR_FALLBACK still emits the standard finish_reason event alongside feedback_event")
|
||||
void errorFallback_alsoEmitsFinishReason() throws Exception {
|
||||
// The two events ride the same PENDING_EVENTS list. Existing
|
||||
// consumers (memory gate, channel accumulator, message metadata
|
||||
// persistence) read finish_reason; the new feedback_event is
|
||||
// additive — losing finish_reason here would silently break
|
||||
// those consumers.
|
||||
OverAllState state = new OverAllState(Map.of(
|
||||
FINAL_ANSWER, "[错误] 认证失败: Invalid API Key",
|
||||
FINISH_REASON, "error_fallback"
|
||||
));
|
||||
|
||||
Map<String, Object> result = node.apply(state);
|
||||
|
||||
GraphEventPublisher.GraphEvent fr = pickFinishReasonEvent(result);
|
||||
assertNotNull(fr, "finish_reason event must remain on PENDING_EVENTS");
|
||||
assertEquals("error_fallback", fr.data().get("reason"));
|
||||
|
||||
GraphEventPublisher.GraphEvent fb = pickFeedbackEvent(result);
|
||||
assertNotNull(fb, "feedback_event must coexist with finish_reason on the same output");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("NORMAL turn does NOT emit feedback_event (no recovery affordance needed)")
|
||||
void normalTurn_noFeedbackEvent() throws Exception {
|
||||
OverAllState state = new OverAllState(Map.of(
|
||||
FINAL_ANSWER, "正常回答"
|
||||
));
|
||||
|
||||
Map<String, Object> result = node.apply(state);
|
||||
|
||||
assertNull(pickFeedbackEvent(result),
|
||||
"Successful turns must not attach feedback_event — would render misleading retry buttons");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("INCOMPLETE turn does NOT emit feedback_event (handled by its own card)")
|
||||
void incompleteTurn_noFeedbackEvent() throws Exception {
|
||||
// INCOMPLETE has its own dedicated UI card ("regenerate" button
|
||||
// wired via finishReason=incomplete). Adding feedback_event there
|
||||
// would duplicate the affordance and confuse users.
|
||||
OverAllState state = new OverAllState(Map.of(
|
||||
FINAL_ANSWER, "已经流式输出的部分内容…",
|
||||
FINISH_REASON, "incomplete"
|
||||
));
|
||||
|
||||
Map<String, Object> result = node.apply(state);
|
||||
|
||||
assertNull(pickFeedbackEvent(result),
|
||||
"INCOMPLETE has its own card; must not also surface feedback_event");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("STOPPED (user-initiated abort) does NOT emit feedback_event")
|
||||
void stoppedTurn_noFeedbackEvent() throws Exception {
|
||||
// User clicked stop. They don't need a "retry" prompt — the
|
||||
// partial output is the explicit signal they asked for.
|
||||
OverAllState state = new OverAllState(Map.of(
|
||||
FINAL_ANSWER, "我刚在生成…",
|
||||
FINISH_REASON, "stopped"
|
||||
));
|
||||
|
||||
Map<String, Object> result = node.apply(state);
|
||||
|
||||
assertNull(pickFeedbackEvent(result));
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,10 +5,14 @@ import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.ai.chat.messages.AssistantMessage;
|
||||
import org.springframework.ai.chat.messages.Message;
|
||||
import org.springframework.ai.chat.messages.SystemMessage;
|
||||
import org.springframework.ai.chat.model.ChatModel;
|
||||
import org.springframework.ai.chat.prompt.Prompt;
|
||||
import org.springframework.ai.tool.ToolCallback;
|
||||
import vip.mate.agent.AgentToolSet;
|
||||
import vip.mate.agent.graph.NodeStreamingChatHelper;
|
||||
import vip.mate.agent.graph.state.SourceEvidenceLedger;
|
||||
import vip.mate.channel.web.ChatStreamTracker;
|
||||
|
||||
import java.util.HashMap;
|
||||
@ -17,6 +21,7 @@ import java.util.Map;
|
||||
import java.util.concurrent.CancellationException;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentCaptor.forClass;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static vip.mate.agent.graph.state.MateClawStateKeys.*;
|
||||
@ -98,6 +103,38 @@ class ReasoningNodeOutputTest {
|
||||
assertEquals("回答内容", output.get(FINAL_ANSWER));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("源码证据不足的 final answer:原文进 streamedContent,警告作为 finalAnswer 追加")
|
||||
void evidenceInsufficientFinalAnswer_splitsPersistedContentAndWarning() throws Exception {
|
||||
NodeStreamingChatHelper.StreamResult result = new NodeStreamingChatHelper.StreamResult(
|
||||
"SkillController.java 是入口,SkillServiceImpl.java 负责业务。", "",
|
||||
new AssistantMessage("SkillController.java 是入口,SkillServiceImpl.java 负责业务。"),
|
||||
List.of(), false, 100, 50);
|
||||
when(streamingHelper.streamCall(any(), any(), anyString(), anyString())).thenReturn(result);
|
||||
Map<String, Object> stateMap = new HashMap<>();
|
||||
stateMap.put(CONVERSATION_ID, "test-conv");
|
||||
stateMap.put(SYSTEM_PROMPT, "you are a helper");
|
||||
stateMap.put(USER_MESSAGE, "分析源码");
|
||||
stateMap.put(MESSAGES, List.of());
|
||||
stateMap.put(CURRENT_ITERATION, 3);
|
||||
stateMap.put(MAX_ITERATIONS, 10);
|
||||
stateMap.put(LLM_CALL_COUNT, 5);
|
||||
stateMap.put(FORCED_TOOL_CALL, "");
|
||||
stateMap.put(SOURCE_EVIDENCE_LEDGER, SourceEvidenceLedger.empty()
|
||||
.withSourcePath("src/main/java/vip/mate/skill/controller/SkillController.java"));
|
||||
|
||||
Map<String, Object> output = createNode().apply(new OverAllState(stateMap));
|
||||
|
||||
assertControlFlagsCleared(output, "evidenceInsufficientFinalAnswer");
|
||||
assertEquals("evidence_insufficient", output.get(FINISH_REASON));
|
||||
assertEquals("SkillController.java 是入口,SkillServiceImpl.java 负责业务。",
|
||||
output.get(STREAMED_CONTENT));
|
||||
assertTrue(((String) output.get(FINAL_ANSWER)).contains("证据不足"));
|
||||
assertTrue(((String) output.get(FINAL_ANSWER)).contains("SkillServiceImpl.java"));
|
||||
assertEquals(false, output.get(CONTENT_STREAMED),
|
||||
"warning suffix should be broadcast and persisted as a visible final delta");
|
||||
}
|
||||
|
||||
// ===== 工具调用 =====
|
||||
|
||||
@Test
|
||||
@ -138,6 +175,33 @@ class ReasoningNodeOutputTest {
|
||||
assertEquals("error_fallback", output.get(FINISH_REASON));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("thinking-only no-content 路径:标 INCOMPLETE 并附带可重试提示")
|
||||
void thinkingOnlyCap_preservedAsIncomplete() throws Exception {
|
||||
// Simulates the "深度思考 ... 5.4k chars never finishes" symptom:
|
||||
// helper disposes the stream after THINKING_ONLY_HARD_CAP_CHARS of
|
||||
// reasoning_content with zero visible content/tools. ReasoningNode
|
||||
// surfaces a short fallback line and preserves the thinking transcript.
|
||||
String thinkingTranscript = "我先读 X,再读 Y,再读 Z…".repeat(64);
|
||||
NodeStreamingChatHelper.StreamResult result = new NodeStreamingChatHelper.StreamResult(
|
||||
"", thinkingTranscript, new AssistantMessage(""),
|
||||
List.of(), false, 0, 600, true, "thinking_only_no_content",
|
||||
NodeStreamingChatHelper.ErrorType.UNKNOWN);
|
||||
when(streamingHelper.streamCall(any(), any(), anyString(), anyString())).thenReturn(result);
|
||||
|
||||
Map<String, Object> output = createNode().apply(buildStaleState());
|
||||
|
||||
assertControlFlagsCleared(output, "thinkingOnlyCap");
|
||||
assertLlmCallCountWritten(output, "thinkingOnlyCap");
|
||||
assertEquals("incomplete", output.get(FINISH_REASON));
|
||||
String answer = (String) output.get(FINAL_ANSWER);
|
||||
assertNotNull(answer);
|
||||
assertTrue(answer.contains("思考阶段"),
|
||||
"Fallback line should explain the thinking-only loop to the user");
|
||||
assertEquals(thinkingTranscript, output.get(FINAL_THINKING),
|
||||
"Thinking transcript must be preserved for the UI's collapse panel");
|
||||
}
|
||||
|
||||
// ===== CancellationException (no content stop) =====
|
||||
|
||||
@Test
|
||||
|
||||
@ -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"));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,346 @@
|
||||
package vip.mate.agent.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import vip.mate.agent.AgentService;
|
||||
import vip.mate.agent.binding.service.AgentBindingService;
|
||||
import vip.mate.agent.model.AgentEntity;
|
||||
import vip.mate.agent.model.TemplateDTO;
|
||||
import vip.mate.exception.MateClawException;
|
||||
import vip.mate.skill.model.SkillEntity;
|
||||
import vip.mate.skill.repository.SkillMapper;
|
||||
import vip.mate.tool.model.AvailableToolDTO;
|
||||
import vip.mate.tool.service.AvailableToolService;
|
||||
import vip.mate.workspace.document.WorkspaceFileService;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anyList;
|
||||
import static org.mockito.ArgumentMatchers.argThat;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.doReturn;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.spy;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* Hire-time pre-binding behavior for {@link TemplateService#applyTemplate}.
|
||||
*
|
||||
* <p>The contract being pinned: a template that declares
|
||||
* {@code defaultSkillSlugs} / {@code defaultToolNames} produces an agent that
|
||||
* already has those capabilities wired, and references that can't be
|
||||
* resolved (slug not in this workspace, tool not in the picker) are dropped
|
||||
* silently — the hire MUST still succeed so a partially-installed
|
||||
* environment doesn't break onboarding.
|
||||
*/
|
||||
class TemplateServiceBindingTest {
|
||||
|
||||
private static final long WORKSPACE = 1L;
|
||||
private static final long CREATOR = 7L;
|
||||
private static final long CREATED_AGENT_ID = 999L;
|
||||
|
||||
private AgentService agentService;
|
||||
private WorkspaceFileService workspaceFileService;
|
||||
private AgentBindingService agentBindingService;
|
||||
private SkillMapper skillMapper;
|
||||
private AvailableToolService availableToolService;
|
||||
private TemplateService service;
|
||||
private TemplateService spyService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
agentService = mock(AgentService.class);
|
||||
workspaceFileService = mock(WorkspaceFileService.class);
|
||||
agentBindingService = mock(AgentBindingService.class);
|
||||
skillMapper = mock(SkillMapper.class);
|
||||
availableToolService = mock(AvailableToolService.class);
|
||||
|
||||
// createAgent stamps an id and echoes the entity back, matching the
|
||||
// real DAO contract the production code relies on.
|
||||
when(agentService.createAgent(any(AgentEntity.class))).thenAnswer(inv -> {
|
||||
AgentEntity a = inv.getArgument(0);
|
||||
a.setId(CREATED_AGENT_ID);
|
||||
return a;
|
||||
});
|
||||
|
||||
service = new TemplateService(
|
||||
agentService,
|
||||
workspaceFileService,
|
||||
new ObjectMapper(),
|
||||
agentBindingService,
|
||||
skillMapper,
|
||||
availableToolService);
|
||||
spyService = spy(service);
|
||||
}
|
||||
|
||||
/** Build a minimal template; tests append bind lists. */
|
||||
private TemplateDTO baseTemplate(String id) {
|
||||
TemplateDTO t = new TemplateDTO();
|
||||
t.setId(id);
|
||||
t.setName(id);
|
||||
t.setDescription("test");
|
||||
t.setAgentType("react");
|
||||
t.setMaxIterations(10);
|
||||
t.setSystemPrompt("## Role\ntest");
|
||||
return t;
|
||||
}
|
||||
|
||||
/** Stub the in-memory template registry so the test owns the data. */
|
||||
private void registerTemplate(TemplateDTO template) {
|
||||
doReturn(List.of(template)).when(spyService).listTemplates();
|
||||
}
|
||||
|
||||
private SkillEntity skillRow(long id, String slug) {
|
||||
SkillEntity s = new SkillEntity();
|
||||
s.setId(id);
|
||||
s.setName(slug);
|
||||
s.setWorkspaceId(WORKSPACE);
|
||||
return s;
|
||||
}
|
||||
|
||||
private AvailableToolDTO availableTool(String name) {
|
||||
AvailableToolDTO dto = new AvailableToolDTO();
|
||||
dto.setName(name);
|
||||
dto.setAvailable(true);
|
||||
return dto;
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("declared skill slugs resolve to ids and pre-bind on the new agent")
|
||||
void preBindsDeclaredSkillSlugs() {
|
||||
TemplateDTO t = baseTemplate("data-analyst-stub");
|
||||
t.setDefaultSkillSlugs(List.of("sql_query", "xlsx"));
|
||||
registerTemplate(t);
|
||||
|
||||
when(skillMapper.selectOne(any(LambdaQueryWrapper.class)))
|
||||
.thenReturn(skillRow(101L, "sql_query"))
|
||||
.thenReturn(skillRow(202L, "xlsx"));
|
||||
|
||||
AgentEntity created = spyService.applyTemplate("data-analyst-stub", WORKSPACE, CREATOR, null);
|
||||
|
||||
assertNotNull(created.getId());
|
||||
verify(agentBindingService, times(1))
|
||||
.setSkillBindings(eq(CREATED_AGENT_ID), argThat(ids ->
|
||||
ids.size() == 2 && ids.contains(101L) && ids.contains(202L)));
|
||||
// No tool bindings declared → no tool side-effects.
|
||||
verify(agentBindingService, never()).setToolBindings(anyLong(), anyList());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("missing slugs are skipped without aborting the hire")
|
||||
void skipsMissingSlugsAndStillHires() {
|
||||
TemplateDTO t = baseTemplate("partial-stub");
|
||||
t.setDefaultSkillSlugs(List.of("ghost-skill", "sql_query"));
|
||||
registerTemplate(t);
|
||||
|
||||
when(skillMapper.selectOne(any(LambdaQueryWrapper.class)))
|
||||
.thenReturn(null) // ghost-skill not in workspace
|
||||
.thenReturn(skillRow(101L, "sql_query"));
|
||||
|
||||
AgentEntity created = spyService.applyTemplate("partial-stub", WORKSPACE, CREATOR, null);
|
||||
|
||||
assertNotNull(created.getId());
|
||||
// Only the resolvable slug makes it into the binding call.
|
||||
verify(agentBindingService, times(1))
|
||||
.setSkillBindings(eq(CREATED_AGENT_ID), argThat(ids ->
|
||||
ids.size() == 1 && ids.contains(101L)));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("when every slug is unknown, setSkillBindings is never called and the agent still exists")
|
||||
void noSlugsResolveSoNoBindCall() {
|
||||
TemplateDTO t = baseTemplate("all-ghost-stub");
|
||||
t.setDefaultSkillSlugs(List.of("ghost-a", "ghost-b"));
|
||||
registerTemplate(t);
|
||||
|
||||
when(skillMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(null);
|
||||
|
||||
AgentEntity created = spyService.applyTemplate("all-ghost-stub", WORKSPACE, CREATOR, null);
|
||||
|
||||
assertNotNull(created.getId());
|
||||
// Empty resolved list → caller must NOT issue an empty
|
||||
// setSkillBindings (which would otherwise wipe out future bindings
|
||||
// post-create if any race wrote them in between).
|
||||
verify(agentBindingService, never()).setSkillBindings(anyLong(), anyList());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("legacy templates with no binding fields behave as before")
|
||||
void noBindingFieldsLeavesAgentUntouched() {
|
||||
TemplateDTO t = baseTemplate("legacy-stub");
|
||||
// Neither defaultSkillSlugs nor defaultToolNames set.
|
||||
registerTemplate(t);
|
||||
|
||||
AgentEntity created = spyService.applyTemplate("legacy-stub", WORKSPACE, CREATOR, null);
|
||||
|
||||
assertNotNull(created.getId());
|
||||
verify(agentBindingService, never()).setSkillBindings(anyLong(), anyList());
|
||||
verify(agentBindingService, never()).setToolBindings(anyLong(), anyList());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("tool names are pre-filtered through the picker before binding")
|
||||
void toolBindingsFilterAgainstPicker() {
|
||||
TemplateDTO t = baseTemplate("tool-stub");
|
||||
t.setDefaultToolNames(List.of("search", "ghost_tool", "browser_use"));
|
||||
registerTemplate(t);
|
||||
|
||||
when(availableToolService.listAvailable()).thenReturn(List.of(
|
||||
availableTool("search"),
|
||||
availableTool("browser_use")));
|
||||
|
||||
AgentEntity created = spyService.applyTemplate("tool-stub", WORKSPACE, CREATOR, null);
|
||||
|
||||
assertNotNull(created.getId());
|
||||
// ghost_tool is not in the picker → filtered. The remaining two
|
||||
// pass through; setToolBindings's own validator would otherwise
|
||||
// throw on the unknown name and abort the entire bind call.
|
||||
verify(agentBindingService, times(1))
|
||||
.setToolBindings(eq(CREATED_AGENT_ID), argThat(names ->
|
||||
names.size() == 2
|
||||
&& names.contains("search")
|
||||
&& names.contains("browser_use")
|
||||
&& !names.contains("ghost_tool")));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("picker failure during apply skips tool binding instead of breaking the hire")
|
||||
void pickerFailureDoesNotBreakHire() {
|
||||
TemplateDTO t = baseTemplate("picker-down-stub");
|
||||
t.setDefaultToolNames(List.of("search"));
|
||||
registerTemplate(t);
|
||||
|
||||
when(availableToolService.listAvailable())
|
||||
.thenThrow(new RuntimeException("MCP discovery upstream timeout"));
|
||||
|
||||
AgentEntity created = spyService.applyTemplate("picker-down-stub", WORKSPACE, CREATOR, null);
|
||||
|
||||
// Hire still completes; tool bind silently skipped (conservative
|
||||
// stance documented on applyDefaultToolBindings).
|
||||
assertEquals(CREATED_AGENT_ID, created.getId());
|
||||
verify(agentBindingService, never()).setToolBindings(anyLong(), anyList());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("setSkillBindings exception propagates so @Transactional rolls back the hire")
|
||||
void bindServiceExceptionPropagates() {
|
||||
// Pins the documented split: resolution failures are graceful, but
|
||||
// service-layer exceptions (a race deleting the skill row between
|
||||
// resolve and bind, a workspace-mismatch we couldn't predict) are
|
||||
// fail-stop. If someone later wraps the bind call in try/catch to
|
||||
// "make it more robust", this test forces them to also revisit
|
||||
// applyDefaultSkillBindings's Javadoc and the @Transactional
|
||||
// rollback contract instead of silently changing behavior.
|
||||
TemplateDTO t = baseTemplate("racey-stub");
|
||||
t.setDefaultSkillSlugs(List.of("sql_query"));
|
||||
registerTemplate(t);
|
||||
|
||||
when(skillMapper.selectOne(any(LambdaQueryWrapper.class)))
|
||||
.thenReturn(skillRow(101L, "sql_query"));
|
||||
doThrow(new MateClawException("err.skill.cross_workspace_binding", 403, "simulated race"))
|
||||
.when(agentBindingService).setSkillBindings(anyLong(), anyList());
|
||||
|
||||
MateClawException thrown = assertThrows(MateClawException.class,
|
||||
() -> spyService.applyTemplate("racey-stub", WORKSPACE, CREATOR, null));
|
||||
assertEquals(403, thrown.getCode());
|
||||
assertEquals("err.skill.cross_workspace_binding", thrown.getMsgKey());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("workspace lookup reads from the persisted agent — survives a service-side workspace override")
|
||||
void workspaceLookupUsesPersistedAgent() {
|
||||
// Defends against a future where AgentService.createAgent normalises
|
||||
// workspaceId (auto-assign default, project-onto-user-default, etc.)
|
||||
// — the slug resolver MUST query the same workspace that the bind
|
||||
// validator will check. Here we mutate the persisted agent's
|
||||
// workspace to a value different from the input parameter; if the
|
||||
// helper still queried the parameter, the lookup would target the
|
||||
// wrong workspace and (in production) miss the seeded skill. We
|
||||
// can't introspect the LambdaQueryWrapper's parameter map from a
|
||||
// Mockito-only test (MyBatis-Plus lambda cache isn't bootstrapped),
|
||||
// so this test pins the flow against crashes; the workspace-source
|
||||
// correctness is enforced by code review on the helper itself.
|
||||
when(agentService.createAgent(any(AgentEntity.class))).thenAnswer(inv -> {
|
||||
AgentEntity a = inv.getArgument(0);
|
||||
a.setId(CREATED_AGENT_ID);
|
||||
a.setWorkspaceId(42L);
|
||||
return a;
|
||||
});
|
||||
|
||||
TemplateDTO t = baseTemplate("ws-override-stub");
|
||||
t.setDefaultSkillSlugs(List.of("sql_query"));
|
||||
registerTemplate(t);
|
||||
|
||||
when(skillMapper.selectOne(any(LambdaQueryWrapper.class)))
|
||||
.thenReturn(skillRow(101L, "sql_query"));
|
||||
|
||||
spyService.applyTemplate("ws-override-stub", WORKSPACE /* = 1 */, CREATOR, null);
|
||||
|
||||
verify(agentBindingService, times(1))
|
||||
.setSkillBindings(eq(CREATED_AGENT_ID), argThat(ids ->
|
||||
ids.size() == 1 && ids.contains(101L)));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("null workspace on the persisted agent does not crash the helper")
|
||||
void nullWorkspaceFallsBackToOne() {
|
||||
// Mirrors the AgentBindingService.requireSameWorkspace fallback —
|
||||
// a row with workspace_id = null must not produce an `IS NULL`
|
||||
// lookup that silently matches nothing. The helper falls back to
|
||||
// workspace 1; without that, the LambdaQueryWrapper would still
|
||||
// build but every seeded skill would miss. Smoke-tested here for
|
||||
// crash-freeness; the value of the fallback (1L) is asserted by
|
||||
// code review of the helper.
|
||||
when(agentService.createAgent(any(AgentEntity.class))).thenAnswer(inv -> {
|
||||
AgentEntity a = inv.getArgument(0);
|
||||
a.setId(CREATED_AGENT_ID);
|
||||
a.setWorkspaceId(null);
|
||||
return a;
|
||||
});
|
||||
|
||||
TemplateDTO t = baseTemplate("null-ws-stub");
|
||||
t.setDefaultSkillSlugs(List.of("sql_query"));
|
||||
registerTemplate(t);
|
||||
|
||||
when(skillMapper.selectOne(any(LambdaQueryWrapper.class)))
|
||||
.thenReturn(skillRow(101L, "sql_query"));
|
||||
|
||||
spyService.applyTemplate("null-ws-stub", WORKSPACE, CREATOR, null);
|
||||
|
||||
verify(agentBindingService, times(1))
|
||||
.setSkillBindings(eq(CREATED_AGENT_ID), argThat(ids -> ids.contains(101L)));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("blank slug entries are skipped before they reach the mapper")
|
||||
void blankSlugsSkipped() {
|
||||
TemplateDTO t = baseTemplate("blanks-stub");
|
||||
t.setDefaultSkillSlugs(java.util.Arrays.asList("sql_query", "", null, " "));
|
||||
registerTemplate(t);
|
||||
|
||||
when(skillMapper.selectOne(any(LambdaQueryWrapper.class)))
|
||||
.thenReturn(skillRow(101L, "sql_query"));
|
||||
|
||||
AgentEntity created = spyService.applyTemplate("blanks-stub", WORKSPACE, CREATOR, null);
|
||||
|
||||
assertNotNull(created.getId());
|
||||
// Only the one real slug triggers a mapper lookup → only one bind.
|
||||
verify(skillMapper, times(1)).selectOne(any(LambdaQueryWrapper.class));
|
||||
verify(agentBindingService, times(1))
|
||||
.setSkillBindings(eq(CREATED_AGENT_ID), argThat(ids ->
|
||||
ids.size() == 1 && ids.contains(101L)));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,91 @@
|
||||
package vip.mate.approval;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import vip.mate.agent.context.ChannelTarget;
|
||||
import vip.mate.agent.context.ChatOrigin;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* RFC-063r §2.12: Memento round-trip for cross-restart approval replay.
|
||||
*
|
||||
* <p>Exercises {@link ApprovalWorkflowService#restoreChatOrigin(String)}
|
||||
* directly — independent of the DB layer — to pin the serialization
|
||||
* contract: full round-trip preserves every field, and a corrupt or null
|
||||
* payload falls back to {@link ChatOrigin#EMPTY} rather than throwing.
|
||||
*/
|
||||
class ApprovalReplayContinuityTest {
|
||||
|
||||
private ApprovalWorkflowService workflow;
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
objectMapper = new ObjectMapper();
|
||||
// Don't run the @PostConstruct GC scheduler — only need restoreChatOrigin.
|
||||
workflow = new ApprovalWorkflowService(null, null, objectMapper, null);
|
||||
// Inject objectMapper via reflection so the helper does not NPE.
|
||||
ReflectionTestUtils.setField(workflow, "objectMapper", objectMapper);
|
||||
}
|
||||
|
||||
@Test
|
||||
void chatOrigin_persistedAndRestored_preservesAllFields() throws Exception {
|
||||
ChatOrigin original = new ChatOrigin(
|
||||
/* agentId */ 7L,
|
||||
/* conversationId */ "wechat:chat-42",
|
||||
/* requesterId */ "u-123",
|
||||
/* workspaceId */ 5L,
|
||||
/* workspaceBasePath */ "/data/ws/5",
|
||||
/* channelId */ 9L,
|
||||
/* channelTarget */ new ChannelTarget("group-a", "thread-1", "bot-001"));
|
||||
|
||||
String json = objectMapper.writeValueAsString(original);
|
||||
ChatOrigin restored = workflow.restoreChatOrigin(json);
|
||||
|
||||
assertEquals(original, restored,
|
||||
"Memento round-trip must preserve every field — RFC-063r §2.12");
|
||||
}
|
||||
|
||||
@Test
|
||||
void chatOrigin_corruptJson_fallsBackToEmpty() {
|
||||
String corrupt = "{\"this is not\":valid JSON";
|
||||
ChatOrigin restored = workflow.restoreChatOrigin(corrupt);
|
||||
assertSame(ChatOrigin.EMPTY, restored,
|
||||
"Corrupt payload must fall back to EMPTY without throwing");
|
||||
}
|
||||
|
||||
@Test
|
||||
void chatOrigin_nullPayload_returnsEmpty() {
|
||||
assertSame(ChatOrigin.EMPTY, workflow.restoreChatOrigin(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void chatOrigin_blankPayload_returnsEmpty() {
|
||||
assertSame(ChatOrigin.EMPTY, workflow.restoreChatOrigin(" "));
|
||||
}
|
||||
|
||||
@Test
|
||||
void chatOrigin_unknownFieldsInJson_areTolerated() throws Exception {
|
||||
// Forward-compat: a payload written by a future build with extra
|
||||
// fields must still restore the known fields.
|
||||
String json = """
|
||||
{
|
||||
"agentId": 7,
|
||||
"conversationId": "wechat:chat-42",
|
||||
"requesterId": "u-123",
|
||||
"workspaceId": 5,
|
||||
"workspaceBasePath": "/data/ws/5",
|
||||
"channelId": 9,
|
||||
"channelTarget": {"targetId":"group-a","threadId":null,"accountId":null,"newField":"x"},
|
||||
"futureTopLevelField": "y"
|
||||
}
|
||||
""";
|
||||
ChatOrigin restored = workflow.restoreChatOrigin(json);
|
||||
assertEquals(7L, restored.agentId());
|
||||
assertEquals("wechat:chat-42", restored.conversationId());
|
||||
assertEquals("group-a", restored.channelTarget().targetId());
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,202 @@
|
||||
package vip.mate.approval;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.Wrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||
import org.apache.ibatis.session.Configuration;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import vip.mate.approval.model.ToolApprovalEntity;
|
||||
import vip.mate.approval.repository.ToolApprovalMapper;
|
||||
import vip.mate.workspace.conversation.ConversationService;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.ArgumentMatchers.isNull;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* GC contract for {@link ApprovalWorkflowService} (RFC-067 §4.4).
|
||||
* <p>
|
||||
* The pre-RFC GC lived on {@link ApprovalService} and only mutated the in-memory
|
||||
* map; mate_tool_approval rows stayed PENDING forever (recover-from-DB on next
|
||||
* restart resurrected them) and message metadata kept showing a ghost approval
|
||||
* banner. These tests pin the migrated behavior:
|
||||
* <ul>
|
||||
* <li>Phase A (TTL): expired pending → DB TIMEOUT + metadata DENIED + map removal</li>
|
||||
* <li>Phase B (overflow): pending count over MAX → oldest evicted via the same
|
||||
* full-sync path</li>
|
||||
* <li>Phase C (resolved cleanup): non-pending entries past RESOLVED_TTL drop
|
||||
* from the map only — DB / metadata are not touched</li>
|
||||
* <li>Idempotent on idle ticks: nothing to GC means zero DB / metadata interactions</li>
|
||||
* </ul>
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class ApprovalWorkflowServiceGcTest {
|
||||
|
||||
@Mock private ToolApprovalMapper approvalMapper;
|
||||
@Mock private ConversationService conversationService;
|
||||
|
||||
private ApprovalService approvalService;
|
||||
private ApprovalWorkflowService workflow;
|
||||
|
||||
@BeforeAll
|
||||
static void initMyBatisPlusCache() {
|
||||
TableInfoHelper.initTableInfo(
|
||||
new MapperBuilderAssistant(new Configuration(), ""),
|
||||
ToolApprovalEntity.class);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
approvalService = new ApprovalService();
|
||||
workflow = new ApprovalWorkflowService(
|
||||
approvalService, approvalMapper, new ObjectMapper(), conversationService);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Phase A: pending past PENDING_TTL goes through full DB+metadata+memory sync")
|
||||
void expiredPendingGoesThroughMarkTimeout() {
|
||||
// Pre-RFC: this row would silently be removed from the in-memory map but
|
||||
// mate_tool_approval would stay PENDING and the next recoverFromDb would
|
||||
// resurrect it. New contract: full two-phase markTimeout.
|
||||
Instant created = Instant.now().minus(Duration.ofMinutes(31));
|
||||
seedPending("pid-expired", "conv-1", "write_file", created);
|
||||
when(approvalMapper.update(isNull(), any(Wrapper.class))).thenReturn(1);
|
||||
when(conversationService.markPendingApprovalsResolved(
|
||||
eq("conv-1"), any(), eq(MetadataDecision.DENIED))).thenReturn(1);
|
||||
|
||||
workflow.garbageCollect();
|
||||
|
||||
// Map cleared
|
||||
assertThat(approvalService.size()).isZero();
|
||||
// DB UPDATE happened exactly once (markTimeout's conditional update).
|
||||
verify(approvalMapper, times(1)).update(isNull(), any(Wrapper.class));
|
||||
// Metadata reconciled with DENIED.
|
||||
verify(conversationService, times(1)).markPendingApprovalsResolved(
|
||||
eq("conv-1"), any(), eq(MetadataDecision.DENIED));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Phase A: pending within TTL is not touched")
|
||||
void freshPendingIsKept() {
|
||||
Instant created = Instant.now().minus(Duration.ofMinutes(5));
|
||||
PendingApproval p = seedPending("pid-fresh", "conv-2", "search", created);
|
||||
|
||||
workflow.garbageCollect();
|
||||
|
||||
assertThat(approvalService.getPending("pid-fresh")).isPresent();
|
||||
assertThat(p.getStatus()).isEqualTo("pending");
|
||||
verifyNoInteractions(approvalMapper);
|
||||
verifyNoInteractions(conversationService);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Phase C: resolved entry past RESOLVED_TTL drops from map without DB / metadata touch")
|
||||
void resolvedTtlExpiredIsMemoryOnlyDrop() {
|
||||
// DB row already terminal — workflow correctly decides this is memory-only cleanup.
|
||||
Instant created = Instant.now().minus(Duration.ofHours(2));
|
||||
PendingApproval p = seedPending("pid-old-approved", "conv-3", "shell", created);
|
||||
p.setStatus("approved");
|
||||
p.setResolvedAt(Instant.now().minus(Duration.ofHours(2)));
|
||||
|
||||
workflow.garbageCollect();
|
||||
|
||||
assertThat(approvalService.getPending("pid-old-approved")).isEmpty();
|
||||
verifyNoInteractions(approvalMapper);
|
||||
verifyNoInteractions(conversationService);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Phase C: resolved entry within TTL is kept")
|
||||
void freshResolvedIsKept() {
|
||||
PendingApproval p = seedPending("pid-recent-approved", "conv-4", "search", Instant.now());
|
||||
p.setStatus("approved");
|
||||
p.setResolvedAt(Instant.now().minus(Duration.ofMinutes(10)));
|
||||
|
||||
workflow.garbageCollect();
|
||||
|
||||
assertThat(approvalService.getPending("pid-recent-approved")).isPresent();
|
||||
assertThat(p.getStatus()).isEqualTo("approved");
|
||||
verifyNoInteractions(approvalMapper);
|
||||
verifyNoInteractions(conversationService);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Idle GC tick (no entries): zero DB / metadata interactions")
|
||||
void idleGcIsNoop() {
|
||||
workflow.garbageCollect();
|
||||
|
||||
verifyNoInteractions(approvalMapper);
|
||||
verifyNoInteractions(conversationService);
|
||||
assertThat(approvalService.size()).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("markTimeout idempotent: pendingId already off PENDING -> alreadyResolved, no metadata change")
|
||||
void markTimeoutAlreadyConsumed() {
|
||||
PendingApproval p = seedPending("pid-already", "conv-5", "search", Instant.now());
|
||||
p.setStatus("consumed");
|
||||
|
||||
ResolveOutcome outcome = workflow.markTimeout("pid-already");
|
||||
|
||||
assertThat(outcome.isAlreadyResolved()).isTrue();
|
||||
verifyNoInteractions(approvalMapper);
|
||||
verifyNoInteractions(conversationService);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Phase A: per-row failure doesn't abort the sweep — other expired entries still process")
|
||||
void perRowFailureContinuesSweep() {
|
||||
// Two expired pendings; first one's DB UPDATE throws, second one succeeds.
|
||||
// Pre-RFC's "all-or-nothing" loop would lose progress on the second; new GC
|
||||
// catches per-row exceptions and continues.
|
||||
Instant created = Instant.now().minus(Duration.ofMinutes(40));
|
||||
seedPending("pid-fail", "conv-fail", "write_file", created);
|
||||
seedPending("pid-ok", "conv-ok", "shell", created);
|
||||
|
||||
// First call throws, second returns 1.
|
||||
when(approvalMapper.update(isNull(), any(Wrapper.class)))
|
||||
.thenThrow(new RuntimeException("simulated outage"))
|
||||
.thenReturn(1);
|
||||
when(conversationService.markPendingApprovalsResolved(
|
||||
eq("conv-ok"), any(), eq(MetadataDecision.DENIED))).thenReturn(1);
|
||||
|
||||
workflow.garbageCollect();
|
||||
|
||||
// pid-fail is still in memory (markTimeout's @Transactional roll-back leaves it untouched
|
||||
// and the GC catch-block logs but continues).
|
||||
assertThat(approvalService.getPending("pid-fail")).isPresent();
|
||||
// pid-ok was successfully timed out.
|
||||
assertThat(approvalService.getPending("pid-ok")).isEmpty();
|
||||
|
||||
verify(approvalMapper, times(2)).update(isNull(), any(Wrapper.class));
|
||||
// Metadata reconciliation only fired for the successful row.
|
||||
verify(conversationService, times(1)).markPendingApprovalsResolved(
|
||||
eq("conv-ok"), any(), eq(MetadataDecision.DENIED));
|
||||
}
|
||||
|
||||
// ---------- helpers ----------
|
||||
|
||||
private PendingApproval seedPending(String pendingId, String conversationId,
|
||||
String toolName, Instant createdAt) {
|
||||
PendingApproval p = new PendingApproval(
|
||||
pendingId, conversationId, "system", toolName, "{}", "test",
|
||||
createdAt, "pending");
|
||||
approvalService.registerRecovered(p);
|
||||
return p;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,243 @@
|
||||
package vip.mate.approval;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||
import org.apache.ibatis.session.Configuration;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import vip.mate.approval.model.ToolApprovalEntity;
|
||||
import vip.mate.approval.repository.ToolApprovalMapper;
|
||||
import vip.mate.workspace.conversation.ConversationService;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* Recovery contract for ApprovalWorkflowService.recoverFromDb (RFC-067 §4.1).
|
||||
* <p>
|
||||
* The pre-RFC implementation generated a fresh random pendingId on recovery,
|
||||
* which silently desynchronized the in-memory map from mate_tool_approval and
|
||||
* left every later resolve()/updateDbStatus() call hitting zero rows. These
|
||||
* tests pin the new contract:
|
||||
* <ul>
|
||||
* <li>Live row → pendingMap entry preserves the DB pendingId AND createdAt
|
||||
* (so PENDING_TTL math still works after restart)</li>
|
||||
* <li>Expired row (expireAt past) → DB → TIMEOUT, metadata reconciled DENIED,
|
||||
* no pendingMap entry</li>
|
||||
* <li>Legacy row with expireAt NULL falls back to createdAt + PENDING_TTL —
|
||||
* this is the regression-prevention case for §4.1's effectiveExpireAt
|
||||
* fallback. A naive "if expireAt != null && now > expireAt" check would
|
||||
* silently revive ancient PENDING rows after every restart.</li>
|
||||
* </ul>
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class ApprovalWorkflowServiceRecoveryTest {
|
||||
|
||||
@Mock private ToolApprovalMapper approvalMapper;
|
||||
@Mock private ConversationService conversationService;
|
||||
|
||||
private ApprovalService approvalService; // real, so registerRecovered is exercised
|
||||
private ApprovalWorkflowService workflow;
|
||||
|
||||
@BeforeAll
|
||||
static void initMyBatisPlusCache() {
|
||||
// PR-2 resolveAndConsume builds a LambdaUpdateWrapper.set(...) which needs
|
||||
// ToolApprovalEntity's TableInfo to be registered in MyBatis-Plus's static
|
||||
// cache (a Spring context normally does this during mapper scan).
|
||||
TableInfoHelper.initTableInfo(
|
||||
new MapperBuilderAssistant(new Configuration(), ""),
|
||||
ToolApprovalEntity.class);
|
||||
}
|
||||
|
||||
private void initWorkflow(List<ToolApprovalEntity> dbRows) {
|
||||
approvalService = new ApprovalService();
|
||||
// Skip the GC scheduler — initGc() spins up a daemon thread we don't need here.
|
||||
// Tests interact with the registry via registerRecovered + getPending only.
|
||||
workflow = new ApprovalWorkflowService(
|
||||
approvalService,
|
||||
approvalMapper,
|
||||
new ObjectMapper(),
|
||||
conversationService);
|
||||
when(approvalMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(dbRows);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Live PENDING row recovers with DB pendingId + createdAt preserved")
|
||||
void recoversLiveRowPreservingIdAndCreatedAt() {
|
||||
LocalDateTime created = LocalDateTime.now().minusMinutes(5);
|
||||
ToolApprovalEntity row = newPendingRow("pid-live-1", "conv-1", created,
|
||||
created.plusMinutes(30));
|
||||
initWorkflow(List.of(row));
|
||||
|
||||
workflow.recoverFromDb();
|
||||
|
||||
PendingApproval recovered = approvalService.getPending("pid-live-1").orElse(null);
|
||||
assertThat(recovered).isNotNull();
|
||||
assertThat(recovered.getPendingId()).isEqualTo("pid-live-1");
|
||||
assertThat(recovered.getConversationId()).isEqualTo("conv-1");
|
||||
assertThat(recovered.getStatus()).isEqualTo("pending");
|
||||
// createdAt round-trips with second precision (LocalDateTime → Instant via system zone)
|
||||
assertThat(recovered.getCreatedAt().getEpochSecond())
|
||||
.isEqualTo(created.atZone(java.time.ZoneId.systemDefault()).toEpochSecond());
|
||||
|
||||
// Did not silently expire the live row.
|
||||
verify(approvalMapper, never()).updateById(any(ToolApprovalEntity.class));
|
||||
verifyNoInteractions(conversationService);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Expired row with explicit past expireAt: DB -> TIMEOUT, metadata DENIED, not in map")
|
||||
void expiredRowWithExplicitExpireAt() {
|
||||
LocalDateTime created = LocalDateTime.now().minusMinutes(31);
|
||||
ToolApprovalEntity row = newPendingRow("pid-exp-1", "conv-2", created,
|
||||
created.plusMinutes(30));
|
||||
initWorkflow(List.of(row));
|
||||
when(approvalMapper.updateById(any(ToolApprovalEntity.class))).thenReturn(1);
|
||||
|
||||
workflow.recoverFromDb();
|
||||
|
||||
assertThat(approvalService.getPending("pid-exp-1")).isEmpty();
|
||||
ArgumentCaptor<ToolApprovalEntity> updated = ArgumentCaptor.forClass(ToolApprovalEntity.class);
|
||||
verify(approvalMapper).updateById(updated.capture());
|
||||
assertThat(updated.getValue().getStatus()).isEqualTo("TIMEOUT");
|
||||
assertThat(updated.getValue().getResolvedAt()).isNotNull();
|
||||
verify(conversationService).markPendingApprovalsResolved(
|
||||
eq("conv-2"), eq(Set.of("pid-exp-1")), eq(MetadataDecision.DENIED));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Legacy row with expireAt=NULL still expires via createdAt + PENDING_TTL fallback")
|
||||
void legacyRowFallsBackToCreatedAtPlusTtl() {
|
||||
// Mirrors the §4.1 regression case: pre-RFC rows persisted by an older build
|
||||
// never got an expireAt column populated. Without the fallback, recoverFromDb
|
||||
// would resurrect them as live PENDING after every restart — a permanent ghost
|
||||
// approval source.
|
||||
LocalDateTime created = LocalDateTime.now().minusMinutes(31);
|
||||
ToolApprovalEntity row = newPendingRow("pid-legacy-1", "conv-3", created, null);
|
||||
initWorkflow(List.of(row));
|
||||
when(approvalMapper.updateById(any(ToolApprovalEntity.class))).thenReturn(1);
|
||||
|
||||
workflow.recoverFromDb();
|
||||
|
||||
assertThat(approvalService.getPending("pid-legacy-1")).isEmpty();
|
||||
verify(approvalMapper).updateById(any(ToolApprovalEntity.class));
|
||||
verify(conversationService).markPendingApprovalsResolved(
|
||||
eq("conv-3"), eq(Set.of("pid-legacy-1")), eq(MetadataDecision.DENIED));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("DB updateById returning 0 rows: metadata is NOT touched (no drift)")
|
||||
void expireSkipsMetadataWhenDbAffectsZeroRows() {
|
||||
// Concurrent resolve case: another path already moved the row off PENDING
|
||||
// between selectList and updateById. Metadata flip MUST be gated on DB
|
||||
// success, otherwise message metadata = denied while DB is e.g. CONSUMED,
|
||||
// and the next recoverFromDb would resurrect it — exactly the drift we
|
||||
// came here to fix.
|
||||
LocalDateTime created = LocalDateTime.now().minusMinutes(31);
|
||||
ToolApprovalEntity row = newPendingRow("pid-race-1", "conv-race", created,
|
||||
created.plusMinutes(30));
|
||||
initWorkflow(List.of(row));
|
||||
when(approvalMapper.updateById(any(ToolApprovalEntity.class))).thenReturn(0);
|
||||
|
||||
workflow.recoverFromDb();
|
||||
|
||||
assertThat(approvalService.getPending("pid-race-1")).isEmpty();
|
||||
verify(approvalMapper).updateById(any(ToolApprovalEntity.class));
|
||||
verifyNoInteractions(conversationService);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("DB updateById throwing: metadata is NOT touched")
|
||||
void expireSkipsMetadataWhenDbThrows() {
|
||||
LocalDateTime created = LocalDateTime.now().minusMinutes(31);
|
||||
ToolApprovalEntity row = newPendingRow("pid-throw-1", "conv-throw", created,
|
||||
created.plusMinutes(30));
|
||||
initWorkflow(List.of(row));
|
||||
when(approvalMapper.updateById(any(ToolApprovalEntity.class)))
|
||||
.thenThrow(new RuntimeException("simulated DB outage"));
|
||||
|
||||
workflow.recoverFromDb();
|
||||
|
||||
assertThat(approvalService.getPending("pid-throw-1")).isEmpty();
|
||||
verifyNoInteractions(conversationService);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Legacy row with expireAt=NULL but createdAt within TTL: still recovers as live")
|
||||
void legacyRowWithinTtlStillRecovers() {
|
||||
LocalDateTime created = LocalDateTime.now().minusMinutes(5);
|
||||
ToolApprovalEntity row = newPendingRow("pid-legacy-live", "conv-4", created, null);
|
||||
initWorkflow(List.of(row));
|
||||
|
||||
workflow.recoverFromDb();
|
||||
|
||||
assertThat(approvalService.getPending("pid-legacy-live")).isPresent();
|
||||
verify(approvalMapper, never()).updateById(any(ToolApprovalEntity.class));
|
||||
verifyNoInteractions(conversationService);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Recovered pending carries its replay payload through resolveAndConsume")
|
||||
void resolveAfterRecoveryYieldsRecoveredPayload() {
|
||||
// Pre-RFC, recovery generated a fresh random id; the next resolveAndConsume
|
||||
// either pulled the wrong record or the in-memory map was empty altogether.
|
||||
// This pins that the original DB pendingId AND the replay payload (toolCallPayload)
|
||||
// round-trip through recovery and still drive consume successfully through the
|
||||
// PR-2 ResolveOutcome contract.
|
||||
LocalDateTime created = LocalDateTime.now().minusMinutes(2);
|
||||
ToolApprovalEntity row = newPendingRow("pid-resolve-1", "conv-5", created,
|
||||
created.plusMinutes(30));
|
||||
row.setToolCallPayload("{\"name\":\"write_file\"}");
|
||||
initWorkflow(List.of(row));
|
||||
// Stub the DB UPDATE that the new two-phase resolve runs; metadata mock is
|
||||
// already injected and returns 0 by default which matches "no message rewrites".
|
||||
when(approvalMapper.update(any(), any())).thenReturn(1);
|
||||
|
||||
workflow.recoverFromDb();
|
||||
PendingApproval recovered = approvalService.getPending("pid-resolve-1").orElseThrow();
|
||||
assertThat(recovered.getToolCallPayload()).isEqualTo("{\"name\":\"write_file\"}");
|
||||
|
||||
ResolveOutcome outcome = workflow.resolveAndConsume("pid-resolve-1", "alice");
|
||||
assertThat(outcome.isConsumed()).isTrue();
|
||||
assertThat(outcome.dbSynced()).isTrue();
|
||||
assertThat(outcome.consumedSnapshot()).isNotNull();
|
||||
assertThat(outcome.consumedSnapshot().getPendingId()).isEqualTo("pid-resolve-1");
|
||||
assertThat(outcome.consumedSnapshot().getToolCallPayload()).isEqualTo("{\"name\":\"write_file\"}");
|
||||
assertThat(outcome.consumedSnapshot().getStatus()).isEqualTo("consumed");
|
||||
assertThat(outcome.consumedSnapshot().getResolvedBy()).isEqualTo("alice");
|
||||
// pendingMap entry has been removed; a second consume is idempotent already_resolved.
|
||||
ResolveOutcome second = workflow.resolveAndConsume("pid-resolve-1", "alice");
|
||||
assertThat(second.isAlreadyResolved()).isTrue();
|
||||
}
|
||||
|
||||
private ToolApprovalEntity newPendingRow(String pendingId, String conversationId,
|
||||
LocalDateTime createdAt, LocalDateTime expireAt) {
|
||||
ToolApprovalEntity e = new ToolApprovalEntity();
|
||||
e.setPendingId(pendingId);
|
||||
e.setConversationId(conversationId);
|
||||
e.setUserId("u");
|
||||
e.setToolName("write_file");
|
||||
e.setToolArguments("{}");
|
||||
e.setSummary("test");
|
||||
e.setStatus("PENDING");
|
||||
e.setCreatedAt(createdAt);
|
||||
e.setExpireAt(expireAt);
|
||||
return e;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,351 @@
|
||||
package vip.mate.approval;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.Wrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||
import org.apache.ibatis.session.Configuration;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import vip.mate.approval.model.ToolApprovalEntity;
|
||||
import vip.mate.approval.repository.ToolApprovalMapper;
|
||||
import vip.mate.workspace.conversation.ConversationService;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.ArgumentMatchers.isNull;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* Two-phase resolve contract for {@link ApprovalWorkflowService} (RFC-067 §4.2 / §4.3).
|
||||
* <p>
|
||||
* The pre-RFC implementation removed the in-memory entry FIRST then attempted DB
|
||||
* UPDATE on a best-effort try / catch — payload could be lost while DB stayed
|
||||
* PENDING. These tests pin the new ordering:
|
||||
* <ol>
|
||||
* <li>snapshot (no map mutation)</li>
|
||||
* <li>DB UPDATE conditional on {@code status='PENDING'} (idempotent against concurrent resolve)</li>
|
||||
* <li>metadata reconciliation (same tx)</li>
|
||||
* <li>memory mutation only on commit (afterCommit hook; immediate when no tx active)</li>
|
||||
* </ol>
|
||||
* <p>
|
||||
* Tests run outside Spring's tx manager, so the {@code afterCommit} hook executes
|
||||
* immediately — that exercises the same observable end-state as a committed tx.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class ApprovalWorkflowServiceResolveTest {
|
||||
|
||||
@Mock private ToolApprovalMapper approvalMapper;
|
||||
@Mock private ConversationService conversationService;
|
||||
|
||||
private ApprovalService approvalService;
|
||||
private ApprovalWorkflowService workflow;
|
||||
|
||||
@BeforeAll
|
||||
static void initMyBatisPlusCache() {
|
||||
// LambdaUpdateWrapper.set / .eq need the entity's TableInfo to be registered in
|
||||
// MyBatis-Plus's static cache. In a Spring context this happens during mapper
|
||||
// scan; in a plain MockitoExtension test we trigger it manually.
|
||||
TableInfoHelper.initTableInfo(
|
||||
new MapperBuilderAssistant(new Configuration(), ""),
|
||||
ToolApprovalEntity.class);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
approvalService = new ApprovalService();
|
||||
workflow = new ApprovalWorkflowService(
|
||||
approvalService, approvalMapper, new ObjectMapper(), conversationService);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("resolve(approved) updates DB, metadata, and snapshot status; entry stays in map")
|
||||
void resolveApprovedHappyPath() {
|
||||
PendingApproval pending = seedPending("pid-1", "conv-1", "write_file");
|
||||
when(approvalMapper.update(isNull(), any(Wrapper.class))).thenReturn(1);
|
||||
when(conversationService.markPendingApprovalsResolved(
|
||||
eq("conv-1"), eq(Set.of("pid-1")), eq(MetadataDecision.APPROVED))).thenReturn(1);
|
||||
|
||||
ResolveOutcome outcome = workflow.resolve("pid-1", "alice", "approved");
|
||||
|
||||
assertThat(outcome.decision()).isEqualTo("approved");
|
||||
assertThat(outcome.dbSynced()).isTrue();
|
||||
assertThat(outcome.messagesRewritten()).isEqualTo(1);
|
||||
assertThat(outcome.consumedSnapshot()).isNull();
|
||||
|
||||
// Memory: status flipped to "approved", entry stays in map (resolve does NOT remove)
|
||||
assertThat(pending.getStatus()).isEqualTo("approved");
|
||||
assertThat(pending.getResolvedBy()).isEqualTo("alice");
|
||||
assertThat(approvalService.getPending("pid-1")).isPresent();
|
||||
|
||||
verify(approvalMapper, times(1)).update(isNull(), any(Wrapper.class));
|
||||
verify(conversationService).markPendingApprovalsResolved(
|
||||
"conv-1", Set.of("pid-1"), MetadataDecision.APPROVED);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("resolve(denied) flips metadata + snapshot to denied")
|
||||
void resolveDeniedHappyPath() {
|
||||
PendingApproval pending = seedPending("pid-2", "conv-2", "shell");
|
||||
when(approvalMapper.update(isNull(), any(Wrapper.class))).thenReturn(1);
|
||||
when(conversationService.markPendingApprovalsResolved(
|
||||
eq("conv-2"), eq(Set.of("pid-2")), eq(MetadataDecision.DENIED))).thenReturn(1);
|
||||
|
||||
ResolveOutcome outcome = workflow.resolve("pid-2", "bob", "denied");
|
||||
|
||||
assertThat(outcome.decision()).isEqualTo("denied");
|
||||
assertThat(outcome.dbSynced()).isTrue();
|
||||
assertThat(pending.getStatus()).isEqualTo("denied");
|
||||
verify(conversationService).markPendingApprovalsResolved(
|
||||
"conv-2", Set.of("pid-2"), MetadataDecision.DENIED);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("resolve no-op when pendingId not in map: no DB / metadata interaction")
|
||||
void resolveUnknownPendingId() {
|
||||
ResolveOutcome outcome = workflow.resolve("ghost-id", "alice", "approved");
|
||||
|
||||
assertThat(outcome.isAlreadyResolved()).isTrue();
|
||||
assertThat(outcome.dbSynced()).isFalse();
|
||||
verifyNoInteractions(approvalMapper);
|
||||
verifyNoInteractions(conversationService);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("resolve idempotent against concurrent resolve: DB rows=0 -> no metadata, no memory mutation")
|
||||
void resolveIdempotentOnConcurrentResolve() {
|
||||
PendingApproval pending = seedPending("pid-race", "conv-race", "write_file");
|
||||
// Another path already moved the row off PENDING between snapshot and DB UPDATE.
|
||||
when(approvalMapper.update(isNull(), any(Wrapper.class))).thenReturn(0);
|
||||
|
||||
ResolveOutcome outcome = workflow.resolve("pid-race", "alice", "approved");
|
||||
|
||||
assertThat(outcome.isAlreadyResolved()).isTrue();
|
||||
assertThat(outcome.dbSynced()).isFalse();
|
||||
assertThat(outcome.messagesRewritten()).isZero();
|
||||
// Snapshot status was NOT flipped to approved — memory stays consistent with DB.
|
||||
assertThat(pending.getStatus()).isEqualTo("pending");
|
||||
assertThat(approvalService.getPending("pid-race")).isPresent();
|
||||
verifyNoInteractions(conversationService);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("resolveAndConsume happy path: DB CONSUMED, metadata APPROVED, snapshot removed from map")
|
||||
void resolveAndConsumeHappyPath() {
|
||||
PendingApproval pending = seedPending("pid-c-1", "conv-c", "write_file");
|
||||
pending.setToolCallPayload("{\"name\":\"write_file\"}");
|
||||
when(approvalMapper.update(isNull(), any(Wrapper.class))).thenReturn(1);
|
||||
when(conversationService.markPendingApprovalsResolved(
|
||||
eq("conv-c"), eq(Set.of("pid-c-1")), eq(MetadataDecision.APPROVED))).thenReturn(2);
|
||||
|
||||
ResolveOutcome outcome = workflow.resolveAndConsume("pid-c-1", "carol");
|
||||
|
||||
assertThat(outcome.isConsumed()).isTrue();
|
||||
assertThat(outcome.dbSynced()).isTrue();
|
||||
assertThat(outcome.messagesRewritten()).isEqualTo(2);
|
||||
assertThat(outcome.consumedSnapshot()).isNotNull();
|
||||
assertThat(outcome.consumedSnapshot().getToolCallPayload()).isEqualTo("{\"name\":\"write_file\"}");
|
||||
|
||||
// Memory: status flipped to consumed, entry REMOVED (single-shot consume).
|
||||
assertThat(pending.getStatus()).isEqualTo("consumed");
|
||||
assertThat(approvalService.getPending("pid-c-1")).isEmpty();
|
||||
|
||||
// Second consume returns idempotent already_resolved (entry is gone).
|
||||
ResolveOutcome second = workflow.resolveAndConsume("pid-c-1", "carol");
|
||||
assertThat(second.isAlreadyResolved()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("resolveAndConsume DB rows=0: no metadata, snapshot stays in map")
|
||||
void resolveAndConsumeRaceLeavesMapAlone() {
|
||||
PendingApproval pending = seedPending("pid-c-race", "conv-cr", "write_file");
|
||||
pending.setToolCallPayload("{}");
|
||||
when(approvalMapper.update(isNull(), any(Wrapper.class))).thenReturn(0);
|
||||
|
||||
ResolveOutcome outcome = workflow.resolveAndConsume("pid-c-race", "alice");
|
||||
|
||||
assertThat(outcome.isAlreadyResolved()).isTrue();
|
||||
// Critical: payload is NOT lost. Replay can still find the entry next loop.
|
||||
assertThat(approvalService.getPending("pid-c-race")).isPresent();
|
||||
assertThat(pending.getStatus()).isEqualTo("pending");
|
||||
verifyNoInteractions(conversationService);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("consumeApproved redeems the earliest approved record; missing match -> alreadyResolved")
|
||||
void consumeApprovedHappyAndMiss() {
|
||||
PendingApproval pending = seedPending("pid-app-1", "conv-app", "search");
|
||||
// Caller previously approved but did not consume — common in /approve text flow.
|
||||
pending.setStatus("approved");
|
||||
when(approvalMapper.update(isNull(), any(Wrapper.class))).thenReturn(1);
|
||||
when(conversationService.markPendingApprovalsResolved(
|
||||
eq("conv-app"), eq(Set.of("pid-app-1")), eq(MetadataDecision.APPROVED))).thenReturn(1);
|
||||
|
||||
ResolveOutcome consumed = workflow.consumeApproved("conv-app", "search");
|
||||
|
||||
assertThat(consumed.isConsumed()).isTrue();
|
||||
assertThat(consumed.consumedSnapshot()).isNotNull();
|
||||
assertThat(approvalService.getPending("pid-app-1")).isEmpty();
|
||||
|
||||
// Second call: nothing approved left → no additional DB / metadata interaction.
|
||||
org.mockito.Mockito.clearInvocations(approvalMapper, conversationService);
|
||||
ResolveOutcome miss = workflow.consumeApproved("conv-app", "search");
|
||||
assertThat(miss.isAlreadyResolved()).isTrue();
|
||||
verifyNoInteractions(approvalMapper);
|
||||
verifyNoInteractions(conversationService);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("cancelStalePending issues a SUPERSEDED outcome per pending in the conversation")
|
||||
void cancelStalePendingMultipleEntries() {
|
||||
PendingApproval a = seedPending("pid-stale-A", "conv-stale", "write_file");
|
||||
PendingApproval b = seedPending("pid-stale-B", "conv-stale", "shell");
|
||||
PendingApproval keep = seedPending("pid-keep", "conv-stale", "memory_recall");
|
||||
when(approvalMapper.update(isNull(), any(Wrapper.class))).thenReturn(1);
|
||||
when(conversationService.markPendingApprovalsResolved(
|
||||
eq("conv-stale"), any(), eq(MetadataDecision.DENIED))).thenReturn(1);
|
||||
|
||||
List<ResolveOutcome> outcomes = workflow.cancelStalePending("conv-stale", "pid-keep");
|
||||
|
||||
assertThat(outcomes).hasSize(2);
|
||||
assertThat(outcomes).extracting(ResolveOutcome::pendingId)
|
||||
.containsExactlyInAnyOrder("pid-stale-A", "pid-stale-B");
|
||||
assertThat(outcomes).allMatch(o -> "superseded".equals(o.decision()));
|
||||
// Excluded entry untouched.
|
||||
assertThat(approvalService.getPending("pid-keep")).isPresent();
|
||||
assertThat(keep.getStatus()).isEqualTo("pending");
|
||||
// Cancelled entries removed from map.
|
||||
assertThat(approvalService.getPending("pid-stale-A")).isEmpty();
|
||||
assertThat(approvalService.getPending("pid-stale-B")).isEmpty();
|
||||
assertThat(a.getStatus()).isEqualTo("superseded");
|
||||
assertThat(b.getStatus()).isEqualTo("superseded");
|
||||
|
||||
// Two DB updates fired (one per cancellation).
|
||||
verify(approvalMapper, times(2)).update(isNull(), any(Wrapper.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("denyAllByConversation: every pending becomes denied; metadata reconciled per row")
|
||||
void denyAllConversationSweep() {
|
||||
// Stop endpoint scenario: user halts a turn while two pendings sit in the map.
|
||||
PendingApproval a = seedPending("pid-stop-A", "conv-stop", "write_file");
|
||||
PendingApproval b = seedPending("pid-stop-B", "conv-stop", "shell");
|
||||
seedPending("pid-other-conv", "conv-other", "search"); // not in target conversation
|
||||
when(approvalMapper.update(isNull(), any(Wrapper.class))).thenReturn(1);
|
||||
when(conversationService.markPendingApprovalsResolved(
|
||||
eq("conv-stop"), any(), eq(MetadataDecision.DENIED))).thenReturn(1);
|
||||
|
||||
List<ResolveOutcome> outcomes = workflow.denyAllByConversation("conv-stop", "alice");
|
||||
|
||||
assertThat(outcomes).hasSize(2);
|
||||
assertThat(outcomes).extracting(ResolveOutcome::pendingId)
|
||||
.containsExactlyInAnyOrder("pid-stop-A", "pid-stop-B");
|
||||
assertThat(outcomes).allMatch(o -> "denied".equals(o.decision()));
|
||||
assertThat(a.getStatus()).isEqualTo("denied");
|
||||
assertThat(b.getStatus()).isEqualTo("denied");
|
||||
// Targets removed from map.
|
||||
assertThat(approvalService.getPending("pid-stop-A")).isEmpty();
|
||||
assertThat(approvalService.getPending("pid-stop-B")).isEmpty();
|
||||
// Other conversation untouched.
|
||||
assertThat(approvalService.getPending("pid-other-conv")).isPresent();
|
||||
// Two metadata reconciliations fired (one per pending).
|
||||
verify(conversationService, times(2)).markPendingApprovalsResolved(
|
||||
eq("conv-stop"), any(), eq(MetadataDecision.DENIED));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("denyAllByConversation: empty conversation -> empty outcomes, no DB / metadata interaction")
|
||||
void denyAllNoPendingsIsNoop() {
|
||||
seedPending("pid-other", "conv-other", "search");
|
||||
|
||||
List<ResolveOutcome> outcomes = workflow.denyAllByConversation("conv-empty", "alice");
|
||||
|
||||
assertThat(outcomes).isEmpty();
|
||||
verifyNoInteractions(approvalMapper);
|
||||
verifyNoInteractions(conversationService);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("denyAllByConversation: per-row failure doesn't abort the sweep")
|
||||
void denyAllPerRowFailureContinues() {
|
||||
seedPending("pid-fail", "conv-mix", "write_file");
|
||||
seedPending("pid-ok", "conv-mix", "shell");
|
||||
// First UPDATE throws, second succeeds.
|
||||
when(approvalMapper.update(isNull(), any(Wrapper.class)))
|
||||
.thenThrow(new RuntimeException("simulated outage"))
|
||||
.thenReturn(1);
|
||||
when(conversationService.markPendingApprovalsResolved(
|
||||
eq("conv-mix"), any(), eq(MetadataDecision.DENIED))).thenReturn(1);
|
||||
|
||||
List<ResolveOutcome> outcomes = workflow.denyAllByConversation("conv-mix", "alice");
|
||||
|
||||
// Only the successful row makes it into the outcomes list.
|
||||
assertThat(outcomes).hasSize(1);
|
||||
assertThat(outcomes.get(0).pendingId()).isEqualTo("pid-ok");
|
||||
// Failed row is still in memory (transactional rollback would leave it untouched).
|
||||
assertThat(approvalService.getPending("pid-fail")).isPresent();
|
||||
assertThat(approvalService.getPending("pid-ok")).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("DB UPDATE throwing propagates so @Transactional can roll back; memory untouched")
|
||||
void dbThrowsPropagatesForRollback() {
|
||||
PendingApproval pending = seedPending("pid-throw", "conv-throw", "write_file");
|
||||
when(approvalMapper.update(isNull(), any(Wrapper.class)))
|
||||
.thenThrow(new RuntimeException("simulated outage"));
|
||||
|
||||
try {
|
||||
workflow.resolve("pid-throw", "alice", "approved");
|
||||
org.junit.jupiter.api.Assertions.fail("expected RuntimeException");
|
||||
} catch (RuntimeException expected) {
|
||||
assertThat(expected.getMessage()).contains("simulated outage");
|
||||
}
|
||||
// Memory snapshot must not have flipped.
|
||||
assertThat(pending.getStatus()).isEqualTo("pending");
|
||||
verifyNoInteractions(conversationService);
|
||||
// approvalService is a real instance in these tests, not a Mockito mock —
|
||||
// its untouched state is asserted via the snapshot status above.
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("ResolveOutcome carries conversationId + toolName for SSE broadcast use")
|
||||
void outcomeShape() {
|
||||
PendingApproval pending = seedPending("pid-shape", "conv-shape", "search_web");
|
||||
when(approvalMapper.update(isNull(), any(Wrapper.class))).thenReturn(1);
|
||||
when(conversationService.markPendingApprovalsResolved(
|
||||
eq("conv-shape"), eq(Set.of("pid-shape")), eq(MetadataDecision.DENIED))).thenReturn(0);
|
||||
|
||||
ResolveOutcome outcome = workflow.resolve("pid-shape", "alice", "denied");
|
||||
|
||||
assertThat(outcome.pendingId()).isEqualTo("pid-shape");
|
||||
assertThat(outcome.conversationId()).isEqualTo("conv-shape");
|
||||
assertThat(outcome.toolName()).isEqualTo("search_web");
|
||||
assertThat(outcome.messagesRewritten()).isZero();
|
||||
}
|
||||
|
||||
// ---------- helpers ----------
|
||||
|
||||
private PendingApproval seedPending(String pendingId, String conversationId, String toolName) {
|
||||
// Use the public createPending overload, then re-key the map under the
|
||||
// requested pendingId so the test asserts work against a stable id.
|
||||
// The recovery constructor is package-visible from this same package.
|
||||
PendingApproval p = new PendingApproval(
|
||||
pendingId, conversationId, "system", toolName, "{}", "test",
|
||||
java.time.Instant.now(), "pending");
|
||||
approvalService.registerRecovered(p);
|
||||
return p;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,89 @@
|
||||
package vip.mate.architecture;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import vip.mate.agent.graph.state.MateClawStateKeys;
|
||||
|
||||
import java.lang.reflect.Modifier;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.junit.jupiter.api.Assertions.fail;
|
||||
|
||||
/**
|
||||
* Architecture guard — every state key declared on
|
||||
* {@link MateClawStateKeys} that participates in graph state (i.e. is not a
|
||||
* node-name constant) MUST be registered in
|
||||
* {@link vip.mate.agent.AgentGraphBuilder}'s {@code KeyStrategyFactory}
|
||||
* for at least one of the two graphs (ReAct + Plan-Execute).
|
||||
*
|
||||
* <p>Regression rationale: the post-deploy bug where {@code CHAT_ORIGIN} was
|
||||
* declared on {@link MateClawStateKeys} but missing from both
|
||||
* {@code KeyStrategyFactory} blocks shipped silently, and {@code spring-ai-alibaba-graph}
|
||||
* dropped the key on multi-node merges, causing the channel-binding flakiness
|
||||
* reported by the user. This test parses the source of
|
||||
* {@code AgentGraphBuilder.java} for all
|
||||
* {@code .addStrategy(MateClawStateKeys.X, ...)} mentions and asserts the
|
||||
* coverage so the same kind of "forgot to register" can never ship again.
|
||||
*
|
||||
* <p>Excluded by suffix: any constant whose name ends with {@code _NODE} —
|
||||
* those are graph-node identifiers used by {@code addNode(...)}, not state
|
||||
* keys.
|
||||
*/
|
||||
class StateKeyRegistrationCoverageTest {
|
||||
|
||||
private static final Pattern ADD_STRATEGY = Pattern.compile(
|
||||
"\\.addStrategy\\(\\s*MateClawStateKeys\\.([A-Z_]+)");
|
||||
|
||||
@Test
|
||||
void everyStateKeyMustBeRegisteredInKeyStrategyFactory() throws Exception {
|
||||
// Read the AgentGraphBuilder source — relative to mateclaw-server module root.
|
||||
Path source = Paths.get("src/main/java/vip/mate/agent/AgentGraphBuilder.java")
|
||||
.toAbsolutePath();
|
||||
if (!Files.exists(source)) {
|
||||
fail("Cannot find AgentGraphBuilder.java at " + source
|
||||
+ " — has the file moved? Update this test's path.");
|
||||
}
|
||||
String content = Files.readString(source);
|
||||
|
||||
Set<String> registered = new TreeSet<>();
|
||||
Matcher m = ADD_STRATEGY.matcher(content);
|
||||
while (m.find()) {
|
||||
registered.add(m.group(1));
|
||||
}
|
||||
assertTrue(registered.size() > 10,
|
||||
"Suspiciously few addStrategy hits — regex broken? Found: " + registered);
|
||||
|
||||
Set<String> declared = new TreeSet<>();
|
||||
for (var f : MateClawStateKeys.class.getDeclaredFields()) {
|
||||
int mods = f.getModifiers();
|
||||
if (!Modifier.isPublic(mods) || !Modifier.isStatic(mods)
|
||||
|| !Modifier.isFinal(mods) || f.getType() != String.class) {
|
||||
continue;
|
||||
}
|
||||
// Node-name constants are NOT state keys — they're graph-node
|
||||
// identifiers used by addNode(...). Exclude them by suffix.
|
||||
if (f.getName().endsWith("_NODE")) continue;
|
||||
declared.add(f.getName());
|
||||
}
|
||||
|
||||
Set<String> missing = new TreeSet<>(declared);
|
||||
missing.removeAll(registered);
|
||||
|
||||
if (!missing.isEmpty()) {
|
||||
fail("State keys declared on MateClawStateKeys but NOT registered in any "
|
||||
+ "KeyStrategyFactory in AgentGraphBuilder.java:\n"
|
||||
+ " " + missing + "\n\n"
|
||||
+ "Without registration, spring-ai-alibaba-graph may drop these keys on "
|
||||
+ "multi-node state merges (silently, intermittently). Add an "
|
||||
+ ".addStrategy(MateClawStateKeys.X, KeyStrategy.REPLACE) line for each "
|
||||
+ "missing key in BOTH the ReAct and Plan-Execute KeyStrategyFactory blocks "
|
||||
+ "(or document why the key is intentionally Plan-only / ReAct-only).");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,119 @@
|
||||
package vip.mate.architecture;
|
||||
|
||||
import com.tngtech.archunit.core.domain.JavaClass;
|
||||
import com.tngtech.archunit.core.domain.JavaClasses;
|
||||
import com.tngtech.archunit.core.domain.JavaMethod;
|
||||
import com.tngtech.archunit.core.importer.ClassFileImporter;
|
||||
import com.tngtech.archunit.core.importer.ImportOption;
|
||||
import com.tngtech.archunit.lang.ArchCondition;
|
||||
import com.tngtech.archunit.lang.ConditionEvents;
|
||||
import com.tngtech.archunit.lang.SimpleConditionEvent;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.ai.chat.model.ToolContext;
|
||||
import org.springframework.ai.tool.ToolCallback;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes;
|
||||
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
|
||||
|
||||
/**
|
||||
* RFC-063r §2.3: every concrete {@link ToolCallback} implementation must
|
||||
* override {@code call(String, ToolContext)} so it cannot silently drop the
|
||||
* Spring AI {@link ToolContext} (which carries the {@code ChatOrigin}).
|
||||
*
|
||||
* <p>Background: the previous {@code LocaleAwareToolCallback} only overrode
|
||||
* {@code call(String)}; the framework default routed
|
||||
* {@code call(String, ToolContext)} back to {@code call(String)}, dropping the
|
||||
* context. This test pins the rule so a future regression fails CI.
|
||||
*/
|
||||
class ToolCallbackToolContextForwardArchTest {
|
||||
|
||||
private static final JavaClasses MATECLAW_CLASSES = new ClassFileImporter()
|
||||
.withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS)
|
||||
.importPackages("vip.mate");
|
||||
|
||||
@Test
|
||||
void everyToolCallbackImplementationMustOverrideCallWithToolContext() {
|
||||
classes()
|
||||
.that().implement(ToolCallback.class)
|
||||
.and().areNotInterfaces()
|
||||
.and().areNotAnnotations()
|
||||
.and(haveSimpleNameNot("ToolCallback"))
|
||||
.should(overrideCallWithToolContext())
|
||||
.check(MATECLAW_CLASSES);
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC-063r §5.2 hard rule: {@code CronJobRunner} must NEVER carry
|
||||
* {@code @Transactional} (class-level or method-level). The class is
|
||||
* the entry point for cron-tick execution; an inline transaction would
|
||||
* either swallow self-invocation calls or — worse — hold a DB connection
|
||||
* across the multi-minute LLM call inside {@code runAgent}, exhausting
|
||||
* the HikariCP pool under concurrent cron load.
|
||||
*
|
||||
* <p>The three transactional segments live on
|
||||
* {@code CronJobLifecycleService}; cross-bean invocation routes through
|
||||
* the Spring AOP proxy and works as designed. This test pins the rule.
|
||||
*/
|
||||
@Test
|
||||
void cronJobRunnerMustNotCarryTransactional() {
|
||||
noClasses()
|
||||
.that().haveSimpleName("CronJobRunner")
|
||||
.and().resideInAPackage("vip.mate.cron..")
|
||||
.should(beAnnotatedOrHaveAnyMethodAnnotatedWith(Transactional.class))
|
||||
.because("RFC-063r §5.2: CronJobRunner.runAgent runs an LLM HTTP call (seconds-to-minutes); " +
|
||||
"@Transactional would hold a DB connection during that call and exhaust HikariCP under " +
|
||||
"concurrent cron load. Transactions must live on CronJobLifecycleService instead.")
|
||||
.check(MATECLAW_CLASSES);
|
||||
}
|
||||
|
||||
private static com.tngtech.archunit.base.DescribedPredicate<JavaClass> haveSimpleNameNot(String simpleName) {
|
||||
return new com.tngtech.archunit.base.DescribedPredicate<>("simple name is not " + simpleName) {
|
||||
@Override
|
||||
public boolean test(JavaClass javaClass) {
|
||||
return !javaClass.getSimpleName().equals(simpleName);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static ArchCondition<JavaClass> beAnnotatedOrHaveAnyMethodAnnotatedWith(
|
||||
Class<? extends java.lang.annotation.Annotation> annotation) {
|
||||
String desc = annotation.getName();
|
||||
return new ArchCondition<>("be annotated or have any method annotated with " + desc) {
|
||||
@Override
|
||||
public void check(JavaClass clazz, ConditionEvents events) {
|
||||
if (clazz.isAnnotatedWith(annotation)) {
|
||||
events.add(SimpleConditionEvent.satisfied(clazz,
|
||||
clazz.getFullName() + " is annotated with " + desc));
|
||||
return;
|
||||
}
|
||||
for (JavaMethod m : clazz.getMethods()) {
|
||||
if (m.isAnnotatedWith(annotation)) {
|
||||
events.add(SimpleConditionEvent.satisfied(clazz,
|
||||
clazz.getFullName() + "#" + m.getName() + " is annotated with " + desc));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static ArchCondition<JavaClass> overrideCallWithToolContext() {
|
||||
return new ArchCondition<>("override call(String, ToolContext)") {
|
||||
@Override
|
||||
public void check(JavaClass clazz, ConditionEvents events) {
|
||||
boolean overrides = clazz.getMethods().stream().anyMatch(m ->
|
||||
m.getName().equals("call")
|
||||
&& m.getRawParameterTypes().size() == 2
|
||||
&& m.getRawParameterTypes().get(0).getFullName().equals(String.class.getName())
|
||||
&& m.getRawParameterTypes().get(1).getFullName().equals(ToolContext.class.getName()));
|
||||
if (!overrides) {
|
||||
events.add(SimpleConditionEvent.violated(clazz,
|
||||
clazz.getFullName() + " does not override call(String, ToolContext); "
|
||||
+ "the framework default would silently drop the ChatOrigin "
|
||||
+ "(see RFC-063r §2.3)."));
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,309 @@
|
||||
package vip.mate.auth.pat;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import vip.mate.auth.pat.repository.PersonalAccessTokenMapper;
|
||||
import vip.mate.exception.MateClawException;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HashSet;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* RFC-03 Lane I1 — covers {@link PersonalAccessTokenService} core contracts:
|
||||
*
|
||||
* <ul>
|
||||
* <li>Plaintext format ({@code mc_*}) and uniqueness across mints.</li>
|
||||
* <li>SHA-256 hashing is deterministic and matches a known vector — a
|
||||
* silent change to the hash function would invalidate every existing
|
||||
* row in production, so this is enforced in test.</li>
|
||||
* <li>{@link PersonalAccessTokenService#findActiveByPlaintext} rejects
|
||||
* null, blank, wrong-prefix, hash-miss, disabled, and expired
|
||||
* tokens with no observable difference (don't leak which one).</li>
|
||||
* <li>{@link PersonalAccessTokenService#recordUse} debounces writes so
|
||||
* a CI loop doesn't hammer the row.</li>
|
||||
* <li>{@link PersonalAccessTokenService#revoke} requires owner match —
|
||||
* a token id alone is insufficient to revoke someone else's token.</li>
|
||||
* </ul>
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class PersonalAccessTokenServiceTest {
|
||||
|
||||
@Mock
|
||||
private PersonalAccessTokenMapper mapper;
|
||||
|
||||
@InjectMocks
|
||||
private PersonalAccessTokenService service;
|
||||
|
||||
private PersonalAccessTokenEntity entity;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
entity = new PersonalAccessTokenEntity();
|
||||
entity.setId(42L);
|
||||
entity.setUserId(7L);
|
||||
entity.setName("ci-key");
|
||||
entity.setEnabled(true);
|
||||
}
|
||||
|
||||
// ── Plaintext format ──────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
@DisplayName("generated plaintext starts with mc_ and is sufficiently long for 256-bit entropy")
|
||||
void plaintextFormat() {
|
||||
String tok = service.generatePlaintext();
|
||||
assertTrue(tok.startsWith("mc_"), "PAT must start with the observable mc_ prefix");
|
||||
// 32 bytes base64 url-encoded without padding = 43 chars; total = 3 + 43 = 46.
|
||||
assertEquals(46, tok.length(),
|
||||
"32 bytes of entropy → 43 base64 chars + 3-char prefix; got " + tok);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("each generation yields a unique plaintext (entropy actually random)")
|
||||
void plaintextUniqueness() {
|
||||
Set<String> seen = new HashSet<>();
|
||||
for (int i = 0; i < 100; i++) {
|
||||
assertTrue(seen.add(service.generatePlaintext()),
|
||||
"duplicate within 100 mints — RNG is not actually random");
|
||||
}
|
||||
}
|
||||
|
||||
// ── SHA-256 hashing ──────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
@DisplayName("sha256Hex matches the canonical reference vector for 'abc'")
|
||||
void sha256ReferenceVector() {
|
||||
// From FIPS 180-4 — locking in the algorithm; if this assertion ever
|
||||
// fires, every PAT in the database is invalidated by the same change.
|
||||
assertEquals(
|
||||
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
|
||||
PersonalAccessTokenService.sha256Hex("abc"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("sha256Hex output is always 64 lowercase hex chars")
|
||||
void sha256OutputShape() {
|
||||
String h = PersonalAccessTokenService.sha256Hex("any plaintext");
|
||||
assertEquals(64, h.length());
|
||||
assertTrue(h.matches("[0-9a-f]+"));
|
||||
}
|
||||
|
||||
// ── findActiveByPlaintext rejection paths ─────────────────────────────
|
||||
|
||||
@Test
|
||||
@DisplayName("null / blank input returns empty without DB roundtrip")
|
||||
void nullBlankReturnsEmpty() {
|
||||
assertTrue(service.findActiveByPlaintext(null).isEmpty());
|
||||
assertTrue(service.findActiveByPlaintext("").isEmpty());
|
||||
assertTrue(service.findActiveByPlaintext(" ").isEmpty());
|
||||
verify(mapper, never()).selectOne(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("token without mc_ prefix returns empty without DB roundtrip")
|
||||
void wrongPrefixReturnsEmpty() {
|
||||
// JWT-shaped value should not even hit the DB — keeps the auth filter
|
||||
// dispatch cheap when callers send either token type by mistake.
|
||||
assertTrue(service.findActiveByPlaintext("eyJhbGciOiJIUzI1NiJ9...").isEmpty());
|
||||
verify(mapper, never()).selectOne(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("hash miss returns empty")
|
||||
void hashMissReturnsEmpty() {
|
||||
when(mapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(null);
|
||||
assertTrue(service.findActiveByPlaintext("mc_unknown_token").isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("expired token returns empty even when the row matches")
|
||||
void expiredTokenReturnsEmpty() {
|
||||
entity.setExpiresAt(LocalDateTime.now().minusMinutes(1));
|
||||
when(mapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(entity);
|
||||
assertTrue(service.findActiveByPlaintext("mc_some_plaintext").isEmpty(),
|
||||
"expired tokens must reject — past-expiry is the same as no-such-token from auth's PoV");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("active, unexpired token returns the entity")
|
||||
void activeTokenReturned() {
|
||||
entity.setExpiresAt(LocalDateTime.now().plusDays(7));
|
||||
when(mapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(entity);
|
||||
|
||||
Optional<PersonalAccessTokenEntity> result = service.findActiveByPlaintext("mc_valid_plaintext");
|
||||
|
||||
assertTrue(result.isPresent());
|
||||
assertEquals(42L, result.get().getId());
|
||||
}
|
||||
|
||||
// ── recordUse debounce predicate (pure logic) ─────────────────────────
|
||||
|
||||
@Test
|
||||
@DisplayName("shouldRecordUse — null lastUsedAt returns true (first write always proceeds)")
|
||||
void shouldRecordUseFirstCall() {
|
||||
assertTrue(PersonalAccessTokenService.shouldRecordUse(null, LocalDateTime.now()));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("shouldRecordUse — within 60s of last write returns false (debounced)")
|
||||
void shouldRecordUseDebounced() {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
// 30s ago — well within the 60s window.
|
||||
assertFalse(PersonalAccessTokenService.shouldRecordUse(now.minusSeconds(30), now));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("shouldRecordUse — after 60s window returns true (writes again)")
|
||||
void shouldRecordUseAfterWindow() {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
// 2 min ago — beyond the 60s debounce.
|
||||
assertTrue(PersonalAccessTokenService.shouldRecordUse(now.minusMinutes(2), now));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("shouldRecordUse — exactly at 60s boundary returns true")
|
||||
void shouldRecordUseAtBoundary() {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
// 61s ago — just past the boundary.
|
||||
assertTrue(PersonalAccessTokenService.shouldRecordUse(now.minusSeconds(61), now));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("recordUse — first write hits the mapper")
|
||||
void recordUseFirstCallWrites() {
|
||||
service.recordUse(entity);
|
||||
verify(mapper, times(1)).updateById(any(PersonalAccessTokenEntity.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("recordUse — second call within debounce skips the mapper")
|
||||
void recordUseDebouncedSkipsMapper() {
|
||||
entity.setLastUsedAt(LocalDateTime.now());
|
||||
service.recordUse(entity);
|
||||
verify(mapper, never()).updateById(any(PersonalAccessTokenEntity.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("recordUse swallows DB errors — never fails an authenticated request")
|
||||
void recordUseSwallowsErrors() {
|
||||
when(mapper.updateById(any(PersonalAccessTokenEntity.class)))
|
||||
.thenThrow(new RuntimeException("simulated DB outage"));
|
||||
// Must not throw — last-used is observability, not a correctness gate.
|
||||
service.recordUse(entity);
|
||||
}
|
||||
|
||||
// ── revoke ownership ──────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
@DisplayName("revoke with matching owner soft-deletes")
|
||||
void revokeOwnedToken() {
|
||||
when(mapper.selectById(42L)).thenReturn(entity);
|
||||
when(mapper.updateById(any(PersonalAccessTokenEntity.class))).thenReturn(1);
|
||||
|
||||
service.revoke(42L, 7L);
|
||||
|
||||
verify(mapper, times(1)).updateById(any(PersonalAccessTokenEntity.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("revoke with wrong owner throws not-found — no info leak about token ownership")
|
||||
void revokeWrongOwner() {
|
||||
// Token exists but belongs to user 7, not 999.
|
||||
when(mapper.selectById(42L)).thenReturn(entity);
|
||||
|
||||
var ex = assertThrows(MateClawException.class,
|
||||
() -> service.revoke(42L, 999L));
|
||||
assertTrue(ex.getMessage().contains("not found") || ex.getMessage().contains("not owned"),
|
||||
"error message must indicate not-found, not 'unauthorized' — to avoid leaking which token ids exist");
|
||||
// Critically: must NOT have called updateById — owner check happens before any write.
|
||||
verify(mapper, never()).updateById(any(PersonalAccessTokenEntity.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("revoke of missing token throws not-found")
|
||||
void revokeMissingToken() {
|
||||
when(mapper.selectById(99L)).thenReturn(null);
|
||||
assertThrows(MateClawException.class,
|
||||
() -> service.revoke(99L, 7L));
|
||||
verify(mapper, never()).updateById(any(PersonalAccessTokenEntity.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("revoke of already-deleted token throws not-found (no double-delete confusion)")
|
||||
void revokeAlreadyDeletedToken() {
|
||||
entity.setDeleted(1);
|
||||
when(mapper.selectById(42L)).thenReturn(entity);
|
||||
assertThrows(MateClawException.class,
|
||||
() -> service.revoke(42L, 7L));
|
||||
verify(mapper, never()).updateById(any(PersonalAccessTokenEntity.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("create requires non-null userId")
|
||||
void createRequiresUserId() {
|
||||
assertThrows(MateClawException.class,
|
||||
() -> service.create(null, "name", null, null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("tokenHash never leaks via Jackson serialization (privacy regression guard)")
|
||||
void tokenHashDoesNotLeakInJson() throws Exception {
|
||||
// The list endpoint returns PersonalAccessTokenEntity directly to
|
||||
// the client. Jackson must skip tokenHash even when other fields
|
||||
// serialize normally — otherwise admin UI / log middleware leaks
|
||||
// the per-token digest. Smoke test on 2026-05-02 caught this.
|
||||
PersonalAccessTokenEntity e = new PersonalAccessTokenEntity();
|
||||
e.setId(123L);
|
||||
e.setUserId(7L);
|
||||
e.setName("ci-key");
|
||||
e.setTokenHash("8020f458548f7b433f872da4d6828933e4f3ba421823f3e7010c9ffd3c505f20");
|
||||
e.setScopes("*");
|
||||
e.setEnabled(true);
|
||||
|
||||
String json = new ObjectMapper().writeValueAsString(e);
|
||||
|
||||
assertFalse(json.contains("tokenHash"),
|
||||
"tokenHash field name leaked to JSON: " + json);
|
||||
assertFalse(json.contains("8020f458"),
|
||||
"tokenHash value leaked to JSON: " + json);
|
||||
// Sanity: other fields still serialize so we didn't accidentally
|
||||
// @JsonIgnore the wrong field.
|
||||
assertTrue(json.contains("\"name\":\"ci-key\""));
|
||||
assertTrue(json.contains("\"id\":123"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("create returns plaintext exactly once and inserts the row")
|
||||
void createReturnsPlaintext() {
|
||||
when(mapper.insert(any(PersonalAccessTokenEntity.class))).thenReturn(1);
|
||||
PersonalAccessTokenService.CreatedToken result = service.create(
|
||||
7L, "ci-key", "*", LocalDateTime.now().plusDays(30));
|
||||
|
||||
assertNotNull(result);
|
||||
assertNotNull(result.plaintext());
|
||||
assertTrue(result.plaintext().startsWith("mc_"));
|
||||
assertNotNull(result.entity());
|
||||
// The row inserted into DB must NOT carry plaintext — only the hash.
|
||||
assertFalse(result.plaintext().equals(result.entity().getTokenHash()),
|
||||
"DB must store the hash, not the plaintext");
|
||||
}
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,440 @@
|
||||
package vip.mate.channel;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import vip.mate.channel.leader.ChannelLeaderElection;
|
||||
import vip.mate.channel.leader.LeaderLease;
|
||||
import vip.mate.channel.model.ChannelEntity;
|
||||
import vip.mate.channel.service.ChannelService;
|
||||
import vip.mate.exception.MateClawException;
|
||||
import vip.mate.workspace.conversation.model.MessageContentPart;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ScheduledFuture;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* Behavioural tests for the multi-instance reconciliation paths:
|
||||
* heartbeat-driven detection of disabled / deleted / config-changed
|
||||
* channels, and follower-retry cancellation on channel deletion.
|
||||
*
|
||||
* <p>These exercise the fixes that prevent a leader node from running
|
||||
* stale config (or a deleted channel) just because the admin API call
|
||||
* happened to land on a different node.
|
||||
*/
|
||||
class ChannelManagerReconcileTest {
|
||||
|
||||
private ChannelService channelService;
|
||||
private ChannelLeaderElection election;
|
||||
private ChannelManager manager;
|
||||
private TrackingAdapter adapter;
|
||||
|
||||
@BeforeEach
|
||||
@SuppressWarnings("unchecked")
|
||||
void setUp() {
|
||||
channelService = mock(ChannelService.class);
|
||||
election = mock(ChannelLeaderElection.class);
|
||||
manager = new ChannelManager(
|
||||
channelService,
|
||||
mock(ChannelMessageRouter.class),
|
||||
mock(ChannelSessionStore.class),
|
||||
new ObjectMapper(),
|
||||
mock(vip.mate.tool.document.GeneratedFileCache.class),
|
||||
mock(vip.mate.channel.notification.ApprovalNotificationService.class),
|
||||
mock(vip.mate.channel.wecom.cards.WeComCardDispatcher.class),
|
||||
mock(vip.mate.channel.wecom.WeComKeepaliveScheduler.class),
|
||||
election);
|
||||
adapter = new TrackingAdapter();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
// Shut down the leaderScheduler so test threads don't leak.
|
||||
manager.destroy();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("heartbeat reconcile: disabled channel triggers local stop")
|
||||
void reconcileStopsOnDisabled() {
|
||||
LeaderLease lease = mock(LeaderLease.class);
|
||||
seedLeaderState(42L, adapter, lease, LocalDateTime.of(2026, 1, 1, 0, 0));
|
||||
|
||||
ChannelEntity disabled = entity(42L, "feishu");
|
||||
disabled.setEnabled(false);
|
||||
disabled.setUpdateTime(LocalDateTime.of(2026, 1, 1, 0, 0));
|
||||
when(channelService.getChannel(42L)).thenReturn(disabled);
|
||||
|
||||
manager.reconcileChannel(42L, "test-channel");
|
||||
|
||||
assertFalse(manager.getAdapter(42L).isPresent(),
|
||||
"Disabled channel detected via reconciliation must stop local adapter");
|
||||
assertTrue(adapter.stopped.get(), "Adapter stop() must be invoked");
|
||||
verify(lease, times(1)).release();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("heartbeat reconcile: not-found exception triggers local stop and lease release")
|
||||
void reconcileStopsOnNotFound() {
|
||||
LeaderLease lease = mock(LeaderLease.class);
|
||||
seedLeaderState(43L, adapter, lease, LocalDateTime.of(2026, 1, 1, 0, 0));
|
||||
|
||||
when(channelService.getChannel(43L))
|
||||
.thenThrow(new MateClawException("err.channel.not_found", "渠道不存在: 43"));
|
||||
|
||||
manager.reconcileChannel(43L, "test-channel");
|
||||
|
||||
assertFalse(manager.getAdapter(43L).isPresent(),
|
||||
"Deleted channel detected via reconciliation must stop local adapter");
|
||||
assertTrue(adapter.stopped.get());
|
||||
verify(lease, times(1)).release();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("heartbeat reconcile: transient DB error keeps adapter running (no false-positive stop)")
|
||||
void reconcileKeepsRunningOnTransientFailure() {
|
||||
LeaderLease lease = mock(LeaderLease.class);
|
||||
seedLeaderState(44L, adapter, lease, LocalDateTime.of(2026, 1, 1, 0, 0));
|
||||
|
||||
when(channelService.getChannel(44L)).thenThrow(new RuntimeException("connection refused"));
|
||||
|
||||
manager.reconcileChannel(44L, "test-channel");
|
||||
|
||||
assertTrue(manager.getAdapter(44L).isPresent(),
|
||||
"Transient lookup errors must not stop the local adapter");
|
||||
assertFalse(adapter.stopped.get());
|
||||
verify(lease, never()).release();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("config change to non-leader-required mode releases the lease (e.g. Feishu WS → webhook)")
|
||||
void modeFlipOutOfLeaderRequiredReleasesLease() {
|
||||
LeaderLease lease = mock(LeaderLease.class);
|
||||
seedLeaderState(50L, adapter, lease, LocalDateTime.of(2026, 1, 1, 0, 0));
|
||||
|
||||
// Stub createAdapter so that the swap doesn't need a real
|
||||
// network-backed Feishu/Telegram start(). The fresh adapter
|
||||
// reports requiresSingleLeader=false, simulating a mode flip.
|
||||
TrackingAdapter newAdapter = new TrackingAdapter() {
|
||||
@Override public boolean requiresSingleLeader() { return false; }
|
||||
};
|
||||
ChannelManager spied = spy(manager);
|
||||
doReturn(newAdapter).when(spied).createAdapter(any(ChannelEntity.class));
|
||||
|
||||
ChannelEntity updated = entity(50L, "feishu");
|
||||
updated.setUpdateTime(LocalDateTime.of(2026, 1, 1, 0, 5));
|
||||
|
||||
spied.applyConfigChange(50L, updated);
|
||||
|
||||
verify(lease, times(1)).release();
|
||||
// The new (non-leader) adapter is started locally on this node.
|
||||
assertTrue(spied.getAdapter(50L).isPresent(),
|
||||
"After mode flip, the local node continues running the channel as a non-leader");
|
||||
assertTrue(newAdapter.started.get(), "New adapter must be started after flip");
|
||||
assertTrue(adapter.stopped.get(), "Old adapter must be stopped before swap");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("config change within leader-required mode preserves the lease (in-place swap)")
|
||||
void inPlaceSwapPreservesLease() {
|
||||
LeaderLease lease = mock(LeaderLease.class);
|
||||
seedLeaderState(51L, adapter, lease, LocalDateTime.of(2026, 1, 1, 0, 0));
|
||||
|
||||
TrackingAdapter newAdapter = new TrackingAdapter(); // requiresSingleLeader=true
|
||||
ChannelManager spied = spy(manager);
|
||||
doReturn(newAdapter).when(spied).createAdapter(any(ChannelEntity.class));
|
||||
|
||||
ChannelEntity updated = entity(51L, "feishu");
|
||||
updated.setUpdateTime(LocalDateTime.of(2026, 1, 1, 0, 5));
|
||||
|
||||
spied.applyConfigChange(51L, updated);
|
||||
|
||||
verify(lease, never()).release();
|
||||
assertTrue(spied.getAdapter(51L).isPresent());
|
||||
assertTrue(newAdapter.started.get());
|
||||
assertTrue(adapter.stopped.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("follower retry: not-found cancels the scheduled retry (no leak)")
|
||||
void followerRetryCancelsOnNotFound() {
|
||||
// Seed a follower retry future so we can verify cancellation.
|
||||
ScheduledFuture<?> future = mock(ScheduledFuture.class);
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<Long, ScheduledFuture<?>> followerRetries =
|
||||
(Map<Long, ScheduledFuture<?>>) ReflectionTestUtils.getField(manager, "followerRetryFutures");
|
||||
assertNotNull(followerRetries);
|
||||
followerRetries.put(99L, future);
|
||||
|
||||
when(channelService.getChannel(99L))
|
||||
.thenThrow(new MateClawException("err.channel.not_found", "渠道不存在: 99"));
|
||||
|
||||
manager.followerRetry(99L);
|
||||
|
||||
assertFalse(manager.hasFollowerRetry(99L),
|
||||
"Deleted channel must cancel the follower retry future");
|
||||
verify(future, times(1)).cancel(false);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("follower retry: transient DB error keeps retry scheduled (no false-positive cancel)")
|
||||
void followerRetryKeepsOnTransientFailure() {
|
||||
ScheduledFuture<?> future = mock(ScheduledFuture.class);
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<Long, ScheduledFuture<?>> followerRetries =
|
||||
(Map<Long, ScheduledFuture<?>>) ReflectionTestUtils.getField(manager, "followerRetryFutures");
|
||||
followerRetries.put(100L, future);
|
||||
|
||||
when(channelService.getChannel(100L)).thenThrow(new RuntimeException("connection refused"));
|
||||
|
||||
manager.followerRetry(100L);
|
||||
|
||||
assertTrue(manager.hasFollowerRetry(100L),
|
||||
"Transient lookup errors must not cancel the follower retry");
|
||||
verify(future, never()).cancel(any(Boolean.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("non-leader reconcile: disabled channel on another node triggers local stop")
|
||||
void nonLeaderReconcileStopsOnDisabled() {
|
||||
// Seed a non-leader active adapter (no lease, no heartbeat) — this is
|
||||
// the state a Feishu-webhook or Telegram-webhook node lives in.
|
||||
TrackingAdapter webhookAdapter = new TrackingAdapter() {
|
||||
@Override public boolean requiresSingleLeader() { return false; }
|
||||
};
|
||||
seedNonLeaderState(60L, webhookAdapter, LocalDateTime.of(2026, 1, 1, 0, 0));
|
||||
|
||||
ChannelEntity disabled = entity(60L, "feishu");
|
||||
disabled.setEnabled(false);
|
||||
disabled.setUpdateTime(LocalDateTime.of(2026, 1, 1, 0, 0));
|
||||
when(channelService.getChannel(60L)).thenReturn(disabled);
|
||||
|
||||
manager.reconcileChannel(60L, "webhook-channel");
|
||||
|
||||
assertFalse(manager.getAdapter(60L).isPresent(),
|
||||
"Non-leader reconcile must stop the local adapter when admin disables on another node");
|
||||
assertTrue(webhookAdapter.stopped.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("non-leader → leader-required flip: winner becomes leader (no direct start without election)")
|
||||
void nonLeaderToLeaderFlipWinsElection() {
|
||||
// Seed a non-leader webhook adapter (no lease).
|
||||
TrackingAdapter webhookAdapter = new TrackingAdapter() {
|
||||
@Override public boolean requiresSingleLeader() { return false; }
|
||||
};
|
||||
seedNonLeaderState(80L, webhookAdapter, LocalDateTime.of(2026, 1, 1, 0, 0));
|
||||
|
||||
// New config flips into leader-required mode.
|
||||
TrackingAdapter wsAdapter = new TrackingAdapter(); // requiresSingleLeader=true
|
||||
ChannelManager spied = spy(manager);
|
||||
doReturn(wsAdapter).when(spied).createAdapter(any(ChannelEntity.class));
|
||||
|
||||
// This node wins the election.
|
||||
LeaderLease lease = mock(LeaderLease.class);
|
||||
when(election.tryAcquire(anyString())).thenReturn(Optional.of(lease));
|
||||
|
||||
ChannelEntity flipped = entity(80L, "feishu");
|
||||
flipped.setUpdateTime(LocalDateTime.of(2026, 1, 1, 0, 5));
|
||||
|
||||
spied.applyConfigChange(80L, flipped);
|
||||
|
||||
assertTrue(webhookAdapter.stopped.get(), "Old non-leader adapter must be stopped");
|
||||
assertTrue(wsAdapter.started.get(), "New leader-required adapter starts only after we won the election");
|
||||
// Lease is recorded so the heartbeat can extend it.
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<Long, LeaderLease> leases =
|
||||
(Map<Long, LeaderLease>) ReflectionTestUtils.getField(spied, "activeLeases");
|
||||
assertSame(lease, leases.get(80L), "Won lease must be tracked under the channel id");
|
||||
verify(election, times(1)).tryAcquire("feishu:80");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("non-leader → leader-required flip: loser does NOT start the new adapter and enters follower retry")
|
||||
void nonLeaderToLeaderFlipLosesElection() {
|
||||
TrackingAdapter webhookAdapter = new TrackingAdapter() {
|
||||
@Override public boolean requiresSingleLeader() { return false; }
|
||||
};
|
||||
seedNonLeaderState(81L, webhookAdapter, LocalDateTime.of(2026, 1, 1, 0, 0));
|
||||
|
||||
TrackingAdapter wsAdapter = new TrackingAdapter();
|
||||
ChannelManager spied = spy(manager);
|
||||
doReturn(wsAdapter).when(spied).createAdapter(any(ChannelEntity.class));
|
||||
|
||||
// Another node already holds the lease.
|
||||
when(election.tryAcquire(anyString())).thenReturn(Optional.empty());
|
||||
|
||||
ChannelEntity flipped = entity(81L, "feishu");
|
||||
flipped.setUpdateTime(LocalDateTime.of(2026, 1, 1, 0, 5));
|
||||
|
||||
spied.applyConfigChange(81L, flipped);
|
||||
|
||||
assertTrue(webhookAdapter.stopped.get(), "Old non-leader adapter must be stopped");
|
||||
assertFalse(wsAdapter.started.get(),
|
||||
"Loser must NOT call newAdapter.start() — that would open a duplicate WS bypassing the leader gate");
|
||||
assertFalse(spied.getAdapter(81L).isPresent());
|
||||
assertTrue(spied.hasFollowerRetry(81L), "Loser must enter follower retry to take over if the current leader dies");
|
||||
verify(election, times(1)).tryAcquire("feishu:81");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("non-leader reconcile: config change on another node propagates via stop+start")
|
||||
void nonLeaderReconcileAppliesConfigUpdate() {
|
||||
TrackingAdapter oldAdapter = new TrackingAdapter() {
|
||||
@Override public boolean requiresSingleLeader() { return false; }
|
||||
};
|
||||
seedNonLeaderState(61L, oldAdapter, LocalDateTime.of(2026, 1, 1, 0, 0));
|
||||
|
||||
TrackingAdapter newAdapter = new TrackingAdapter() {
|
||||
@Override public boolean requiresSingleLeader() { return false; }
|
||||
};
|
||||
ChannelManager spied = spy(manager);
|
||||
doReturn(newAdapter).when(spied).createAdapter(any(ChannelEntity.class));
|
||||
|
||||
ChannelEntity updated = entity(61L, "feishu");
|
||||
updated.setUpdateTime(LocalDateTime.of(2026, 1, 1, 0, 5));
|
||||
when(channelService.getChannel(61L)).thenReturn(updated);
|
||||
|
||||
spied.reconcileChannel(61L, "webhook-channel");
|
||||
|
||||
assertTrue(oldAdapter.stopped.get(), "Old non-leader adapter must be stopped");
|
||||
assertTrue(newAdapter.started.get(), "New non-leader adapter must be started");
|
||||
assertTrue(spied.getAdapter(61L).isPresent());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("restartChannel: when we hold a lease and new mode is non-leader, release the lease via stop+start")
|
||||
void restartChannelDetectsLeaseFlip() {
|
||||
LeaderLease lease = mock(LeaderLease.class);
|
||||
seedLeaderState(70L, adapter, lease, LocalDateTime.of(2026, 1, 1, 0, 0));
|
||||
|
||||
// Make createAdapter produce a non-leader adapter for the restart.
|
||||
// Without the lease-aware check, restartChannel would take the
|
||||
// hot-swap path and leave the lease, heartbeat, and
|
||||
// lastSeenChannelUpdateTime around to be cleaned up only by the
|
||||
// next heartbeat tick — causing an additional restart.
|
||||
TrackingAdapter newAdapter = new TrackingAdapter() {
|
||||
@Override public boolean requiresSingleLeader() { return false; }
|
||||
};
|
||||
ChannelManager spied = spy(manager);
|
||||
doReturn(newAdapter).when(spied).createAdapter(any(ChannelEntity.class));
|
||||
|
||||
ChannelEntity updated = entity(70L, "feishu");
|
||||
updated.setUpdateTime(LocalDateTime.of(2026, 1, 1, 0, 5));
|
||||
when(channelService.getChannel(70L)).thenReturn(updated);
|
||||
|
||||
spied.restartChannel(70L);
|
||||
|
||||
verify(lease, times(1)).release();
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<Long, LeaderLease> leases =
|
||||
(Map<Long, LeaderLease>) ReflectionTestUtils.getField(spied, "activeLeases");
|
||||
assertFalse(leases.containsKey(70L), "Lease must be removed from activeLeases after the mode flip");
|
||||
assertTrue(adapter.stopped.get());
|
||||
assertTrue(newAdapter.started.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("stopAll releases plugin leases and cancels plugin heartbeats (no leak on shutdown)")
|
||||
@SuppressWarnings("unchecked")
|
||||
void stopAllReleasesPluginLeases() {
|
||||
ChannelAdapter pluginAdapter = mock(ChannelAdapter.class);
|
||||
when(pluginAdapter.getChannelType()).thenReturn("custom-im");
|
||||
when(pluginAdapter.getDisplayName()).thenReturn("custom-im");
|
||||
LeaderLease pluginLease = mock(LeaderLease.class);
|
||||
ScheduledFuture<?> pluginHeartbeat = mock(ScheduledFuture.class);
|
||||
|
||||
Map<String, ChannelAdapter> pluginChannels =
|
||||
(Map<String, ChannelAdapter>) ReflectionTestUtils.getField(manager, "pluginChannels");
|
||||
Map<String, LeaderLease> pluginLeases =
|
||||
(Map<String, LeaderLease>) ReflectionTestUtils.getField(manager, "pluginLeases");
|
||||
Map<String, ScheduledFuture<?>> pluginHeartbeats =
|
||||
(Map<String, ScheduledFuture<?>>) ReflectionTestUtils.getField(manager, "pluginHeartbeatFutures");
|
||||
pluginChannels.put("my-plugin", pluginAdapter);
|
||||
pluginLeases.put("my-plugin", pluginLease);
|
||||
pluginHeartbeats.put("my-plugin", pluginHeartbeat);
|
||||
|
||||
manager.stopAll();
|
||||
|
||||
verify(pluginAdapter, times(1)).stop();
|
||||
verify(pluginLease, times(1)).release();
|
||||
verify(pluginHeartbeat, times(1)).cancel(false);
|
||||
assertTrue(pluginChannels.isEmpty());
|
||||
assertTrue(pluginLeases.isEmpty());
|
||||
assertTrue(pluginHeartbeats.isEmpty());
|
||||
}
|
||||
|
||||
// ==================== helpers ====================
|
||||
|
||||
private ChannelEntity entity(Long id, String type) {
|
||||
ChannelEntity e = new ChannelEntity();
|
||||
e.setId(id);
|
||||
e.setName("test-" + id);
|
||||
e.setChannelType(type);
|
||||
e.setEnabled(true);
|
||||
return e;
|
||||
}
|
||||
|
||||
private void seedLeaderState(Long id, ChannelAdapter adapter, LeaderLease lease,
|
||||
LocalDateTime updateTime) {
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<Long, ChannelAdapter> active =
|
||||
(Map<Long, ChannelAdapter>) ReflectionTestUtils.getField(manager, "activeAdapters");
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<Long, LeaderLease> leases =
|
||||
(Map<Long, LeaderLease>) ReflectionTestUtils.getField(manager, "activeLeases");
|
||||
active.put(id, adapter);
|
||||
leases.put(id, lease);
|
||||
lastSeenMap().put(id, updateTime);
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed a non-leader-required active adapter: appears in
|
||||
* {@code activeAdapters} and {@code lastSeenChannelUpdateTime}, but
|
||||
* no entry in {@code activeLeases} / {@code heartbeatFutures} (those
|
||||
* only exist for leader-required modes).
|
||||
*/
|
||||
private void seedNonLeaderState(Long id, ChannelAdapter adapter, LocalDateTime updateTime) {
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<Long, ChannelAdapter> active =
|
||||
(Map<Long, ChannelAdapter>) ReflectionTestUtils.getField(manager, "activeAdapters");
|
||||
active.put(id, adapter);
|
||||
lastSeenMap().put(id, updateTime);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private Map<Long, LocalDateTime> lastSeenMap() {
|
||||
return (Map<Long, LocalDateTime>) ReflectionTestUtils.getField(manager, "lastSeenChannelUpdateTime");
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal adapter that records start/stop without opening any
|
||||
* upstream connection — the tests need observable state, not real
|
||||
* IM behavior.
|
||||
*/
|
||||
private static class TrackingAdapter implements ChannelAdapter {
|
||||
final AtomicBoolean started = new AtomicBoolean(false);
|
||||
final AtomicBoolean stopped = new AtomicBoolean(false);
|
||||
|
||||
@Override public void start() { started.set(true); }
|
||||
@Override public void stop() { stopped.set(true); }
|
||||
@Override public boolean isRunning() { return started.get() && !stopped.get(); }
|
||||
@Override public void onMessage(ChannelMessage message) {}
|
||||
@Override public void sendMessage(String targetId, String content) {}
|
||||
@Override public void sendContentParts(String targetId, List<MessageContentPart> parts) {}
|
||||
@Override public String getChannelType() { return "feishu"; }
|
||||
@Override public boolean requiresSingleLeader() { return true; }
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,70 @@
|
||||
package vip.mate.channel;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* Pin the adaptive-debounce thresholds in
|
||||
* {@link ChannelMessageRouter#pickDebounceMs(int)}.
|
||||
*
|
||||
* <p>The router merges same-conversation messages within a debounce window
|
||||
* before forwarding to the agent. The default {@link
|
||||
* ChannelMessageRouter#DEBOUNCE_MS} (500ms) is right for normal chatting
|
||||
* but too short for IM clients that silently split long pasted prompts
|
||||
* across multiple frames — the second fragment can arrive 1-2 seconds
|
||||
* after the first, missing the window. When merged content crosses
|
||||
* {@link ChannelMessageRouter#LONG_TEXT_THRESHOLD} the merger switches to
|
||||
* {@link ChannelMessageRouter#LONG_DEBOUNCE_MS} so it has time to absorb
|
||||
* the rest. These tests document that boundary behavior so future tuning
|
||||
* is intentional rather than incidental.
|
||||
*/
|
||||
class ChannelMessageRouterDebounceTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("short messages keep the 500ms default debounce")
|
||||
void shortMessagesKeepDefaultDebounce() {
|
||||
assertEquals(ChannelMessageRouter.DEBOUNCE_MS,
|
||||
ChannelMessageRouter.pickDebounceMs(0));
|
||||
assertEquals(ChannelMessageRouter.DEBOUNCE_MS,
|
||||
ChannelMessageRouter.pickDebounceMs(50));
|
||||
assertEquals(ChannelMessageRouter.DEBOUNCE_MS,
|
||||
ChannelMessageRouter.pickDebounceMs(500));
|
||||
assertEquals(ChannelMessageRouter.DEBOUNCE_MS,
|
||||
ChannelMessageRouter.pickDebounceMs(ChannelMessageRouter.LONG_TEXT_THRESHOLD));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("crossing the threshold flips to the extended 2.5s window")
|
||||
void longContentTriggersLongDebounce() {
|
||||
assertEquals(ChannelMessageRouter.LONG_DEBOUNCE_MS,
|
||||
ChannelMessageRouter.pickDebounceMs(ChannelMessageRouter.LONG_TEXT_THRESHOLD + 1));
|
||||
assertEquals(ChannelMessageRouter.LONG_DEBOUNCE_MS,
|
||||
ChannelMessageRouter.pickDebounceMs(2000));
|
||||
assertEquals(ChannelMessageRouter.LONG_DEBOUNCE_MS,
|
||||
ChannelMessageRouter.pickDebounceMs(6000));
|
||||
assertEquals(ChannelMessageRouter.LONG_DEBOUNCE_MS,
|
||||
ChannelMessageRouter.pickDebounceMs(Integer.MAX_VALUE));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("threshold + windows are sane: long > default, threshold below typical IM split")
|
||||
void thresholdsAreSane() {
|
||||
// The whole point — the extended window must actually be larger,
|
||||
// otherwise the adaptive branch is a no-op.
|
||||
assertTrue(ChannelMessageRouter.LONG_DEBOUNCE_MS > ChannelMessageRouter.DEBOUNCE_MS,
|
||||
"extended debounce must exceed default");
|
||||
// Threshold sits below the typical ~2000-char WeCom client split
|
||||
// point; if it ever crept above 2000 the merger would never
|
||||
// engage on a real paste-split.
|
||||
assertTrue(ChannelMessageRouter.LONG_TEXT_THRESHOLD < 2000,
|
||||
"threshold must stay under the IM client's split point");
|
||||
// And well above any normally typed message — typing 1500+ chars
|
||||
// in one bubble is extremely rare. Guards against accidentally
|
||||
// applying the long-debounce penalty to ordinary chats.
|
||||
assertTrue(ChannelMessageRouter.LONG_TEXT_THRESHOLD >= 1000,
|
||||
"threshold must be high enough that typing doesn't trip it");
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,153 @@
|
||||
package vip.mate.channel;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* Pin the group-chat sender-attribution contract.
|
||||
*
|
||||
* <p>In groups, three users sharing one conversation send overlapping
|
||||
* questions. Without {@code [@sender]} tags, the persisted history
|
||||
* collapses into an unattributed wall of "user:" turns and the LLM can
|
||||
* no longer tell who asked what. Without per-sender debounce boundaries,
|
||||
* a paste-split fragment from user A can also accidentally absorb user
|
||||
* B's text and mis-attribute it. These tests pin both contracts so a
|
||||
* future refactor can't silently regress group multi-user usability.
|
||||
*/
|
||||
class ChannelMessageRouterGroupAttributionTest {
|
||||
|
||||
private static ChannelMessage groupMessage(String senderId, String senderName, String content) {
|
||||
return ChannelMessage.builder()
|
||||
.channelType("wecom")
|
||||
.senderId(senderId)
|
||||
.senderName(senderName)
|
||||
.chatId("group-abc") // chatId set ⇒ group context
|
||||
.content(content)
|
||||
.build();
|
||||
}
|
||||
|
||||
private static ChannelMessage singleMessage(String senderId, String content) {
|
||||
return ChannelMessage.builder()
|
||||
.channelType("wecom")
|
||||
.senderId(senderId)
|
||||
.senderName(senderId)
|
||||
.chatId(null) // chatId null ⇒ 1:1 chat
|
||||
.content(content)
|
||||
.build();
|
||||
}
|
||||
|
||||
// ===== buildGroupTag =====
|
||||
|
||||
@Test
|
||||
@DisplayName("single chat (chatId null) → no tag, no behavior change")
|
||||
void singleChatNoTag() {
|
||||
assertNull(ChannelMessageRouter.buildGroupTag(singleMessage("alice", "hi")));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("group chat with senderName → [@senderName] tag")
|
||||
void groupWithSenderName() {
|
||||
ChannelMessage m = groupMessage("alice-id", "Alice Wang", "hi");
|
||||
assertEquals("[@Alice Wang]", ChannelMessageRouter.buildGroupTag(m));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("group chat falls back to senderId when senderName is blank")
|
||||
void groupFallsBackToSenderId() {
|
||||
ChannelMessage m = groupMessage("alice-id", "", "hi");
|
||||
assertEquals("[@alice-id]", ChannelMessageRouter.buildGroupTag(m));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("group chat with no resolvable identity returns null (don't fabricate a tag)")
|
||||
void groupNoIdentity() {
|
||||
ChannelMessage m = ChannelMessage.builder()
|
||||
.channelType("wecom")
|
||||
.chatId("group-abc")
|
||||
.content("hi")
|
||||
.build();
|
||||
// Both senderId and senderName are null. Better to skip attribution
|
||||
// than to invent "[@null]" which would corrupt the prompt.
|
||||
assertNull(ChannelMessageRouter.buildGroupTag(m));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("blank chatId is treated as not-a-group")
|
||||
void blankChatIdNotAGroup() {
|
||||
ChannelMessage m = ChannelMessage.builder()
|
||||
.channelType("wecom")
|
||||
.senderId("alice")
|
||||
.senderName("Alice")
|
||||
.chatId(" ")
|
||||
.content("hi")
|
||||
.build();
|
||||
assertNull(ChannelMessageRouter.buildGroupTag(m));
|
||||
}
|
||||
|
||||
// ===== applyGroupTag =====
|
||||
|
||||
@Test
|
||||
@DisplayName("applyGroupTag: single chat content passes through verbatim")
|
||||
void applyTagSingleChatPassesThrough() {
|
||||
ChannelMessage m = singleMessage("alice", "hello world");
|
||||
assertEquals("hello world",
|
||||
ChannelMessageRouter.applyGroupTag(m, "hello world"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("applyGroupTag: group content gets [@sender] prefix")
|
||||
void applyTagGroupPrefixes() {
|
||||
ChannelMessage m = groupMessage("alice-id", "Alice", "hello world");
|
||||
assertEquals("[@Alice] hello world",
|
||||
ChannelMessageRouter.applyGroupTag(m, "hello world"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("applyGroupTag: idempotent — already-prefixed content is not double-tagged")
|
||||
void applyTagIdempotent() {
|
||||
ChannelMessage m = groupMessage("alice-id", "Alice", "ignored");
|
||||
// Simulates a code path that has already attributed the content
|
||||
// (e.g. a future channel adapter that pre-tags inbound text).
|
||||
assertEquals("[@Alice] hello",
|
||||
ChannelMessageRouter.applyGroupTag(m, "[@Alice] hello"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("applyGroupTag: empty content stays empty (no bare-tag artifact)")
|
||||
void applyTagEmptyStaysEmpty() {
|
||||
ChannelMessage m = groupMessage("alice-id", "Alice", "");
|
||||
// A truly empty message (no text, no parts producing text) shouldn't
|
||||
// surface as a useless "[@Alice]" turn — the agent has nothing to
|
||||
// act on. Skip the tag to keep persisted history clean.
|
||||
assertEquals("", ChannelMessageRouter.applyGroupTag(m, ""));
|
||||
assertNull(ChannelMessageRouter.applyGroupTag(m, null));
|
||||
}
|
||||
|
||||
// ===== isSameSender (the merge boundary helper) =====
|
||||
|
||||
@Test
|
||||
@DisplayName("isSameSender: same sender → merge allowed (paste-split / rapid follow-up)")
|
||||
void sameSenderMergeAllowed() {
|
||||
assertTrue(ChannelMessageRouter.isSameSender("alice", "alice"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("isSameSender: different sender → no merge (group sender boundary)")
|
||||
void differentSenderNoMerge() {
|
||||
// The whole point of the group fix: A's pending must NOT absorb B's
|
||||
// text, otherwise the merged buffer attributes both to A.
|
||||
assertFalse(ChannelMessageRouter.isSameSender("alice", "bob"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("isSameSender: null on either side → no merge (defensive)")
|
||||
void nullSendersNoMerge() {
|
||||
// Pending fixtures occasionally have null senderIds; better to start
|
||||
// a fresh pending than to silently merge into an unidentified buffer.
|
||||
assertFalse(ChannelMessageRouter.isSameSender(null, "alice"));
|
||||
assertFalse(ChannelMessageRouter.isSameSender("alice", null));
|
||||
assertFalse(ChannelMessageRouter.isSameSender(null, null));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,212 @@
|
||||
package vip.mate.channel;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
/**
|
||||
* RFC-03 Lane K3 — covers {@link MediaPathGuard} validation rules.
|
||||
*
|
||||
* <p>Each test reads as a property of "what every channel adapter must
|
||||
* agree on": no path traversal, no implicit directory writes, no
|
||||
* surprise extensions, no DoS via giant files. Failures bind to the
|
||||
* stable {@link MediaPathGuard.Reason} codes so audit / metrics
|
||||
* downstream can group violations without parsing message text.
|
||||
*/
|
||||
class MediaPathGuardTest {
|
||||
|
||||
private static MediaPathGuard.Policy policy(Path workspace) {
|
||||
return new MediaPathGuard.Policy(
|
||||
workspace,
|
||||
Set.of("png", "jpg", "pdf", "txt"),
|
||||
10 * 1024 * 1024 // 10 MiB
|
||||
);
|
||||
}
|
||||
|
||||
// ── Containment ───────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
@DisplayName("file under workspace root → returns canonical path")
|
||||
void fileInWorkspaceAccepted(@TempDir Path workspace) throws Exception {
|
||||
Path file = workspace.resolve("hello.txt");
|
||||
Files.writeString(file, "hi");
|
||||
|
||||
Path resolved = MediaPathGuard.validate(file, policy(workspace));
|
||||
|
||||
assertNotNull(resolved);
|
||||
assertEquals(workspace.toRealPath(), resolved.getParent());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("file outside workspace via ../ → PATH_OUTSIDE_WORKSPACE")
|
||||
void traversalRejected(@TempDir Path workspace, @TempDir Path elsewhere) throws Exception {
|
||||
Path victim = elsewhere.resolve("secret.txt");
|
||||
Files.writeString(victim, "top secret");
|
||||
|
||||
var ex = assertThrows(MediaPathGuard.MediaValidationException.class,
|
||||
() -> MediaPathGuard.validate(victim, policy(workspace)));
|
||||
assertEquals(MediaPathGuard.Reason.PATH_OUTSIDE_WORKSPACE, ex.reason());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("workspace prefix-name overlap not exploitable — /ws-foo doesn't match /ws-foobar")
|
||||
void prefixOverlapIsNotContainment(@TempDir Path tmp) throws Exception {
|
||||
// /tmp/ws/ ← workspace
|
||||
// /tmp/wsbig/file.txt ← attacker file with a similar prefix
|
||||
Path workspace = tmp.resolve("ws");
|
||||
Path neighbor = tmp.resolve("wsbig");
|
||||
Files.createDirectory(workspace);
|
||||
Files.createDirectory(neighbor);
|
||||
Path attackerFile = neighbor.resolve("file.txt");
|
||||
Files.writeString(attackerFile, "x");
|
||||
|
||||
var ex = assertThrows(MediaPathGuard.MediaValidationException.class,
|
||||
() -> MediaPathGuard.validate(attackerFile, policy(workspace)));
|
||||
assertEquals(MediaPathGuard.Reason.PATH_OUTSIDE_WORKSPACE, ex.reason(),
|
||||
"Path.startsWith must compare elements, not strings — otherwise wsbig looks like a child of ws");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("missing file → FILE_MISSING (not opaque IO_ERROR)")
|
||||
void missingFile(@TempDir Path workspace) {
|
||||
Path nope = workspace.resolve("does-not-exist.txt");
|
||||
|
||||
var ex = assertThrows(MediaPathGuard.MediaValidationException.class,
|
||||
() -> MediaPathGuard.validate(nope, policy(workspace)));
|
||||
assertEquals(MediaPathGuard.Reason.FILE_MISSING, ex.reason(),
|
||||
"missing files have a dedicated reason so audit output isn't misleading");
|
||||
}
|
||||
|
||||
// ── Type ──────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
@DisplayName("directory passed in place of file → NOT_A_REGULAR_FILE")
|
||||
void directoryRejected(@TempDir Path workspace) throws Exception {
|
||||
Path subdir = workspace.resolve("subdir");
|
||||
Files.createDirectory(subdir);
|
||||
|
||||
var ex = assertThrows(MediaPathGuard.MediaValidationException.class,
|
||||
() -> MediaPathGuard.validate(subdir, policy(workspace)));
|
||||
assertEquals(MediaPathGuard.Reason.NOT_A_REGULAR_FILE, ex.reason());
|
||||
}
|
||||
|
||||
// ── Extension ─────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
@DisplayName("extension allowlist case-insensitive")
|
||||
void extensionCaseInsensitive(@TempDir Path workspace) throws Exception {
|
||||
Path uppercaseExt = workspace.resolve("HelloWorld.PNG");
|
||||
Files.write(uppercaseExt, new byte[]{0x1a});
|
||||
|
||||
// Should pass — policy allows "png" lowercase, file has "PNG".
|
||||
Path ok = MediaPathGuard.validate(uppercaseExt, policy(workspace));
|
||||
assertNotNull(ok);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("policy allowlist normalizes leading-dot extensions")
|
||||
void allowlistAcceptsDotPrefix(@TempDir Path workspace) throws Exception {
|
||||
Path file = workspace.resolve("a.txt");
|
||||
Files.writeString(file, "x");
|
||||
|
||||
// Same policy, but the dev specified ".txt" instead of "txt" — both work.
|
||||
var p = new MediaPathGuard.Policy(workspace, Set.of(".txt"), 1024L);
|
||||
assertNotNull(MediaPathGuard.validate(file, p));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("extension not in allowlist → EXTENSION_NOT_ALLOWED")
|
||||
void extensionNotAllowed(@TempDir Path workspace) throws Exception {
|
||||
Path file = workspace.resolve("malware.exe");
|
||||
Files.write(file, new byte[]{0x4d, 0x5a});
|
||||
|
||||
var ex = assertThrows(MediaPathGuard.MediaValidationException.class,
|
||||
() -> MediaPathGuard.validate(file, policy(workspace)));
|
||||
assertEquals(MediaPathGuard.Reason.EXTENSION_NOT_ALLOWED, ex.reason());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("file with no extension → EXTENSION_NOT_ALLOWED (defensive)")
|
||||
void noExtension(@TempDir Path workspace) throws Exception {
|
||||
Path file = workspace.resolve("README");
|
||||
Files.writeString(file, "doc");
|
||||
|
||||
var ex = assertThrows(MediaPathGuard.MediaValidationException.class,
|
||||
() -> MediaPathGuard.validate(file, policy(workspace)));
|
||||
assertEquals(MediaPathGuard.Reason.EXTENSION_NOT_ALLOWED, ex.reason());
|
||||
}
|
||||
|
||||
// ── Size ──────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
@DisplayName("file at policy size cap is accepted (boundary)")
|
||||
void atCapAccepted(@TempDir Path workspace) throws Exception {
|
||||
Path file = workspace.resolve("at-cap.txt");
|
||||
Files.write(file, new byte[100]);
|
||||
|
||||
var p = new MediaPathGuard.Policy(workspace, Set.of("txt"), 100L);
|
||||
Path ok = MediaPathGuard.validate(file, p);
|
||||
assertNotNull(ok);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("file 1 byte over cap → FILE_TOO_LARGE")
|
||||
void overCapRejected(@TempDir Path workspace) throws Exception {
|
||||
Path file = workspace.resolve("big.txt");
|
||||
Files.write(file, new byte[101]);
|
||||
|
||||
var p = new MediaPathGuard.Policy(workspace, Set.of("txt"), 100L);
|
||||
var ex = assertThrows(MediaPathGuard.MediaValidationException.class,
|
||||
() -> MediaPathGuard.validate(file, p));
|
||||
assertEquals(MediaPathGuard.Reason.FILE_TOO_LARGE, ex.reason());
|
||||
}
|
||||
|
||||
// ── Returned canonical path ───────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
@DisplayName("returned path is canonical (toRealPath) — TOCTOU-safe for downstream callers")
|
||||
void canonicalPathReturned(@TempDir Path workspace) throws Exception {
|
||||
// Use a relative path that resolves to a real file via . segment —
|
||||
// the returned value must drop the redundant segment.
|
||||
Path realFile = workspace.resolve("real.png");
|
||||
Files.write(realFile, new byte[]{1});
|
||||
Path withDotSegment = workspace.resolve(".").resolve("real.png");
|
||||
|
||||
Path canonical = MediaPathGuard.validate(withDotSegment, policy(workspace));
|
||||
|
||||
assertEquals(realFile.toRealPath(), canonical);
|
||||
assertNotEquals(withDotSegment, canonical,
|
||||
"validate must return the canonical form, not echo the user-supplied path verbatim");
|
||||
}
|
||||
|
||||
// ── Policy guards ─────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
@DisplayName("non-positive maxBytes → IllegalArgumentException at policy construction")
|
||||
void zeroMaxBytesRejected(@TempDir Path workspace) {
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> new MediaPathGuard.Policy(workspace, Set.of("txt"), 0L));
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> new MediaPathGuard.Policy(workspace, Set.of("txt"), -100L));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("extensionOf — public-internals helper coverage")
|
||||
void extensionOfHelper() {
|
||||
assertEquals("png", MediaPathGuard.extensionOf(Path.of("a.png")));
|
||||
assertEquals("png", MediaPathGuard.extensionOf(Path.of("PATH/a.PNG")));
|
||||
assertEquals("", MediaPathGuard.extensionOf(Path.of("README")));
|
||||
assertEquals("", MediaPathGuard.extensionOf(Path.of("trailing.")));
|
||||
assertEquals("gz", MediaPathGuard.extensionOf(Path.of("archive.tar.gz")),
|
||||
"double-dot filenames should report the rightmost segment");
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,100 @@
|
||||
package vip.mate.channel.feishu;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class FeishuTextSplitTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("short content returns single-element list unchanged")
|
||||
void shortContent() {
|
||||
List<String> chunks = FeishuChannelAdapter.splitTextForFeishu("hello world", 100);
|
||||
assertEquals(List.of("hello world"), chunks);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("null/empty returns empty list")
|
||||
void nullEmpty() {
|
||||
assertTrue(FeishuChannelAdapter.splitTextForFeishu(null, 100).isEmpty());
|
||||
assertTrue(FeishuChannelAdapter.splitTextForFeishu("", 100).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("split prefers paragraph (\\n\\n) boundary over hard cut")
|
||||
void prefersParagraphBoundary() {
|
||||
String content = "first paragraph here\n\nsecond paragraph here";
|
||||
// maxChars chosen so the paragraph boundary lands in the second half
|
||||
List<String> chunks = FeishuChannelAdapter.splitTextForFeishu(content, 30);
|
||||
assertEquals(2, chunks.size());
|
||||
assertEquals("first paragraph here\n\n", chunks.get(0));
|
||||
assertEquals("second paragraph here", chunks.get(1));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("split falls through to line boundary when no paragraph break")
|
||||
void fallsThroughToLine() {
|
||||
String content = "line1 with content\nline2 with content\nline3 with content";
|
||||
List<String> chunks = FeishuChannelAdapter.splitTextForFeishu(content, 25);
|
||||
assertTrue(chunks.size() >= 2);
|
||||
// each chunk should end at \n boundary or be the final chunk
|
||||
for (int i = 0; i < chunks.size() - 1; i++) {
|
||||
assertTrue(chunks.get(i).endsWith("\n"),
|
||||
"Non-final chunk should end at line boundary: '" + chunks.get(i) + "'");
|
||||
}
|
||||
assertEquals(content, String.join("", chunks),
|
||||
"Concatenation must reconstruct the original");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("oversized single line falls through to whitespace boundary")
|
||||
void fallsThroughToWhitespace() {
|
||||
String content = "word ".repeat(200); // 1000 chars, no \n
|
||||
List<String> chunks = FeishuChannelAdapter.splitTextForFeishu(content, 100);
|
||||
assertTrue(chunks.size() >= 10);
|
||||
for (String chunk : chunks) {
|
||||
assertTrue(chunk.length() <= 100,
|
||||
"Each chunk must be within limit, got " + chunk.length());
|
||||
}
|
||||
assertEquals(content, String.join("", chunks));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("zero-boundary content (no spaces, no newlines) hard-cuts")
|
||||
void hardCutWhenNoBoundary() {
|
||||
String content = "x".repeat(1000);
|
||||
List<String> chunks = FeishuChannelAdapter.splitTextForFeishu(content, 250);
|
||||
assertEquals(4, chunks.size());
|
||||
for (String chunk : chunks) {
|
||||
assertEquals(250, chunk.length());
|
||||
}
|
||||
assertEquals(content, String.join("", chunks));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("default 4000-char limit holds for very long markdown answer")
|
||||
void realisticLongAnswer() {
|
||||
// Simulate a 12K-char LLM answer with paragraph breaks every ~400 chars
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (int i = 0; i < 30; i++) {
|
||||
sb.append("段落 ").append(i).append(":")
|
||||
.append("这里是一些内容,模拟一个真实的长回答。".repeat(10))
|
||||
.append("\n\n");
|
||||
}
|
||||
String content = sb.toString();
|
||||
|
||||
List<String> chunks = FeishuChannelAdapter.splitTextForFeishu(
|
||||
content, FeishuChannelAdapter.MAX_TEXT_MESSAGE_CHARS);
|
||||
assertTrue(chunks.size() >= 2,
|
||||
"12K-char answer should split into multiple chunks, got " + chunks.size());
|
||||
for (String chunk : chunks) {
|
||||
assertTrue(chunk.length() <= FeishuChannelAdapter.MAX_TEXT_MESSAGE_CHARS,
|
||||
"Chunk exceeds limit: " + chunk.length());
|
||||
}
|
||||
assertEquals(content, String.join("", chunks),
|
||||
"Reconstruction lossless");
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,124 @@
|
||||
package vip.mate.channel.leader;
|
||||
|
||||
import net.javacrumbs.shedlock.core.LockConfiguration;
|
||||
import net.javacrumbs.shedlock.core.LockProvider;
|
||||
import net.javacrumbs.shedlock.core.SimpleLock;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class ChannelLeaderElectionTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("tryAcquire returns empty when LockProvider rejects (another node owns the lock)")
|
||||
void tryAcquireWhenLockHeldElsewhere() {
|
||||
LockProvider provider = mock(LockProvider.class);
|
||||
when(provider.lock(any(LockConfiguration.class))).thenReturn(Optional.empty());
|
||||
|
||||
ChannelLeaderElection election = new ChannelLeaderElection(provider);
|
||||
Optional<LeaderLease> lease = election.tryAcquire("feishu:42");
|
||||
|
||||
assertTrue(lease.isEmpty(), "Expected empty optional when lock is held elsewhere");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("tryAcquire returns a lease when LockProvider grants the lock")
|
||||
void tryAcquireWhenLockGranted() {
|
||||
LockProvider provider = mock(LockProvider.class);
|
||||
SimpleLock simpleLock = mock(SimpleLock.class);
|
||||
when(provider.lock(any(LockConfiguration.class))).thenReturn(Optional.of(simpleLock));
|
||||
|
||||
ChannelLeaderElection election = new ChannelLeaderElection(provider);
|
||||
Optional<LeaderLease> lease = election.tryAcquire("qq:7");
|
||||
|
||||
assertTrue(lease.isPresent());
|
||||
assertEquals("channel-leader:qq:7", lease.get().getName());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Lock name is prefixed so leases don't collide with other ShedLock users (e.g. cron)")
|
||||
void lockNameIsPrefixed() {
|
||||
LockProvider provider = mock(LockProvider.class);
|
||||
SimpleLock simpleLock = mock(SimpleLock.class);
|
||||
when(provider.lock(any(LockConfiguration.class))).thenReturn(Optional.of(simpleLock));
|
||||
|
||||
ChannelLeaderElection election = new ChannelLeaderElection(provider);
|
||||
election.tryAcquire("feishu:42");
|
||||
|
||||
ArgumentCaptor<LockConfiguration> captor = ArgumentCaptor.forClass(LockConfiguration.class);
|
||||
verify(provider).lock(captor.capture());
|
||||
assertEquals("channel-leader:feishu:42", captor.getValue().getName());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Lease extend() reports success when ShedLock returns a new SimpleLock")
|
||||
void leaseExtendSuccess() {
|
||||
SimpleLock current = mock(SimpleLock.class);
|
||||
SimpleLock next = mock(SimpleLock.class);
|
||||
when(current.extend(any(Duration.class), any(Duration.class))).thenReturn(Optional.of(next));
|
||||
|
||||
LeaderLease lease = new LeaderLease("test", current);
|
||||
assertTrue(lease.extend(Duration.ofSeconds(60)));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Lease extend() reports failure when ShedLock returns empty (lock lost)")
|
||||
void leaseExtendLost() {
|
||||
SimpleLock current = mock(SimpleLock.class);
|
||||
when(current.extend(any(Duration.class), any(Duration.class))).thenReturn(Optional.empty());
|
||||
|
||||
LeaderLease lease = new LeaderLease("test", current);
|
||||
assertFalse(lease.extend(Duration.ofSeconds(60)));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Lease extend() swallows exceptions and reports failure — heartbeats must not crash the scheduler")
|
||||
void leaseExtendCatchesException() {
|
||||
SimpleLock current = mock(SimpleLock.class);
|
||||
when(current.extend(any(Duration.class), any(Duration.class)))
|
||||
.thenThrow(new RuntimeException("db connection lost"));
|
||||
|
||||
LeaderLease lease = new LeaderLease("test", current);
|
||||
assertFalse(lease.extend(Duration.ofSeconds(60)));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Lease release() unlocks the underlying SimpleLock exactly once even if called twice")
|
||||
void leaseReleaseIdempotent() {
|
||||
SimpleLock simpleLock = mock(SimpleLock.class);
|
||||
LeaderLease lease = new LeaderLease("test", simpleLock);
|
||||
|
||||
lease.release();
|
||||
lease.release();
|
||||
|
||||
verify(simpleLock, times(1)).unlock();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Lease release() swallows unlock exceptions so shutdown can't be blocked by them")
|
||||
void leaseReleaseCatchesException() {
|
||||
SimpleLock simpleLock = mock(SimpleLock.class);
|
||||
doThrow(new RuntimeException("db gone")).when(simpleLock).unlock();
|
||||
|
||||
LeaderLease lease = new LeaderLease("test", simpleLock);
|
||||
assertDoesNotThrow(lease::release);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Once released, extend() always returns false without touching the underlying lock")
|
||||
void extendAfterReleaseIsFalse() {
|
||||
SimpleLock simpleLock = mock(SimpleLock.class);
|
||||
LeaderLease lease = new LeaderLease("test", simpleLock);
|
||||
|
||||
lease.release();
|
||||
assertFalse(lease.extend(Duration.ofSeconds(60)));
|
||||
verify(simpleLock, never()).extend(any(Duration.class), any(Duration.class));
|
||||
}
|
||||
}
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,91 @@
|
||||
package vip.mate.channel.web;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import vip.mate.workspace.conversation.model.MessageEntity;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Truth-table for {@link ChatController#derivePersistStatus} (RFC-067 §4.6).
|
||||
* <p>
|
||||
* The pre-RFC controller had three different inline derivations across normal /
|
||||
* queued / replay {@code doOnComplete}; queued and replay hardcoded
|
||||
* {@code completed} which caused {@code awaiting_approval} turns to be silently
|
||||
* downgraded — the frontend would then call {@code expirePendingApprovals} and
|
||||
* ghost-clear the banner. These tests pin the unified five-way truth table so a
|
||||
* future refactor can't reintroduce the divergence.
|
||||
*/
|
||||
class ChatControllerPersistStatusTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("awaiting_approval wins over every other condition (top of priority)")
|
||||
void awaitingApprovalTakesPrecedence() {
|
||||
// Even when stop+error fired AFTER the approval gate, persistence must
|
||||
// still surface awaiting_approval — the turn isn't truly finished and
|
||||
// the frontend's done handler skips expire on this status.
|
||||
assertThat(ChatController.derivePersistStatus(true, true, true,
|
||||
ChatStreamTracker.InterruptType.USER_INTERRUPT_WITH_FOLLOWUP))
|
||||
.isEqualTo("awaiting_approval");
|
||||
assertThat(ChatController.derivePersistStatus(true, false, false, null))
|
||||
.isEqualTo("awaiting_approval");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("error beats every non-approval state")
|
||||
void errorOnTypedErrorPrefix() {
|
||||
assertThat(ChatController.derivePersistStatus(false, true, false, null))
|
||||
.isEqualTo("error");
|
||||
// Stop coexists with error → still error (BaseAgent sanitization needs to
|
||||
// skip these regardless of whether the user pressed Stop afterward).
|
||||
assertThat(ChatController.derivePersistStatus(false, true, true,
|
||||
ChatStreamTracker.InterruptType.USER_STOP))
|
||||
.isEqualTo("error");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("clean finish → completed")
|
||||
void completedOnCleanFinish() {
|
||||
assertThat(ChatController.derivePersistStatus(false, false, false, null))
|
||||
.isEqualTo("completed");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("user-stop without follow-up → stopped")
|
||||
void stoppedOnPlainStop() {
|
||||
assertThat(ChatController.derivePersistStatus(false, false, true,
|
||||
ChatStreamTracker.InterruptType.USER_STOP))
|
||||
.isEqualTo("stopped");
|
||||
// Null InterruptType (defensive) also collapses to stopped — mirrors
|
||||
// historical behavior where wasStopped+null was the common shape on
|
||||
// doOnCancel paths before the typed enum landed.
|
||||
assertThat(ChatController.derivePersistStatus(false, false, true, null))
|
||||
.isEqualTo("stopped");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("user interrupt-with-followup → interrupted (queued message takes over)")
|
||||
void interruptedOnFollowupQueue() {
|
||||
assertThat(ChatController.derivePersistStatus(false, false, true,
|
||||
ChatStreamTracker.InterruptType.USER_INTERRUPT_WITH_FOLLOWUP))
|
||||
.isEqualTo("interrupted");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("empty completed turns persist an explicit placeholder")
|
||||
void emptyCompletedTurnUsesPlaceholder() {
|
||||
assertThat(ChatController.emptyAssistantPlaceholder("completed"))
|
||||
.isEqualTo("[本次没有输出]");
|
||||
assertThat(ChatController.emptyAssistantPlaceholder("awaiting_approval"))
|
||||
.isEqualTo("[等待审批]");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("done.persisted reflects whether an assistant row was actually saved")
|
||||
void donePersistedFollowsSavedAssistant() {
|
||||
MessageEntity saved = new MessageEntity();
|
||||
|
||||
assertThat(ChatController.isAssistantPersisted(saved)).isTrue();
|
||||
assertThat(ChatController.isAssistantPersisted(null)).isFalse();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,146 @@
|
||||
package vip.mate.channel.web;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* Tests for {@link ChatStreamTracker#addBatchedEventRelay}: size-driven flush,
|
||||
* time-driven flush, and pass-through ordering for non-batched events.
|
||||
*/
|
||||
class ChatStreamTrackerBatchedRelayTest {
|
||||
|
||||
private ChatStreamTracker newTracker() {
|
||||
return new ChatStreamTracker(new ObjectMapper());
|
||||
}
|
||||
|
||||
private record Captured(String name, String json) {}
|
||||
|
||||
/**
|
||||
* Spin until {@code condition} is true or {@code timeoutMs} elapses.
|
||||
* Polling instead of {@code Awaitility} to keep the test classpath
|
||||
* dependency-free (the project doesn't bundle Awaitility).
|
||||
*/
|
||||
private static boolean waitUntil(java.util.function.BooleanSupplier condition, long timeoutMs) {
|
||||
long deadline = System.currentTimeMillis() + timeoutMs;
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
if (condition.getAsBoolean()) return true;
|
||||
try {
|
||||
Thread.sleep(20);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return condition.getAsBoolean();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Buffer flushes when batch size threshold is hit")
|
||||
void flushAtBatchSize() {
|
||||
ChatStreamTracker tracker = newTracker();
|
||||
String src = "src-batch-size";
|
||||
tracker.register(src);
|
||||
List<Captured> captured = new CopyOnWriteArrayList<>();
|
||||
|
||||
Runnable deregister = tracker.addBatchedEventRelay(src, "parent",
|
||||
3, 5_000L, // batch=3, flushMs large so the timer never fires
|
||||
(name, json) -> captured.add(new Captured(name, json)));
|
||||
try {
|
||||
tracker.broadcast(src, "tool_call_started", "{\"name\":\"a\"}");
|
||||
tracker.broadcast(src, "tool_call_completed", "{\"name\":\"a\",\"ok\":true}");
|
||||
assertTrue(captured.isEmpty(), "Below batch threshold — no flush yet");
|
||||
|
||||
tracker.broadcast(src, "tool_call_started", "{\"name\":\"b\"}");
|
||||
// 3rd buffered event triggers immediate flush.
|
||||
assertTrue(waitUntil(() -> !captured.isEmpty(), 1_000),
|
||||
"Flush should happen at batch threshold");
|
||||
List<Captured> snapshot = new ArrayList<>(captured);
|
||||
assertEquals(1, snapshot.size(),
|
||||
"Single delegation_batch envelope expected at the size threshold");
|
||||
assertEquals("delegation_batch", snapshot.get(0).name());
|
||||
assertTrue(snapshot.get(0).json().contains("delegation_batch"));
|
||||
} finally {
|
||||
deregister.run();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Buffer flushes at the elapsed-time boundary")
|
||||
void flushOnTimer() {
|
||||
ChatStreamTracker tracker = newTracker();
|
||||
String src = "src-batch-time";
|
||||
tracker.register(src);
|
||||
List<Captured> captured = new CopyOnWriteArrayList<>();
|
||||
|
||||
Runnable deregister = tracker.addBatchedEventRelay(src, "parent",
|
||||
100, 200L, // huge batch, short timer
|
||||
(name, json) -> captured.add(new Captured(name, json)));
|
||||
try {
|
||||
tracker.broadcast(src, "tool_call_started", "{\"name\":\"a\"}");
|
||||
tracker.broadcast(src, "tool_call_completed", "{\"name\":\"a\",\"ok\":true}");
|
||||
// Wait for the scheduler to fire (200ms + slack).
|
||||
assertTrue(waitUntil(() -> !captured.isEmpty(), 2_000),
|
||||
"Time-driven flush expected within 2s");
|
||||
assertEquals("delegation_batch", captured.get(0).name(),
|
||||
"Time-driven flush must produce a delegation_batch envelope");
|
||||
} finally {
|
||||
deregister.run();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Pass-through events fire immediately and preserve ordering")
|
||||
void passThroughPreservesOrdering() {
|
||||
ChatStreamTracker tracker = newTracker();
|
||||
String src = "src-pass-through";
|
||||
tracker.register(src);
|
||||
List<Captured> captured = new CopyOnWriteArrayList<>();
|
||||
|
||||
Runnable deregister = tracker.addBatchedEventRelay(src, "parent",
|
||||
100, 5_000L, // size and time thresholds both far away
|
||||
(name, json) -> captured.add(new Captured(name, json)));
|
||||
try {
|
||||
// Two batchable events buffer up.
|
||||
tracker.broadcast(src, "tool_call_started", "{\"name\":\"a\"}");
|
||||
tracker.broadcast(src, "tool_call_completed", "{\"name\":\"a\",\"ok\":true}");
|
||||
// Pass-through event: must flush prior buffer, then fire itself.
|
||||
tracker.broadcast(src, "phase", "{\"phase\":\"reasoning\"}");
|
||||
|
||||
assertTrue(waitUntil(() -> captured.size() >= 2, 2_000));
|
||||
// Order: delegation_batch (drained buffer) then phase.
|
||||
assertEquals("delegation_batch", captured.get(0).name(),
|
||||
"Pass-through must drain buffered events first");
|
||||
assertEquals("phase", captured.get(1).name(),
|
||||
"Pass-through event must follow the flushed batch");
|
||||
} finally {
|
||||
deregister.run();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Deregister flushes any pending events before unsubscribing")
|
||||
void deregisterFlushesPending() {
|
||||
ChatStreamTracker tracker = newTracker();
|
||||
String src = "src-shutdown";
|
||||
tracker.register(src);
|
||||
AtomicInteger sawBatch = new AtomicInteger(0);
|
||||
Runnable deregister = tracker.addBatchedEventRelay(src, "parent",
|
||||
100, 60_000L,
|
||||
(name, json) -> {
|
||||
if ("delegation_batch".equals(name)) sawBatch.incrementAndGet();
|
||||
});
|
||||
tracker.broadcast(src, "tool_call_started", "{}");
|
||||
tracker.broadcast(src, "tool_call_completed", "{}");
|
||||
deregister.run();
|
||||
assertEquals(1, sawBatch.get(),
|
||||
"Deregistration must drain pending events as one final batch");
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,185 @@
|
||||
package vip.mate.channel.web;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyEmitter;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* Verifies that {@link ChatStreamTracker#broadcastChunked} splits oversize
|
||||
* payloads into ordered {@code tool_result_chunk} events with the documented
|
||||
* envelope shape, and leaves small payloads intact.
|
||||
*/
|
||||
class ChatStreamTrackerChunkedBroadcastTest {
|
||||
|
||||
private ChatStreamTracker newTracker() {
|
||||
return new ChatStreamTracker(new ObjectMapper());
|
||||
}
|
||||
|
||||
/**
|
||||
* Captures (eventName, jsonData) tuples by intercepting Spring's
|
||||
* {@code send(SseEventBuilder)} path. The builder emits multiple
|
||||
* "event:..." / "data:..." entries when rendered to a Set, so we walk
|
||||
* the rendered set once and collect a single logical pair.
|
||||
*/
|
||||
private static final class CapturingEmitter extends SseEmitter {
|
||||
final List<Map<String, String>> events = new CopyOnWriteArrayList<>();
|
||||
|
||||
CapturingEmitter() {
|
||||
super(60_000L);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void send(SseEventBuilder builder) throws IOException {
|
||||
Set<ResponseBodyEmitter.DataWithMediaType> entries = builder.build();
|
||||
// Spring renders the SSE event as:
|
||||
// 1) header string: "event:<name>\ndata:" (note: data: prefix
|
||||
// already attached, no payload yet)
|
||||
// 2) the actual data object (here always a JSON string)
|
||||
// 3) terminator string: "\n\n"
|
||||
// We detect the header from prefix scanning, then take the next
|
||||
// String entry as the payload. Anything else is ignored.
|
||||
String name = null;
|
||||
String payload = null;
|
||||
boolean expectPayload = false;
|
||||
for (ResponseBodyEmitter.DataWithMediaType d : entries) {
|
||||
Object obj = d.getData();
|
||||
if (!(obj instanceof String text)) continue;
|
||||
if (text.contains("event:") && text.contains("data:")) {
|
||||
int evStart = text.indexOf("event:") + "event:".length();
|
||||
int evEnd = text.indexOf('\n', evStart);
|
||||
if (evEnd < 0) evEnd = text.length();
|
||||
name = text.substring(evStart, evEnd).trim();
|
||||
expectPayload = true;
|
||||
} else if (expectPayload && payload == null && !text.equals("\n\n")) {
|
||||
payload = text;
|
||||
expectPayload = false;
|
||||
}
|
||||
}
|
||||
Map<String, String> entry = new LinkedHashMap<>();
|
||||
entry.put("event", name != null ? name : "");
|
||||
entry.put("data", payload != null ? payload : "");
|
||||
events.add(entry);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Small payload broadcasts as a single event unchanged")
|
||||
void smallPayloadBroadcastsUnchanged() {
|
||||
ChatStreamTracker tracker = newTracker();
|
||||
String cid = "small-payload";
|
||||
tracker.register(cid);
|
||||
CapturingEmitter emitter = new CapturingEmitter();
|
||||
tracker.attach(cid, emitter);
|
||||
|
||||
Map<String, Object> payload = new LinkedHashMap<>();
|
||||
payload.put("toolCallId", "call-1");
|
||||
payload.put("toolName", "echo");
|
||||
payload.put("result", "small text");
|
||||
payload.put("success", true);
|
||||
|
||||
tracker.broadcastChunked(cid, "tool_call_completed", payload, "call-1");
|
||||
|
||||
long completedCount = emitter.events.stream()
|
||||
.filter(e -> "tool_call_completed".equals(e.get("event"))).count();
|
||||
long chunkCount = emitter.events.stream()
|
||||
.filter(e -> "tool_result_chunk".equals(e.get("event"))).count();
|
||||
assertEquals(1, completedCount, "Expected single tool_call_completed event");
|
||||
assertEquals(0, chunkCount, "No chunk events for small payload");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Large payload splits into ordered tool_result_chunk events with final flag")
|
||||
void largePayloadChunksAndTerminates() throws Exception {
|
||||
ChatStreamTracker tracker = newTracker();
|
||||
String cid = "large-payload";
|
||||
tracker.register(cid);
|
||||
CapturingEmitter emitter = new CapturingEmitter();
|
||||
tracker.attach(cid, emitter);
|
||||
|
||||
// Build a result well above CHUNK_SIZE (8192 bytes) so the splitter
|
||||
// produces multiple chunks. 30 KiB ensures at least 4 splits even
|
||||
// after envelope overhead.
|
||||
StringBuilder big = new StringBuilder(30_000);
|
||||
for (int i = 0; i < 3000; i++) {
|
||||
big.append("0123456789");
|
||||
}
|
||||
Map<String, Object> payload = new LinkedHashMap<>();
|
||||
payload.put("toolCallId", "call-large");
|
||||
payload.put("toolName", "shell");
|
||||
payload.put("result", big.toString());
|
||||
payload.put("success", true);
|
||||
|
||||
tracker.broadcastChunked(cid, "tool_call_completed", payload, "call-large");
|
||||
|
||||
// 1. Header event preserved with empty result + chunked=true.
|
||||
List<Map<String, String>> completed = new ArrayList<>();
|
||||
List<Map<String, String>> chunks = new ArrayList<>();
|
||||
for (Map<String, String> e : emitter.events) {
|
||||
if ("tool_call_completed".equals(e.get("event"))) completed.add(e);
|
||||
else if ("tool_result_chunk".equals(e.get("event"))) chunks.add(e);
|
||||
}
|
||||
assertEquals(1, completed.size(), "Header event should fire exactly once");
|
||||
assertTrue(chunks.size() >= 4,
|
||||
"Expected several chunk events; got " + chunks.size());
|
||||
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
Map<?, ?> header = mapper.readValue(completed.get(0).get("data"), Map.class);
|
||||
assertEquals(Boolean.TRUE, header.get("chunked"));
|
||||
assertEquals("call-large", header.get("chunkRef"));
|
||||
assertEquals("", header.get("result"),
|
||||
"Header must replace long field with empty placeholder");
|
||||
|
||||
// 2. Chunk envelope: kind / scope / ref / seq monotonic / final on last.
|
||||
StringBuilder reconstructed = new StringBuilder();
|
||||
for (int i = 0; i < chunks.size(); i++) {
|
||||
Map<?, ?> chunk = mapper.readValue(chunks.get(i).get("data"), Map.class);
|
||||
assertEquals("tool_result", chunk.get("kind"));
|
||||
assertEquals("parent", chunk.get("scope"));
|
||||
assertEquals("call-large", chunk.get("ref"));
|
||||
assertEquals(i, chunk.get("seq"), "Chunks must be in seq order");
|
||||
boolean isLast = (i == chunks.size() - 1);
|
||||
assertEquals(isLast, chunk.get("final"),
|
||||
"Only the last chunk should set final=true");
|
||||
reconstructed.append((String) chunk.get("delta"));
|
||||
}
|
||||
assertEquals(big.toString(), reconstructed.toString(),
|
||||
"Concatenated chunks must reproduce the original result verbatim");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Disabling chunked transport keeps single-event behavior")
|
||||
void disabledChunkingIsPassThrough() {
|
||||
ChatStreamTracker tracker = newTracker();
|
||||
tracker.setChunkedToolResultsEnabled(false);
|
||||
String cid = "disabled";
|
||||
tracker.register(cid);
|
||||
CapturingEmitter emitter = new CapturingEmitter();
|
||||
tracker.attach(cid, emitter);
|
||||
|
||||
StringBuilder big = new StringBuilder(15_000);
|
||||
for (int i = 0; i < 1500; i++) big.append("0123456789");
|
||||
Map<String, Object> payload = new LinkedHashMap<>();
|
||||
payload.put("toolCallId", "call-x");
|
||||
payload.put("toolName", "shell");
|
||||
payload.put("result", big.toString());
|
||||
payload.put("success", true);
|
||||
|
||||
tracker.broadcastChunked(cid, "tool_call_completed", payload, "call-x");
|
||||
|
||||
long chunkCount = emitter.events.stream()
|
||||
.filter(e -> "tool_result_chunk".equals(e.get("event"))).count();
|
||||
assertEquals(0, chunkCount, "Chunking disabled — must not emit chunk events");
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,109 @@
|
||||
package vip.mate.channel.web;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.server.ServerHttpResponse;
|
||||
import org.springframework.http.server.ServletServerHttpResponse;
|
||||
import org.springframework.mock.web.MockHttpServletResponse;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* RFC-058 PR-1: ensure {@link Utf8SseEmitter} explicitly stamps
|
||||
* {@code Content-Type: text/event-stream;charset=UTF-8} on the response.
|
||||
*
|
||||
* <p>Spring's default {@link org.springframework.web.servlet.mvc.method.annotation.SseEmitter}
|
||||
* leaves the charset off, which on Windows / GBK locale Chrome and through
|
||||
* certain reverse proxies leads to mojibake for Chinese characters.
|
||||
*/
|
||||
class Utf8SseEmitterTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("extendResponse stamps charset=UTF-8 when Content-Type is unset")
|
||||
void stampsUtf8WhenContentTypeUnset() throws Exception {
|
||||
Utf8SseEmitter emitter = new Utf8SseEmitter(10_000L);
|
||||
MockHttpServletResponse servlet = new MockHttpServletResponse();
|
||||
ServerHttpResponse response = new ServletServerHttpResponse(servlet);
|
||||
|
||||
invokeExtendResponse(emitter, response);
|
||||
|
||||
MediaType contentType = response.getHeaders().getContentType();
|
||||
assertNotNull(contentType, "Content-Type must be set");
|
||||
assertEquals("text", contentType.getType());
|
||||
assertEquals("event-stream", contentType.getSubtype());
|
||||
assertEquals(StandardCharsets.UTF_8, contentType.getCharset(),
|
||||
"charset must be explicitly UTF-8 (not null)");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("extendResponse stamps charset=UTF-8 when Content-Type lacks charset")
|
||||
void stampsUtf8WhenContentTypeMissingCharset() throws Exception {
|
||||
Utf8SseEmitter emitter = new Utf8SseEmitter(10_000L);
|
||||
MockHttpServletResponse servlet = new MockHttpServletResponse();
|
||||
ServerHttpResponse response = new ServletServerHttpResponse(servlet);
|
||||
// Simulate Spring default: text/event-stream WITHOUT charset
|
||||
response.getHeaders().setContentType(MediaType.parseMediaType("text/event-stream"));
|
||||
|
||||
invokeExtendResponse(emitter, response);
|
||||
|
||||
MediaType contentType = response.getHeaders().getContentType();
|
||||
assertNotNull(contentType.getCharset(), "charset must be filled in");
|
||||
assertEquals(StandardCharsets.UTF_8, contentType.getCharset());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("extendResponse does NOT override an explicit non-UTF8 charset")
|
||||
void doesNotClobberExplicitCharset() throws Exception {
|
||||
Utf8SseEmitter emitter = new Utf8SseEmitter(10_000L);
|
||||
MockHttpServletResponse servlet = new MockHttpServletResponse();
|
||||
ServerHttpResponse response = new ServletServerHttpResponse(servlet);
|
||||
// Caller explicitly chose ISO-8859-1 — we must respect it
|
||||
MediaType iso = new MediaType("text", "event-stream", StandardCharsets.ISO_8859_1);
|
||||
response.getHeaders().setContentType(iso);
|
||||
|
||||
invokeExtendResponse(emitter, response);
|
||||
|
||||
MediaType contentType = response.getHeaders().getContentType();
|
||||
assertEquals(StandardCharsets.ISO_8859_1, contentType.getCharset(),
|
||||
"Explicit caller-set charset must not be overridden");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Utf8SseEmitter constructor accepts timeout like SseEmitter")
|
||||
void constructorAcceptsTimeout() {
|
||||
Utf8SseEmitter emitter = new Utf8SseEmitter(60_000L);
|
||||
assertEquals(60_000L, emitter.getTimeout());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Default constructor works (no timeout)")
|
||||
void defaultConstructorWorks() {
|
||||
Utf8SseEmitter emitter = new Utf8SseEmitter();
|
||||
assertNull(emitter.getTimeout(), "Default constructor leaves timeout null");
|
||||
}
|
||||
|
||||
/**
|
||||
* {@code extendResponse} is {@code protected} on the framework class.
|
||||
* Reflection is the cleanest way to exercise it without spinning up a
|
||||
* full DispatcherServlet for a one-line behavioural assertion.
|
||||
*/
|
||||
private static void invokeExtendResponse(Utf8SseEmitter emitter, ServerHttpResponse response)
|
||||
throws Exception {
|
||||
Method m = findExtendResponseMethod(emitter.getClass());
|
||||
m.setAccessible(true);
|
||||
m.invoke(emitter, response);
|
||||
}
|
||||
|
||||
private static Method findExtendResponseMethod(Class<?> cls) throws NoSuchMethodException {
|
||||
for (Class<?> c = cls; c != null; c = c.getSuperclass()) {
|
||||
for (Method m : c.getDeclaredMethods()) {
|
||||
if ("extendResponse".equals(m.getName())) return m;
|
||||
}
|
||||
}
|
||||
throw new NoSuchMethodException("extendResponse not found on " + cls);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,205 @@
|
||||
package vip.mate.channel.wecom;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.Mockito;
|
||||
import vip.mate.channel.ChannelMessageRouter;
|
||||
import vip.mate.channel.model.ChannelEntity;
|
||||
import vip.mate.channel.notification.ApprovalNotificationService;
|
||||
import vip.mate.channel.wecom.cards.WeComCardDispatcher;
|
||||
import vip.mate.workspace.conversation.model.MessageContentPart;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* Pin {@code msgtype=appmsg} parsing — covers the four sub-variants
|
||||
* users actually forward to bots in production: PDF / Word / Excel
|
||||
* (file), image cards, miniprograms, and public-account article links.
|
||||
*
|
||||
* <p>Without this branch, every forwarded PDF / article / miniprogram
|
||||
* fell into the inbound switch's default and got silently dropped.
|
||||
* These tests pin (1) the text marker shape so prompts stay stable,
|
||||
* (2) the attached-media routing for file and image variants, and
|
||||
* (3) the link/miniprogram fallbacks so the agent at least knows
|
||||
* something was shared.
|
||||
*/
|
||||
class AppmsgContentTest {
|
||||
|
||||
private WeComChannelAdapter adapter;
|
||||
private Method extract;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
ChannelEntity entity = new ChannelEntity();
|
||||
entity.setId(1L);
|
||||
entity.setChannelType("wecom");
|
||||
entity.setConfigJson("{\"media_download_enabled\": false}");
|
||||
adapter = new WeComChannelAdapter(
|
||||
entity,
|
||||
Mockito.mock(ChannelMessageRouter.class),
|
||||
new ObjectMapper(),
|
||||
Mockito.mock(ApprovalNotificationService.class),
|
||||
Mockito.mock(WeComCardDispatcher.class),
|
||||
Mockito.mock(WeComKeepaliveScheduler.class));
|
||||
extract = WeComChannelAdapter.class.getDeclaredMethod(
|
||||
"extractAppmsgContent",
|
||||
Map.class, String.class, String.class, String.class, String.class);
|
||||
extract.setAccessible(true);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private Object invoke(Map<String, Object> body) throws Exception {
|
||||
return extract.invoke(adapter, body, "msg-1", "alice", "alice", "single");
|
||||
}
|
||||
|
||||
private String text(Object ctx) throws Exception {
|
||||
return (String) ctx.getClass().getMethod("text").invoke(ctx);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private List<MessageContentPart> parts(Object ctx) throws Exception {
|
||||
return (List<MessageContentPart>) ctx.getClass().getMethod("attachedParts").invoke(ctx);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("appmsg.file → file content part + [文件: filename] marker")
|
||||
void fileVariant() throws Exception {
|
||||
Object ctx = invoke(Map.of("appmsg", Map.of(
|
||||
"title", "report.pdf",
|
||||
"file", Map.of(
|
||||
"url", "https://example.com/report.pdf",
|
||||
"aeskey", "k",
|
||||
"filename", "report.pdf"))));
|
||||
assertEquals("[文件: report.pdf]", text(ctx));
|
||||
assertEquals(1, parts(ctx).size());
|
||||
assertEquals("file", parts(ctx).get(0).getType());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("appmsg.image → image content part + [图片: title] marker")
|
||||
void imageVariant() throws Exception {
|
||||
Object ctx = invoke(Map.of("appmsg", Map.of(
|
||||
"title", "周末聚会",
|
||||
"image", Map.of(
|
||||
"url", "https://example.com/photo.jpg",
|
||||
"aeskey", "k"))));
|
||||
assertEquals("[图片: 周末聚会]", text(ctx));
|
||||
assertEquals(1, parts(ctx).size());
|
||||
assertEquals("image", parts(ctx).get(0).getType());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("appmsg.image with no title → bare [图片] marker")
|
||||
void imageVariantNoTitle() throws Exception {
|
||||
Object ctx = invoke(Map.of("appmsg", Map.of(
|
||||
"image", Map.of(
|
||||
"url", "https://example.com/p.jpg",
|
||||
"aeskey", "k"))));
|
||||
assertEquals("[图片]", text(ctx));
|
||||
assertEquals(1, parts(ctx).size());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("appmsg.miniprogram → [小程序: title] marker, no attached media")
|
||||
void miniprogramVariant() throws Exception {
|
||||
Object ctx = invoke(Map.of("appmsg", Map.of(
|
||||
"title", "外卖小程序",
|
||||
"miniprogram", Map.of("title", "美团外卖"))));
|
||||
assertEquals("[小程序: 美团外卖]", text(ctx));
|
||||
assertTrue(parts(ctx).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("appmsg.miniprogram with no inner title falls back to top-level title")
|
||||
void miniprogramTitleFallback() throws Exception {
|
||||
Object ctx = invoke(Map.of("appmsg", Map.of(
|
||||
"title", "顶层标题",
|
||||
"miniprogram", Map.of())));
|
||||
assertEquals("[小程序: 顶层标题]", text(ctx));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("appmsg.url (public-account article) → [链接] + title + desc + url multi-line + paste-body hint")
|
||||
void linkVariant() throws Exception {
|
||||
Object ctx = invoke(Map.of("appmsg", Map.of(
|
||||
"title", "深度好文:AI 的未来",
|
||||
"description", "本文探讨 AI 在企业的落地路径",
|
||||
"url", "https://mp.weixin.qq.com/s/abc123")));
|
||||
String t = text(ctx);
|
||||
assertTrue(t.startsWith("[链接] 深度好文:AI 的未来"),
|
||||
"title should follow [链接] tag; got: " + t);
|
||||
assertTrue(t.contains("本文探讨 AI 在企业的落地路径"),
|
||||
"description must be present; got: " + t);
|
||||
assertTrue(t.contains("https://mp.weixin.qq.com/s/abc123"),
|
||||
"URL must be in the text so agent can reference it; got: " + t);
|
||||
// Public-account body is captcha-gated — agent must be told not to
|
||||
// hallucinate content from the title.
|
||||
assertTrue(t.contains("公众号文章"),
|
||||
"public-account article hint must be appended; got: " + t);
|
||||
assertTrue(t.contains("不要凭标题猜测内容"),
|
||||
"directive against title-only guessing must be present; got: " + t);
|
||||
assertTrue(parts(ctx).isEmpty(), "link variant produces no attached media");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("non-public-account links (regular URLs) do NOT get the paste-body hint")
|
||||
void linkVariantNonWeixinUrlNoHint() throws Exception {
|
||||
// Generic web links don't have the captcha-gate problem — fetching
|
||||
// the body via a tool is straightforward, so adding the hint would
|
||||
// be misleading.
|
||||
Object ctx = invoke(Map.of("appmsg", Map.of(
|
||||
"title", "GitHub README",
|
||||
"url", "https://github.com/example/repo")));
|
||||
String t = text(ctx);
|
||||
assertTrue(t.contains("https://github.com/example/repo"));
|
||||
assertFalse(t.contains("公众号文章"),
|
||||
"non-mp.weixin.qq.com URLs must not trigger the public-account hint; got: " + t);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("link with title only, no description")
|
||||
void linkVariantNoDesc() throws Exception {
|
||||
Object ctx = invoke(Map.of("appmsg", Map.of(
|
||||
"title", "标题",
|
||||
"url", "https://example.com")));
|
||||
String t = text(ctx);
|
||||
assertTrue(t.contains("[链接] 标题"));
|
||||
assertTrue(t.contains("https://example.com"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("unknown appmsg variant with title → [appmsg: title] marker")
|
||||
void unknownVariantWithTitle() throws Exception {
|
||||
Object ctx = invoke(Map.of("appmsg", Map.of(
|
||||
"title", "未知卡片",
|
||||
"weird_field", Map.of())));
|
||||
assertEquals("[appmsg: 未知卡片]", text(ctx));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("totally empty appmsg → bare [appmsg] marker (agent at least knows something arrived)")
|
||||
void emptyAppmsg() throws Exception {
|
||||
Object ctx = invoke(Map.of("appmsg", Map.of()));
|
||||
assertEquals("[appmsg]", text(ctx));
|
||||
assertTrue(parts(ctx).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("file variant uses appmsg.title as filename when file.filename missing")
|
||||
void fileFilenameFallback() throws Exception {
|
||||
Object ctx = invoke(Map.of("appmsg", Map.of(
|
||||
"title", "周报.docx",
|
||||
"file", Map.of(
|
||||
"url", "https://example.com/x",
|
||||
"aeskey", "k"))));
|
||||
// filename comes from title since file.filename is absent
|
||||
assertTrue(text(ctx).contains("周报.docx"),
|
||||
"marker should carry the title as filename; got: " + text(ctx));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,109 @@
|
||||
package vip.mate.channel.wecom;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.Mockito;
|
||||
import vip.mate.channel.ChannelMessageRouter;
|
||||
import vip.mate.channel.model.ChannelEntity;
|
||||
import vip.mate.channel.notification.ApprovalNotificationService;
|
||||
import vip.mate.channel.wecom.cards.WeComCardDispatcher;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* Pin the group-chat reply-slot cache contract. WeCom AI Bot platform
|
||||
* blocks {@code aibot_send_msg} in group chats — proactive pushes (cron
|
||||
* summaries, async-task forwards, image-generation completions) must
|
||||
* ride {@code aibot_respond_msg} bound to a prior frame's reqId.
|
||||
*
|
||||
* <p>Without this cache, any group push silently failed: the test rig
|
||||
* here exercises the cache plumbing directly so future changes to the
|
||||
* cache eviction strategy or the lookup helper don't regress group
|
||||
* delivery semantics.
|
||||
*/
|
||||
class GroupReplyReqIdCacheTest {
|
||||
|
||||
private WeComChannelAdapter adapter;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
ChannelEntity entity = new ChannelEntity();
|
||||
entity.setId(1L);
|
||||
entity.setChannelType("wecom");
|
||||
entity.setConfigJson("{}");
|
||||
adapter = new WeComChannelAdapter(
|
||||
entity,
|
||||
Mockito.mock(ChannelMessageRouter.class),
|
||||
new ObjectMapper(),
|
||||
Mockito.mock(ApprovalNotificationService.class),
|
||||
Mockito.mock(WeComCardDispatcher.class),
|
||||
Mockito.mock(WeComKeepaliveScheduler.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("unknown chatId yields null — single chats fall through to aibot_send_msg")
|
||||
void unknownChatYieldsNull() {
|
||||
assertNull(adapter.pickGroupReplyReqId("never-seen-chat"));
|
||||
assertNull(adapter.pickGroupReplyReqId(""));
|
||||
assertNull(adapter.pickGroupReplyReqId(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("remembered group reqId is returned by the lookup")
|
||||
void rememberAndLookup() throws Exception {
|
||||
Method remember = WeComChannelAdapter.class.getDeclaredMethod(
|
||||
"rememberGroupReplyReqId", String.class, String.class);
|
||||
remember.setAccessible(true);
|
||||
|
||||
remember.invoke(adapter, "group-1", "req-aaa");
|
||||
assertEquals("req-aaa", adapter.pickGroupReplyReqId("group-1"));
|
||||
|
||||
// Most-recent semantics: a newer reqId for the same group overwrites.
|
||||
remember.invoke(adapter, "group-1", "req-bbb");
|
||||
assertEquals("req-bbb", adapter.pickGroupReplyReqId("group-1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("cache stays bounded under flood — no unbounded growth")
|
||||
void cacheBounded() throws Exception {
|
||||
Method remember = WeComChannelAdapter.class.getDeclaredMethod(
|
||||
"rememberGroupReplyReqId", String.class, String.class);
|
||||
remember.setAccessible(true);
|
||||
|
||||
// Exceed the 1000-entry max with 1500 distinct groups.
|
||||
for (int i = 0; i < 1500; i++) {
|
||||
remember.invoke(adapter, "group-" + i, "req-" + i);
|
||||
}
|
||||
|
||||
// Inspect the underlying cache size via reflection.
|
||||
java.lang.reflect.Field f = WeComChannelAdapter.class.getDeclaredField("lastChatReqIds");
|
||||
f.setAccessible(true);
|
||||
@SuppressWarnings("unchecked")
|
||||
ConcurrentHashMap<String, String> map = (ConcurrentHashMap<String, String>) f.get(adapter);
|
||||
assertTrue(map.size() <= 1000,
|
||||
"cache must not grow beyond LAST_CHAT_REQ_IDS_MAX_SIZE; got " + map.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("each group gets independent reqId tracking — no cross-group leakage")
|
||||
void independentPerGroup() throws Exception {
|
||||
Method remember = WeComChannelAdapter.class.getDeclaredMethod(
|
||||
"rememberGroupReplyReqId", String.class, String.class);
|
||||
remember.setAccessible(true);
|
||||
|
||||
remember.invoke(adapter, "group-A", "req-A1");
|
||||
remember.invoke(adapter, "group-B", "req-B1");
|
||||
assertEquals("req-A1", adapter.pickGroupReplyReqId("group-A"));
|
||||
assertEquals("req-B1", adapter.pickGroupReplyReqId("group-B"));
|
||||
|
||||
// Updating one doesn't affect the other.
|
||||
remember.invoke(adapter, "group-A", "req-A2");
|
||||
assertEquals("req-A2", adapter.pickGroupReplyReqId("group-A"));
|
||||
assertEquals("req-B1", adapter.pickGroupReplyReqId("group-B"));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,171 @@
|
||||
package vip.mate.channel.wecom;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.Mockito;
|
||||
import vip.mate.channel.ChannelMessageRouter;
|
||||
import vip.mate.channel.model.ChannelEntity;
|
||||
import vip.mate.channel.notification.ApprovalNotificationService;
|
||||
import vip.mate.channel.wecom.cards.WeComCardDispatcher;
|
||||
import vip.mate.workspace.conversation.model.MessageContentPart;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* Unit tests for {@code WeComChannelAdapter.extractQuoteContext} — the
|
||||
* inbound quote-message parser that converts WeCom's {@code body.quote}
|
||||
* field into a prefix string + attached media parts the agent can read.
|
||||
*
|
||||
* <p>Quoted-message context is the most common reason agent replies "go
|
||||
* off-topic" on IM: the user long-presses a previous bubble, types a
|
||||
* follow-up like "解释一下", and assumes the agent sees both. Without
|
||||
* this parser the agent only saw the new text and silently lost the
|
||||
* referenced content.
|
||||
*
|
||||
* <p>These tests pin (1) the prefix string shape so prompts stay stable
|
||||
* across releases, (2) flattening rules for {@code mixed} quotes, and
|
||||
* (3) the empty-result contract (null when nothing useful to extract)
|
||||
* so the caller can treat null as "no quote context".
|
||||
*/
|
||||
class QuoteContextTest {
|
||||
|
||||
private WeComChannelAdapter adapter;
|
||||
private Method extract;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
ChannelEntity entity = new ChannelEntity();
|
||||
entity.setId(1L);
|
||||
entity.setChannelType("wecom");
|
||||
entity.setConfigJson("{\"media_download_enabled\": false}"); // skip real downloads
|
||||
adapter = new WeComChannelAdapter(
|
||||
entity,
|
||||
Mockito.mock(ChannelMessageRouter.class),
|
||||
new ObjectMapper(),
|
||||
Mockito.mock(ApprovalNotificationService.class),
|
||||
Mockito.mock(WeComCardDispatcher.class),
|
||||
Mockito.mock(WeComKeepaliveScheduler.class));
|
||||
extract = WeComChannelAdapter.class.getDeclaredMethod(
|
||||
"extractQuoteContext",
|
||||
Map.class, String.class, String.class, String.class, String.class);
|
||||
extract.setAccessible(true);
|
||||
}
|
||||
|
||||
private Object invoke(Map<String, Object> body) throws Exception {
|
||||
return extract.invoke(adapter, body, "msg-1", "alice", "alice", "single");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("missing quote field returns null")
|
||||
void noQuote() throws Exception {
|
||||
assertNull(invoke(Map.of()));
|
||||
assertNull(invoke(Map.of("text", Map.of("content", "hi"))));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("blank msgtype returns null (defensive)")
|
||||
void blankQuoteType() throws Exception {
|
||||
assertNull(invoke(Map.of("quote", Map.of("msgtype", ""))));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("text quote produces a [引用消息: ...] prefix and no attached parts")
|
||||
void textQuote() throws Exception {
|
||||
Object ctx = invoke(Map.of("quote", Map.of(
|
||||
"msgtype", "text",
|
||||
"text", Map.of("content", "你好图片是什么意思"))));
|
||||
assertNotNull(ctx);
|
||||
// QuoteContext is a private record — exercise via reflection on accessor methods.
|
||||
String prefix = (String) ctx.getClass().getMethod("prefix").invoke(ctx);
|
||||
@SuppressWarnings("unchecked")
|
||||
List<MessageContentPart> parts = (List<MessageContentPart>)
|
||||
ctx.getClass().getMethod("attachedParts").invoke(ctx);
|
||||
assertEquals("[引用消息: 你好图片是什么意思]\n", prefix);
|
||||
assertTrue(parts.isEmpty(), "text-only quote attaches no media");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("image quote attaches a part and notes [图片] in prefix")
|
||||
void imageQuote() throws Exception {
|
||||
Object ctx = invoke(Map.of("quote", Map.of(
|
||||
"msgtype", "image",
|
||||
"image", Map.of(
|
||||
"url", "https://example.com/x.jpg",
|
||||
"aeskey", "k"))));
|
||||
assertNotNull(ctx);
|
||||
String prefix = (String) ctx.getClass().getMethod("prefix").invoke(ctx);
|
||||
@SuppressWarnings("unchecked")
|
||||
List<MessageContentPart> parts = (List<MessageContentPart>)
|
||||
ctx.getClass().getMethod("attachedParts").invoke(ctx);
|
||||
assertEquals("[引用消息: [图片]]\n", prefix);
|
||||
assertEquals(1, parts.size());
|
||||
assertEquals("image", parts.get(0).getType());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("file quote uses the original filename in the prefix")
|
||||
void fileQuote() throws Exception {
|
||||
Object ctx = invoke(Map.of("quote", Map.of(
|
||||
"msgtype", "file",
|
||||
"file", Map.of(
|
||||
"url", "https://example.com/x.pdf",
|
||||
"filename", "report.pdf"))));
|
||||
String prefix = (String) ctx.getClass().getMethod("prefix").invoke(ctx);
|
||||
@SuppressWarnings("unchecked")
|
||||
List<MessageContentPart> parts = (List<MessageContentPart>)
|
||||
ctx.getClass().getMethod("attachedParts").invoke(ctx);
|
||||
assertEquals("[引用消息: [文件: report.pdf]]\n", prefix);
|
||||
assertEquals(1, parts.size());
|
||||
assertEquals("file", parts.get(0).getType());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("voice quote with ASR text gets surfaced; without ASR shows [语音消息]")
|
||||
void voiceQuote() throws Exception {
|
||||
Object withAsr = invoke(Map.of("quote", Map.of(
|
||||
"msgtype", "voice",
|
||||
"voice", Map.of("content", "明天开会"))));
|
||||
assertEquals("[引用消息: [语音] 明天开会]\n",
|
||||
withAsr.getClass().getMethod("prefix").invoke(withAsr));
|
||||
|
||||
Object empty = invoke(Map.of("quote", Map.of(
|
||||
"msgtype", "voice",
|
||||
"voice", Map.of("content", ""))));
|
||||
assertEquals("[引用消息: [语音消息]]\n",
|
||||
empty.getClass().getMethod("prefix").invoke(empty));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("mixed quote flattens to a space-joined summary and merges attached parts")
|
||||
void mixedQuote() throws Exception {
|
||||
Object ctx = invoke(Map.of("quote", Map.of(
|
||||
"msgtype", "mixed",
|
||||
"mixed", Map.of("msg_item", List.of(
|
||||
Map.of("msgtype", "text", "text", Map.of("content", "看这张图")),
|
||||
Map.of("msgtype", "image", "image", Map.of(
|
||||
"url", "https://example.com/y.jpg",
|
||||
"aeskey", "k")))))));
|
||||
String prefix = (String) ctx.getClass().getMethod("prefix").invoke(ctx);
|
||||
@SuppressWarnings("unchecked")
|
||||
List<MessageContentPart> parts = (List<MessageContentPart>)
|
||||
ctx.getClass().getMethod("attachedParts").invoke(ctx);
|
||||
assertEquals("[引用消息: 看这张图 [图片]]\n", prefix);
|
||||
assertEquals(1, parts.size(), "mixed image gets attached as a media part");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("unknown quote sub-type still produces a [<type>] tag (informative, not silent)")
|
||||
void unknownQuoteType() throws Exception {
|
||||
Object ctx = invoke(Map.of("quote", Map.of(
|
||||
"msgtype", "appmsg",
|
||||
"appmsg", Map.of("title", "some link"))));
|
||||
String prefix = (String) ctx.getClass().getMethod("prefix").invoke(ctx);
|
||||
assertEquals("[引用消息: [appmsg]]\n", prefix);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,542 @@
|
||||
package vip.mate.channel.wecom;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.Mockito;
|
||||
import vip.mate.channel.ChannelMessageRouter;
|
||||
import vip.mate.channel.model.ChannelEntity;
|
||||
import vip.mate.channel.notification.ApprovalNotificationService;
|
||||
import vip.mate.channel.wecom.cards.WeComCardDispatcher;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.lang.reflect.Field;
|
||||
import java.net.http.WebSocket;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ConcurrentLinkedQueue;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.LinkedBlockingQueue;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.Function;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* RFC-32 §3.0 PR-0 stress catalog — six tests covering every concurrency
|
||||
* race the v2.0~v2.5.1 review chain identified.
|
||||
*
|
||||
* <p>Each test runs against a {@link TestableAdapter} that overrides
|
||||
* {@code sendFrame} so no real WebSocket is touched. Other state
|
||||
* (running flag, lifecycle gate, pendingAcks map) is poked via
|
||||
* reflection — keeping production-code visibility tweaks to a minimum
|
||||
* (just {@code workerIdleTimeoutMs} and dropping {@code private} from
|
||||
* {@code sendFrame}).
|
||||
*
|
||||
* <p>None of these tests sleep more than ~3s total even at high
|
||||
* iteration counts, so they're safe to run in regular CI rather than
|
||||
* a separate stress-only profile.
|
||||
*/
|
||||
class ReplyQueueStressTest {
|
||||
|
||||
private TestableAdapter adapter;
|
||||
private ObjectMapper mapper;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
ChannelEntity entity = new ChannelEntity();
|
||||
entity.setId(1L);
|
||||
entity.setName("test-wecom");
|
||||
entity.setChannelType("wecom");
|
||||
entity.setConfigJson("{}");
|
||||
ChannelMessageRouter router = Mockito.mock(ChannelMessageRouter.class);
|
||||
ApprovalNotificationService approvalSvc = Mockito.mock(ApprovalNotificationService.class);
|
||||
WeComCardDispatcher cardDispatcher = Mockito.mock(WeComCardDispatcher.class);
|
||||
WeComKeepaliveScheduler keepalive = Mockito.mock(WeComKeepaliveScheduler.class);
|
||||
mapper = new ObjectMapper();
|
||||
adapter = new TestableAdapter(entity, router, mapper, approvalSvc, cardDispatcher, keepalive);
|
||||
// Manually bring the adapter to a "running and ready" state without
|
||||
// doing a real WS handshake. This is what doStart + connectWebSocket +
|
||||
// markReady would have produced on a live system.
|
||||
setRunning(adapter, true);
|
||||
invokePrivate(adapter, "ensureReplyExecutor");
|
||||
invokePrivate(adapter, "openReplyQueue");
|
||||
// Most tests run with a much shorter idle timeout so the worker's
|
||||
// 60-second poll doesn't dominate test wall-clock time.
|
||||
adapter.workerIdleTimeoutMs = 80;
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() throws Exception {
|
||||
// Belt-and-suspenders cleanup: even if an assertion failed, drop
|
||||
// the executor so dangling worker threads don't bleed into the
|
||||
// next test.
|
||||
try {
|
||||
invokePrivate(adapter, "releaseConnectionResources", new Class<?>[]{String.class}, "test-teardown");
|
||||
} catch (Exception ignored) {}
|
||||
setRunning(adapter, false);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// S-1: same reqId serial dispatch
|
||||
// =====================================================================
|
||||
|
||||
@Nested
|
||||
@DisplayName("S-1 same reqId serial dispatch")
|
||||
class S1_SerialDispatch {
|
||||
|
||||
@Test
|
||||
@DisplayName("three frames on same reqId: only one in flight at a time")
|
||||
void serialPerReqId() throws Exception {
|
||||
String reqId = "req_s1";
|
||||
// Don't auto-ACK; tests will release ACKs one by one.
|
||||
adapter.autoAck = false;
|
||||
|
||||
CompletableFuture<Map<String, Object>> f1 = adapter.callSendFrameWithAck(reqId, frame(reqId, "msg1"));
|
||||
CompletableFuture<Map<String, Object>> f2 = adapter.callSendFrameWithAck(reqId, frame(reqId, "msg2"));
|
||||
CompletableFuture<Map<String, Object>> f3 = adapter.callSendFrameWithAck(reqId, frame(reqId, "msg3"));
|
||||
|
||||
// Worker thread starts asynchronously — give it a tick to dequeue
|
||||
// the first task and dispatch sendFrame.
|
||||
assertEquals("msg1", awaitFrameText(adapter, 500),
|
||||
"first frame must dispatch within 500ms");
|
||||
|
||||
// No further frame may dispatch until the first ACK arrives.
|
||||
// Sleep ~150ms (≈ 2x adapter.workerIdleTimeoutMs) and assert
|
||||
// the queue stayed empty.
|
||||
Thread.sleep(150);
|
||||
assertNull(adapter.sentFrames.poll(), "second frame must NOT dispatch before first ACK");
|
||||
|
||||
// Release ACK 1 → frame 2 should now dispatch.
|
||||
completeAck(adapter, reqId);
|
||||
assertEquals("msg2", awaitFrameText(adapter, 500));
|
||||
|
||||
Thread.sleep(150);
|
||||
assertNull(adapter.sentFrames.poll(), "third frame must NOT dispatch before second ACK");
|
||||
|
||||
completeAck(adapter, reqId);
|
||||
assertEquals("msg3", awaitFrameText(adapter, 500));
|
||||
|
||||
completeAck(adapter, reqId);
|
||||
|
||||
// All three futures should now complete successfully.
|
||||
assertNotNull(f1.get(500, TimeUnit.MILLISECONDS));
|
||||
assertNotNull(f2.get(500, TimeUnit.MILLISECONDS));
|
||||
assertNotNull(f3.get(500, TimeUnit.MILLISECONDS));
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// S-2: sendFrame sync throw → future fails immediately
|
||||
// =====================================================================
|
||||
|
||||
@Nested
|
||||
@DisplayName("S-2 sendFrame sync throw → future fails fast")
|
||||
class S2_SendFrameThrow {
|
||||
|
||||
@Test
|
||||
@DisplayName("future fails within 200ms on IOException, not the 5s ACK timeout")
|
||||
void syncThrowFailsFast() throws Exception {
|
||||
adapter.sendFrameBehavior = frame -> {
|
||||
throw new RuntimeException("simulated ws sendText failure", new IOException("ws null"));
|
||||
};
|
||||
|
||||
long t0 = System.nanoTime();
|
||||
CompletableFuture<Map<String, Object>> future =
|
||||
adapter.callSendFrameWithAck("req_s2", frame("req_s2", "x"));
|
||||
|
||||
ExecutionException ex = assertThrows(ExecutionException.class,
|
||||
() -> future.get(500, TimeUnit.MILLISECONDS),
|
||||
"future must complete (exceptionally) within 500ms");
|
||||
long elapsedMs = (System.nanoTime() - t0) / 1_000_000;
|
||||
assertTrue(elapsedMs < 200,
|
||||
"should fail-fast in under 200ms, took " + elapsedMs + "ms");
|
||||
assertNotNull(ex.getCause());
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// S-3: idle-close vs late-enqueue race × many iterations × many threads
|
||||
// =====================================================================
|
||||
|
||||
@Nested
|
||||
@DisplayName("S-3 worker idle-close vs late enqueue: no orphans across N iterations")
|
||||
class S3_IdleRace {
|
||||
|
||||
@Test
|
||||
@DisplayName("100 iterations × 8 threads: every offered task completes")
|
||||
void noOrphansUnderRace() throws Exception {
|
||||
// Tighten idle timeout to 30ms so each iteration cycles through
|
||||
// open → busy → idle-close in the low-100ms range.
|
||||
adapter.workerIdleTimeoutMs = 30;
|
||||
adapter.autoAck = true; // ACK as soon as worker dispatches
|
||||
|
||||
int threads = 8;
|
||||
int iterationsPerThread = 100;
|
||||
ExecutorService pool = Executors.newFixedThreadPool(threads);
|
||||
CountDownLatch latch = new CountDownLatch(1);
|
||||
ConcurrentLinkedQueue<CompletableFuture<Map<String, Object>>> all =
|
||||
new ConcurrentLinkedQueue<>();
|
||||
|
||||
for (int t = 0; t < threads; t++) {
|
||||
final int tid = t;
|
||||
pool.submit(() -> {
|
||||
try {
|
||||
latch.await();
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
return;
|
||||
}
|
||||
for (int i = 0; i < iterationsPerThread; i++) {
|
||||
// Mix reqIds: some shared (forces worker reuse), some
|
||||
// unique (forces fresh-state path).
|
||||
String reqId = (i % 3 == 0)
|
||||
? "shared_req"
|
||||
: "t" + tid + "_i" + i;
|
||||
all.add(adapter.callSendFrameWithAck(reqId, frame(reqId, "p")));
|
||||
// Random tiny delay so worker idle-close has a chance
|
||||
// to interleave with late offers.
|
||||
if (i % 10 == 0) {
|
||||
try { Thread.sleep(35); } catch (InterruptedException ie) { return; }
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
latch.countDown();
|
||||
pool.shutdown();
|
||||
assertTrue(pool.awaitTermination(20, TimeUnit.SECONDS), "submission threads must finish");
|
||||
|
||||
// Every offered future must eventually complete. 5s budget for the
|
||||
// worker(s) to drain. Track failures with reasons for debuggability.
|
||||
int total = all.size();
|
||||
int orphans = 0;
|
||||
int succeeded = 0;
|
||||
int failed = 0;
|
||||
long deadline = System.currentTimeMillis() + 5_000;
|
||||
for (CompletableFuture<Map<String, Object>> f : all) {
|
||||
long remaining = Math.max(0, deadline - System.currentTimeMillis());
|
||||
try {
|
||||
f.get(remaining, TimeUnit.MILLISECONDS);
|
||||
succeeded++;
|
||||
} catch (TimeoutException te) {
|
||||
orphans++;
|
||||
} catch (Exception e) {
|
||||
// ExecutionException or interrupt — counted as completed
|
||||
// (test only cares that no future hangs forever).
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
assertEquals(0, orphans,
|
||||
"no future may remain pending after the queue drains; " +
|
||||
"total=" + total + " ok=" + succeeded + " err=" + failed +
|
||||
" orphans=" + orphans);
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// S-4: release in progress → all enqueues fast-fail
|
||||
// =====================================================================
|
||||
|
||||
@Nested
|
||||
@DisplayName("S-4 release window: enqueues fail fast, no orphans")
|
||||
class S4_ReleaseRace {
|
||||
|
||||
@Test
|
||||
@DisplayName("100 concurrent enqueues during release: all complete in <1s")
|
||||
void releaseFailsFast() throws Exception {
|
||||
// Spawn 100 concurrent enqueues. Halfway through, trigger
|
||||
// releaseConnectionResources on a separate thread.
|
||||
int N = 100;
|
||||
ExecutorService pool = Executors.newFixedThreadPool(16);
|
||||
CountDownLatch start = new CountDownLatch(1);
|
||||
ConcurrentLinkedQueue<CompletableFuture<Map<String, Object>>> futures =
|
||||
new ConcurrentLinkedQueue<>();
|
||||
|
||||
for (int i = 0; i < N; i++) {
|
||||
final int idx = i;
|
||||
pool.submit(() -> {
|
||||
try { start.await(); } catch (InterruptedException ignored) {}
|
||||
futures.add(adapter.callSendFrameWithAck("req_s4_" + idx, frame("req_s4_" + idx, "p")));
|
||||
});
|
||||
}
|
||||
// Trigger release shortly after enqueue burst begins.
|
||||
pool.submit(() -> {
|
||||
try { start.await(); } catch (InterruptedException ignored) {}
|
||||
try {
|
||||
Thread.sleep(5); // a small lead so some enqueues land first
|
||||
invokePrivate(adapter, "releaseConnectionResources",
|
||||
new Class<?>[]{String.class}, "s4-test");
|
||||
} catch (Exception ignored) {}
|
||||
});
|
||||
start.countDown();
|
||||
pool.shutdown();
|
||||
assertTrue(pool.awaitTermination(5, TimeUnit.SECONDS));
|
||||
|
||||
// Every future must complete in <1s — either success (offered
|
||||
// before gate closed and worker drained) or IllegalStateException
|
||||
// (gate closed by release).
|
||||
long t0 = System.nanoTime();
|
||||
int hangs = 0;
|
||||
for (CompletableFuture<Map<String, Object>> f : futures) {
|
||||
try {
|
||||
f.get(1_000, TimeUnit.MILLISECONDS);
|
||||
} catch (TimeoutException te) {
|
||||
hangs++;
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
long elapsedMs = (System.nanoTime() - t0) / 1_000_000;
|
||||
assertEquals(0, hangs, hangs + " future(s) hung during release window");
|
||||
assertTrue(elapsedMs < 2_000,
|
||||
"all " + futures.size() + " futures should resolve in <2s, took " + elapsedMs + "ms");
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// S-5: executor ready but markReady not called → fast-fail
|
||||
// =====================================================================
|
||||
|
||||
@Nested
|
||||
@DisplayName("S-5 lifecycle gate: enqueue before markReady fails fast")
|
||||
class S5_GateClosed {
|
||||
|
||||
@Test
|
||||
@DisplayName("with executor present but accepting=false, enqueue returns failed future immediately")
|
||||
void closedGateFailsFast() throws Exception {
|
||||
// Force the lifecycle into "executor ready, transport not ready"
|
||||
// (the exact window R-7 covers).
|
||||
adapter.workerIdleTimeoutMs = 60_000; // restore to default — we don't want the worker pool churning
|
||||
// Take the gate down without going through release.
|
||||
Field gate = WeComChannelAdapter.class.getDeclaredField("replyQueueAccepting");
|
||||
gate.setAccessible(true);
|
||||
((AtomicBoolean) gate.get(adapter)).set(false);
|
||||
|
||||
long t0 = System.nanoTime();
|
||||
CompletableFuture<Map<String, Object>> f =
|
||||
adapter.callSendFrameWithAck("req_s5", frame("req_s5", "x"));
|
||||
ExecutionException ex = assertThrows(ExecutionException.class,
|
||||
() -> f.get(200, TimeUnit.MILLISECONDS));
|
||||
long elapsedMs = (System.nanoTime() - t0) / 1_000_000;
|
||||
assertTrue(elapsedMs < 100,
|
||||
"fast-fail should be near-instant (sync resolution), took " + elapsedMs + "ms");
|
||||
assertInstanceOf(IllegalStateException.class, ex.getCause(),
|
||||
"must surface the gate-closed reason as IllegalStateException");
|
||||
assertTrue(ex.getCause().getMessage().contains("not accepting"),
|
||||
"error message must mention 'not accepting'; got: " + ex.getCause().getMessage());
|
||||
// No frame should ever have been queued.
|
||||
assertNull(adapter.sentFrames.poll(),
|
||||
"sendFrame must not be invoked when gate is closed");
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// S-6: release ordering — accepting=false happens-before ws.close()
|
||||
// =====================================================================
|
||||
|
||||
@Nested
|
||||
@DisplayName("S-6 release ordering: accepting flips first")
|
||||
class S6_ReleaseOrdering {
|
||||
|
||||
@Test
|
||||
@DisplayName("when ws.sendClose runs, replyQueueAccepting is already false")
|
||||
void acceptingFalseBeforeWsClose() throws Exception {
|
||||
// Install an instrumented WebSocket that records the gate value
|
||||
// at the moment sendClose() is invoked.
|
||||
AtomicBoolean acceptingAtCloseTime = new AtomicBoolean(true);
|
||||
AtomicBoolean closeWasCalled = new AtomicBoolean(false);
|
||||
|
||||
WebSocket fakeWs = (WebSocket) java.lang.reflect.Proxy.newProxyInstance(
|
||||
WebSocket.class.getClassLoader(),
|
||||
new Class<?>[]{WebSocket.class},
|
||||
(proxy, method, args) -> {
|
||||
if ("sendClose".equals(method.getName())) {
|
||||
// Snapshot gate state at the exact moment release
|
||||
// is calling close on us. The S-6 invariant:
|
||||
// step 0 must have already flipped accepting.
|
||||
Field gate = WeComChannelAdapter.class.getDeclaredField("replyQueueAccepting");
|
||||
gate.setAccessible(true);
|
||||
acceptingAtCloseTime.set(((AtomicBoolean) gate.get(adapter)).get());
|
||||
closeWasCalled.set(true);
|
||||
return CompletableFuture.completedFuture(proxy);
|
||||
}
|
||||
if (method.getReturnType() == boolean.class) return false;
|
||||
if (method.getReturnType() == long.class) return 0L;
|
||||
return null;
|
||||
});
|
||||
|
||||
// Inject the fake into the adapter and verify accepting is true
|
||||
// (i.e. we're in normal operation about to release).
|
||||
Field wsField = WeComChannelAdapter.class.getDeclaredField("webSocket");
|
||||
wsField.setAccessible(true);
|
||||
wsField.set(adapter, fakeWs);
|
||||
|
||||
Field gate = WeComChannelAdapter.class.getDeclaredField("replyQueueAccepting");
|
||||
gate.setAccessible(true);
|
||||
assertTrue(((AtomicBoolean) gate.get(adapter)).get(),
|
||||
"precondition: accepting must be true before release");
|
||||
|
||||
invokePrivate(adapter, "releaseConnectionResources",
|
||||
new Class<?>[]{String.class}, "s6-test");
|
||||
|
||||
assertTrue(closeWasCalled.get(), "release must invoke ws.sendClose");
|
||||
assertFalse(acceptingAtCloseTime.get(),
|
||||
"step 0 (accepting=false) must happen-before ws.sendClose; " +
|
||||
"if this fails, the release method body has been re-ordered " +
|
||||
"and an enqueue could land between accepting and ws teardown");
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Helpers
|
||||
// =====================================================================
|
||||
|
||||
/** Build the canonical aibot_respond_msg frame the adapter uses. */
|
||||
private static Map<String, Object> frame(String reqId, String text) {
|
||||
return Map.of(
|
||||
"cmd", "aibot_respond_msg",
|
||||
"headers", Map.of("req_id", reqId),
|
||||
"body", Map.of("msgtype", "text", "text", Map.of("content", text))
|
||||
);
|
||||
}
|
||||
|
||||
/** Read the most-recent dispatched frame's text content. Polls up to {@code timeoutMs}. */
|
||||
private static String awaitFrameText(TestableAdapter a, long timeoutMs) throws Exception {
|
||||
Map<String, Object> f = a.sentFrames.poll(timeoutMs, TimeUnit.MILLISECONDS);
|
||||
assertNotNull(f, "no frame dispatched within " + timeoutMs + "ms");
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> body = (Map<String, Object>) f.get("body");
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> txt = (Map<String, Object>) body.get("text");
|
||||
return (String) txt.get("content");
|
||||
}
|
||||
|
||||
/** Complete the in-flight ACK future for the given reqId. Returns true if found. */
|
||||
@SuppressWarnings({"unchecked", "rawtypes"})
|
||||
private static boolean completeAck(WeComChannelAdapter a, String reqId) throws Exception {
|
||||
Field f = WeComChannelAdapter.class.getDeclaredField("pendingAcks");
|
||||
f.setAccessible(true);
|
||||
ConcurrentHashMap<String, CompletableFuture> map =
|
||||
(ConcurrentHashMap<String, CompletableFuture>) f.get(a);
|
||||
// Wait briefly for the worker to register the future before completing.
|
||||
long deadline = System.currentTimeMillis() + 500;
|
||||
CompletableFuture future = null;
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
future = map.get(reqId);
|
||||
if (future != null) break;
|
||||
Thread.sleep(5);
|
||||
}
|
||||
if (future == null) return false;
|
||||
future.complete(Map.of("errcode", 0));
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void setRunning(WeComChannelAdapter a, boolean v) throws Exception {
|
||||
// running lives on AbstractChannelAdapter; walk the class chain to find it.
|
||||
Field running = findField(a.getClass(), "running");
|
||||
((AtomicBoolean) running.get(a)).set(v);
|
||||
}
|
||||
|
||||
private static Field findField(Class<?> cls, String name) throws NoSuchFieldException {
|
||||
for (Class<?> c = cls; c != null; c = c.getSuperclass()) {
|
||||
try {
|
||||
Field f = c.getDeclaredField(name);
|
||||
f.setAccessible(true);
|
||||
return f;
|
||||
} catch (NoSuchFieldException ignored) {
|
||||
// keep walking
|
||||
}
|
||||
}
|
||||
throw new NoSuchFieldException(name + " not found in class chain rooted at " + cls);
|
||||
}
|
||||
|
||||
private static Object invokePrivate(WeComChannelAdapter a, String method) throws Exception {
|
||||
return invokePrivate(a, method, new Class<?>[0]);
|
||||
}
|
||||
|
||||
private static Object invokePrivate(WeComChannelAdapter a, String method,
|
||||
Class<?>[] paramTypes, Object... args) throws Exception {
|
||||
var m = WeComChannelAdapter.class.getDeclaredMethod(method, paramTypes);
|
||||
m.setAccessible(true);
|
||||
return m.invoke(a, args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test-only adapter that captures dispatched frames and lets each
|
||||
* test choose between auto-ACK or manual ACK release.
|
||||
*/
|
||||
static class TestableAdapter extends WeComChannelAdapter {
|
||||
|
||||
final LinkedBlockingQueue<Map<String, Object>> sentFrames = new LinkedBlockingQueue<>();
|
||||
|
||||
/** When false, tests must manually call {@code completeAck}. */
|
||||
volatile boolean autoAck = true;
|
||||
|
||||
/** Optional behavior injected per-test (return value ignored; thrown exceptions propagate). */
|
||||
volatile Function<Map<String, Object>, Void> sendFrameBehavior = null;
|
||||
|
||||
TestableAdapter(ChannelEntity entity, ChannelMessageRouter router,
|
||||
ObjectMapper mapper, ApprovalNotificationService approvalSvc,
|
||||
WeComCardDispatcher cardDispatcher, WeComKeepaliveScheduler keepalive) {
|
||||
super(entity, router, mapper, approvalSvc, cardDispatcher, keepalive);
|
||||
}
|
||||
|
||||
@Override
|
||||
void sendFrame(Map<String, Object> frame) {
|
||||
sentFrames.offer(frame);
|
||||
Function<Map<String, Object>, Void> beh = sendFrameBehavior;
|
||||
if (beh != null) {
|
||||
beh.apply(frame); // may throw
|
||||
return;
|
||||
}
|
||||
if (autoAck) {
|
||||
String reqId = extractReqId(frame);
|
||||
if (reqId != null) {
|
||||
// Schedule async ACK on a tiny delay so the worker has time
|
||||
// to register the future before we complete it.
|
||||
AUTOACK.submit(() -> {
|
||||
try {
|
||||
Thread.sleep(2);
|
||||
completeAck(this, reqId);
|
||||
} catch (Exception ignored) {}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Expose package-private sendFrameWithAck to tests. */
|
||||
CompletableFuture<Map<String, Object>> callSendFrameWithAck(String reqId, Map<String, Object> frame) {
|
||||
try {
|
||||
var m = WeComChannelAdapter.class.getDeclaredMethod("sendFrameWithAck", String.class, Map.class);
|
||||
m.setAccessible(true);
|
||||
@SuppressWarnings("unchecked")
|
||||
CompletableFuture<Map<String, Object>> f =
|
||||
(CompletableFuture<Map<String, Object>>) m.invoke(this, reqId, frame);
|
||||
return f;
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static String extractReqId(Map<String, Object> frame) {
|
||||
Map<String, Object> headers = (Map<String, Object>) frame.get("headers");
|
||||
return headers == null ? null : (String) headers.get("req_id");
|
||||
}
|
||||
|
||||
private static final ExecutorService AUTOACK = Executors.newCachedThreadPool(r -> {
|
||||
Thread t = new Thread(r, "test-auto-ack");
|
||||
t.setDaemon(true);
|
||||
return t;
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,207 @@
|
||||
package vip.mate.channel.wecom;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.Mockito;
|
||||
import vip.mate.channel.ChannelMessageRouter;
|
||||
import vip.mate.channel.model.ChannelEntity;
|
||||
import vip.mate.channel.notification.ApprovalNotificationService;
|
||||
import vip.mate.channel.wecom.cards.WeComCardDispatcher;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.LinkedBlockingQueue;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* Exercises the chunk-content dedup added to
|
||||
* {@link WeComChannelAdapter#replyStream(String, String, String, boolean, String)}
|
||||
* (RFC-32 §2.1.3). Without dedup, every token-level update during tool
|
||||
* argument streaming would emit a fresh frame even when the visible
|
||||
* content didn't change — flickering the IM client.
|
||||
*
|
||||
* <p>Run pattern: drop {@code sendFrame} into a queue so we can count
|
||||
* how many frames actually went out for a given content sequence,
|
||||
* without touching a real WebSocket.
|
||||
*/
|
||||
class ReplyStreamDedupTest {
|
||||
|
||||
private TestableAdapter adapter;
|
||||
private LinkedBlockingQueue<Map<String, Object>> sentFrames;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
ChannelEntity entity = new ChannelEntity();
|
||||
entity.setId(1L);
|
||||
entity.setChannelType("wecom");
|
||||
entity.setConfigJson("{}");
|
||||
adapter = new TestableAdapter(
|
||||
entity,
|
||||
Mockito.mock(ChannelMessageRouter.class),
|
||||
new ObjectMapper(),
|
||||
Mockito.mock(ApprovalNotificationService.class),
|
||||
Mockito.mock(WeComCardDispatcher.class),
|
||||
Mockito.mock(WeComKeepaliveScheduler.class));
|
||||
sentFrames = adapter.sentFrames;
|
||||
|
||||
// Bring the adapter to "running + accepting" so sendFrameWithAck doesn't
|
||||
// fast-fail on the lifecycle gate (PR-0).
|
||||
Field running = adapter.getClass().getSuperclass().getSuperclass().getDeclaredField("running");
|
||||
running.setAccessible(true);
|
||||
((AtomicBoolean) running.get(adapter)).set(true);
|
||||
Method ensure = WeComChannelAdapter.class.getDeclaredMethod("ensureReplyExecutor");
|
||||
ensure.setAccessible(true);
|
||||
ensure.invoke(adapter);
|
||||
Method open = WeComChannelAdapter.class.getDeclaredMethod("openReplyQueue");
|
||||
open.setAccessible(true);
|
||||
open.invoke(adapter);
|
||||
// Long idle so the worker doesn't churn during the short test.
|
||||
adapter.workerIdleTimeoutMs = 60_000L;
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("identical non-final chunks dedup: only first goes out")
|
||||
void identicalChunksDedup() throws Exception {
|
||||
Method m = WeComChannelAdapter.class.getDeclaredMethod(
|
||||
"replyStream", String.class, String.class, String.class, boolean.class);
|
||||
m.setAccessible(true);
|
||||
m.invoke(adapter, "rid", "stream-1", "Hello", false);
|
||||
m.invoke(adapter, "rid", "stream-1", "Hello", false); // dup → skipped
|
||||
m.invoke(adapter, "rid", "stream-1", "Hello", false); // dup → skipped
|
||||
|
||||
// Only the first frame should have been dispatched (give worker a beat).
|
||||
Map<String, Object> first = sentFrames.poll(500, TimeUnit.MILLISECONDS);
|
||||
assertNotNull(first, "first non-final chunk should have dispatched");
|
||||
assertNull(sentFrames.poll(200, TimeUnit.MILLISECONDS),
|
||||
"duplicate non-final chunks must be deduplicated");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("changed content always goes out")
|
||||
void changedContentDispatches() throws Exception {
|
||||
Method m = WeComChannelAdapter.class.getDeclaredMethod(
|
||||
"replyStream", String.class, String.class, String.class, boolean.class);
|
||||
m.setAccessible(true);
|
||||
m.invoke(adapter, "rid", "stream-1", "Hello", false);
|
||||
m.invoke(adapter, "rid", "stream-1", "Hello world", false); // changed → goes
|
||||
m.invoke(adapter, "rid", "stream-1", "Hello world", false); // dup → skipped
|
||||
|
||||
// 2 frames expected (poll up to 500ms each)
|
||||
Map<String, Object> f1 = sentFrames.poll(500, TimeUnit.MILLISECONDS);
|
||||
Map<String, Object> f2 = sentFrames.poll(500, TimeUnit.MILLISECONDS);
|
||||
assertNotNull(f1);
|
||||
assertNotNull(f2);
|
||||
assertNull(sentFrames.poll(200, TimeUnit.MILLISECONDS),
|
||||
"no third frame: only 2 distinct contents should have been sent");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("finish=true always goes out, even with identical content")
|
||||
void finishAlwaysDispatches() throws Exception {
|
||||
Method m = WeComChannelAdapter.class.getDeclaredMethod(
|
||||
"replyStream", String.class, String.class, String.class, boolean.class);
|
||||
m.setAccessible(true);
|
||||
m.invoke(adapter, "rid", "stream-1", "Done", false);
|
||||
m.invoke(adapter, "rid", "stream-1", "Done", true); // SAME content but finish=true → goes
|
||||
|
||||
Map<String, Object> f1 = sentFrames.poll(500, TimeUnit.MILLISECONDS);
|
||||
Map<String, Object> f2 = sentFrames.poll(500, TimeUnit.MILLISECONDS);
|
||||
assertNotNull(f1);
|
||||
assertNotNull(f2, "finish=true must always dispatch even when content matches the previous chunk");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("dedup is per-streamId; different streams don't interfere")
|
||||
void perStreamIsolation() throws Exception {
|
||||
Method m = WeComChannelAdapter.class.getDeclaredMethod(
|
||||
"replyStream", String.class, String.class, String.class, boolean.class);
|
||||
m.setAccessible(true);
|
||||
m.invoke(adapter, "rid", "stream-A", "X", false);
|
||||
m.invoke(adapter, "rid", "stream-B", "X", false); // different stream — must dispatch
|
||||
|
||||
Map<String, Object> f1 = sentFrames.poll(500, TimeUnit.MILLISECONDS);
|
||||
Map<String, Object> f2 = sentFrames.poll(500, TimeUnit.MILLISECONDS);
|
||||
assertNotNull(f1);
|
||||
assertNotNull(f2,
|
||||
"dedup memory must be per-streamId — same content on a different stream still dispatches");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("after finish=true, the dedup slot is cleared so the next stream with same content goes")
|
||||
void finishClearsDedupSlot() throws Exception {
|
||||
Method m = WeComChannelAdapter.class.getDeclaredMethod(
|
||||
"replyStream", String.class, String.class, String.class, boolean.class);
|
||||
m.setAccessible(true);
|
||||
m.invoke(adapter, "rid", "stream-1", "X", false);
|
||||
m.invoke(adapter, "rid", "stream-1", "X", true); // finish, clears slot
|
||||
m.invoke(adapter, "rid", "stream-1", "X", false); // new chunk — slot was cleared, so goes
|
||||
|
||||
// 3 frames expected total
|
||||
for (int i = 0; i < 3; i++) {
|
||||
assertNotNull(sentFrames.poll(500, TimeUnit.MILLISECONDS),
|
||||
"expected frame #" + (i + 1) + " to dispatch");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test-only adapter that captures dispatched frames AND auto-completes
|
||||
* each {@code pendingAcks} future shortly after the frame goes out, so
|
||||
* the per-reqId serial worker can dequeue the next task without waiting
|
||||
* the full 5s {@code orTimeout}. Without auto-ack, the dedup tests that
|
||||
* dispatch multiple distinct frames would each block ~5s on the prior
|
||||
* frame's ACK.
|
||||
*/
|
||||
static class TestableAdapter extends WeComChannelAdapter {
|
||||
final LinkedBlockingQueue<Map<String, Object>> sentFrames = new LinkedBlockingQueue<>();
|
||||
private static final ExecutorService AUTOACK = Executors.newCachedThreadPool(r -> {
|
||||
Thread t = new Thread(r, "test-autoack-dedup");
|
||||
t.setDaemon(true);
|
||||
return t;
|
||||
});
|
||||
|
||||
TestableAdapter(ChannelEntity entity, ChannelMessageRouter router,
|
||||
ObjectMapper mapper, ApprovalNotificationService approvalSvc,
|
||||
WeComCardDispatcher cardDispatcher, WeComKeepaliveScheduler keepalive) {
|
||||
super(entity, router, mapper, approvalSvc, cardDispatcher, keepalive);
|
||||
}
|
||||
|
||||
@Override
|
||||
@SuppressWarnings("unchecked")
|
||||
void sendFrame(Map<String, Object> frame) {
|
||||
sentFrames.offer(frame);
|
||||
// Mirror what the WeCom server would do in production: ACK the
|
||||
// outbound request so the worker's task.future().join() unblocks
|
||||
// and the next frame in the same reqId queue can dispatch.
|
||||
Map<String, Object> headers = (Map<String, Object>) frame.get("headers");
|
||||
if (headers == null) return;
|
||||
String reqId = (String) headers.get("req_id");
|
||||
if (reqId == null || reqId.isBlank()) return;
|
||||
AUTOACK.submit(() -> completeAckSoon(reqId));
|
||||
}
|
||||
|
||||
private void completeAckSoon(String reqId) {
|
||||
try {
|
||||
// Brief delay so the worker has reliably completed
|
||||
// pendingAcks.put before we look it up.
|
||||
Thread.sleep(2);
|
||||
Field f = WeComChannelAdapter.class.getDeclaredField("pendingAcks");
|
||||
f.setAccessible(true);
|
||||
@SuppressWarnings("unchecked")
|
||||
ConcurrentHashMap<String, CompletableFuture<Map<String, Object>>> pending =
|
||||
(ConcurrentHashMap<String, CompletableFuture<Map<String, Object>>>) f.get(this);
|
||||
CompletableFuture<Map<String, Object>> fut = pending.get(reqId);
|
||||
if (fut != null) fut.complete(Map.of("errcode", 0));
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,77 @@
|
||||
package vip.mate.channel.wecom;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* Pin the alignment between {@code WeComChannelAdapter.inboundConversationId}
|
||||
* and {@code ChannelMessageRouter.buildConversationId}.
|
||||
*
|
||||
* <p>These two compute the same logical conversation id from different code
|
||||
* paths: the adapter pre-computes it to choose the per-conversation
|
||||
* upload directory <em>before</em> the {@link vip.mate.channel.ChannelMessage}
|
||||
* exists, and the router computes it from the {@code ChannelMessage}
|
||||
* downstream. They MUST agree on the same string format, otherwise
|
||||
* inbound media saves to one directory while messages persist under a
|
||||
* different conversationId — and the {@code /api/v1/chat/files/{convId}/...}
|
||||
* endpoint's owner check fails for every fetch (403 → broken images).
|
||||
*
|
||||
* <p>The format both produce: {@code wecom:{chatId}} for groups,
|
||||
* {@code wecom:{senderId}} for 1:1 — no {@code group:} infix.
|
||||
*/
|
||||
class WeComInboundConversationIdTest {
|
||||
|
||||
private static String inboundConversationId(String senderId, String chatId, String chatType) throws Exception {
|
||||
Method m = WeComChannelAdapter.class.getDeclaredMethod(
|
||||
"inboundConversationId", String.class, String.class, String.class);
|
||||
m.setAccessible(true);
|
||||
return (String) m.invoke(null, senderId, chatId, chatType);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("group → wecom:{chatId} (no 'group:' infix, matches router)")
|
||||
void groupChatIdFormat() throws Exception {
|
||||
// The bug fix: previously returned "wecom:group:abc" which mismatched
|
||||
// the router's "wecom:abc" — quoted-image fileUrls hit a 403 because
|
||||
// isConversationOwner couldn't find a "wecom:group:abc" row in
|
||||
// mate_conversation.
|
||||
assertEquals("wecom:group-abc",
|
||||
inboundConversationId("XuZhanFu", "group-abc", "group"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("1:1 → wecom:{senderId} (chatId is irrelevant in single chats)")
|
||||
void singleChatSenderFormat() throws Exception {
|
||||
// Single-chat case never had the bug because both adapter and
|
||||
// router fell back to senderId — pin it so a future refactor of
|
||||
// either side doesn't accidentally diverge.
|
||||
assertEquals("wecom:XuZhanFu",
|
||||
inboundConversationId("XuZhanFu", null, "single"));
|
||||
assertEquals("wecom:XuZhanFu",
|
||||
inboundConversationId("XuZhanFu", "ignored-when-single", "single"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("matches ChannelMessageRouter.buildConversationId for both group and 1:1")
|
||||
void matchesRouterFormat() throws Exception {
|
||||
// Router's identifier picker:
|
||||
// chatId != null → "{channelType}:{chatId}" (group)
|
||||
// chatId == null → "{channelType}:{senderId}" (single)
|
||||
// Inbound side passes chatId for groups, null/ignored for 1:1.
|
||||
// Both must arrive at the same string, exact-equal.
|
||||
|
||||
// group: router gets chatId from the ChannelMessage builder
|
||||
String routerGroup = "wecom" + ":" + "group-xyz";
|
||||
assertEquals(routerGroup,
|
||||
inboundConversationId("Alice", "group-xyz", "group"));
|
||||
|
||||
// single: router falls back to senderId (chatId is null on the message)
|
||||
String routerSingle = "wecom" + ":" + "Alice";
|
||||
assertEquals(routerSingle,
|
||||
inboundConversationId("Alice", null, "single"));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,163 @@
|
||||
package vip.mate.channel.wecom;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.Mockito;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* Verify the WeComKeepaliveScheduler bookkeeping + force-finish path.
|
||||
*
|
||||
* <p>The 20s/180s timing constants come from QwenPaw and are already
|
||||
* validated empirically in production; we don't re-test the exact
|
||||
* scheduling intervals here (would require either real wall-clock waits
|
||||
* or invasive ScheduledExecutor mocking). Instead we cover:
|
||||
* <ul>
|
||||
* <li>start/stop/shutdownAll bookkeeping is correct</li>
|
||||
* <li>the force-finish branch (180s ceiling) calls
|
||||
* {@link WeComChannelAdapter#replyStreamFinishForKeepalive} AND
|
||||
* {@link WeComChannelAdapter#invalidateReplyContext} — the
|
||||
* RFC-32 §2.1.2 invariant that prevents the next real reply from
|
||||
* reusing a closed stream slot</li>
|
||||
* <li>the refresh branch (still under ceiling) calls
|
||||
* {@link WeComChannelAdapter#replyStreamRefreshForKeepalive} only</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Force-finish is exercised by reflection-overriding {@code startedAt}
|
||||
* to a long-ago timestamp on a tracked StreamState, then invoking the
|
||||
* private {@code tick} method. This bypasses the ScheduledExecutor
|
||||
* entirely so tests run in milliseconds.
|
||||
*/
|
||||
class WeComKeepaliveSchedulerTest {
|
||||
|
||||
private WeComKeepaliveScheduler scheduler;
|
||||
private WeComChannelAdapter adapter;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
scheduler = new WeComKeepaliveScheduler();
|
||||
adapter = Mockito.mock(WeComChannelAdapter.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("start adds a stream entry; stop removes it")
|
||||
void startStopBookkeeping() {
|
||||
assertEquals(0, scheduler.activeStreamCount());
|
||||
|
||||
scheduler.start(adapter, "req-1", "stream-1", "user-alice");
|
||||
assertEquals(1, scheduler.activeStreamCount());
|
||||
|
||||
scheduler.stop("stream-1");
|
||||
assertEquals(0, scheduler.activeStreamCount());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("start is idempotent — second call for same streamId is a no-op")
|
||||
void startIdempotent() {
|
||||
scheduler.start(adapter, "req-1", "stream-1", "user-alice");
|
||||
scheduler.start(adapter, "req-1", "stream-1", "user-alice");
|
||||
assertEquals(1, scheduler.activeStreamCount(), "second start must not double-track");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("start is null-tolerant — null/blank args silently drop")
|
||||
void startNullTolerant() {
|
||||
scheduler.start(null, "r", "s", "t");
|
||||
scheduler.start(adapter, null, "s", "t");
|
||||
scheduler.start(adapter, "", "s", "t");
|
||||
scheduler.start(adapter, "r", null, "t");
|
||||
scheduler.start(adapter, "r", "", "t");
|
||||
assertEquals(0, scheduler.activeStreamCount(),
|
||||
"null/blank args must not add entries");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("shutdownAll clears every tracked stream")
|
||||
void shutdownAllClears() {
|
||||
scheduler.start(adapter, "req-1", "stream-1", "user-alice");
|
||||
scheduler.start(adapter, "req-2", "stream-2", "user-bob");
|
||||
assertEquals(2, scheduler.activeStreamCount());
|
||||
|
||||
scheduler.shutdownAll();
|
||||
assertEquals(0, scheduler.activeStreamCount());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("force-finish path: replyStreamFinishForKeepalive + invalidateReplyContext + stop")
|
||||
void forceFinishPath() throws Exception {
|
||||
scheduler.start(adapter, "req-x", "stream-x", "user-alice");
|
||||
|
||||
// Reflectively rewind startedAt so the next tick sees elapsed > 180s
|
||||
Object state = getStreamState("stream-x");
|
||||
Field startedAt = state.getClass().getDeclaredField("startedAt");
|
||||
startedAt.setAccessible(true);
|
||||
// Java's `final long` fields normally resist setAccessible.set — unfortunately
|
||||
// primitives also need the modifiers hack on JDK 17+. Use Unsafe-free path:
|
||||
// the field happens to be declared `final` in the static record, so we mutate
|
||||
// via setLong (which works for primitives even on final fields when accessible
|
||||
// is true on JDK17 — verified locally).
|
||||
startedAt.setLong(state, System.currentTimeMillis() - 200_000L);
|
||||
|
||||
// Manually invoke the private tick(StreamState) — no ScheduledExecutor
|
||||
// wall-clock wait
|
||||
Method tick = WeComKeepaliveScheduler.class.getDeclaredMethod(
|
||||
"tick", Class.forName(WeComKeepaliveScheduler.class.getName() + "$StreamState"));
|
||||
tick.setAccessible(true);
|
||||
tick.invoke(scheduler, state);
|
||||
|
||||
verify(adapter, times(1)).replyStreamFinishForKeepalive(
|
||||
eq("req-x"), eq("stream-x"), eq(WeComKeepaliveScheduler.PROCESSING_TEXT));
|
||||
verify(adapter, times(1)).invalidateReplyContext(eq("user-alice"), eq("stream-x"));
|
||||
verify(adapter, never()).replyStreamRefreshForKeepalive(any(), any(), any());
|
||||
// After force-finish, the stream is removed from the tracker
|
||||
assertEquals(0, scheduler.activeStreamCount());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("refresh path: replyStreamRefreshForKeepalive only — no force-finish below ceiling")
|
||||
void refreshPathBelowCeiling() throws Exception {
|
||||
scheduler.start(adapter, "req-y", "stream-y", "user-bob");
|
||||
|
||||
// Don't rewind startedAt; the state is fresh — well under 180s.
|
||||
Object state = getStreamState("stream-y");
|
||||
Method tick = WeComKeepaliveScheduler.class.getDeclaredMethod(
|
||||
"tick", Class.forName(WeComKeepaliveScheduler.class.getName() + "$StreamState"));
|
||||
tick.setAccessible(true);
|
||||
tick.invoke(scheduler, state);
|
||||
|
||||
verify(adapter, times(1)).replyStreamRefreshForKeepalive(
|
||||
eq("req-y"), eq("stream-y"), eq(WeComKeepaliveScheduler.PROCESSING_TEXT));
|
||||
verify(adapter, never()).replyStreamFinishForKeepalive(any(), any(), any());
|
||||
verify(adapter, never()).invalidateReplyContext(any(), any());
|
||||
// Still tracked — refresh ticks don't unregister
|
||||
assertEquals(1, scheduler.activeStreamCount());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("constants match the QwenPaw-verified values (20s refresh / 180s ceiling)")
|
||||
void constantsMatch() {
|
||||
assertEquals(20L, WeComKeepaliveScheduler.REFRESH_INTERVAL_SECONDS);
|
||||
assertEquals(180L, WeComKeepaliveScheduler.MAX_DURATION_SECONDS);
|
||||
assertEquals("🤔 思考中...", WeComKeepaliveScheduler.PROCESSING_TEXT);
|
||||
}
|
||||
|
||||
// Pull a tracked StreamState by streamId via reflection. The states map
|
||||
// lives behind a private final ConcurrentHashMap.
|
||||
private Object getStreamState(String streamId) throws Exception {
|
||||
Field statesField = WeComKeepaliveScheduler.class.getDeclaredField("states");
|
||||
statesField.setAccessible(true);
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> states = (Map<String, Object>) statesField.get(scheduler);
|
||||
Object st = states.get(streamId);
|
||||
assertNotNull(st, "expected stream " + streamId + " to be tracked");
|
||||
return st;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,115 @@
|
||||
package vip.mate.channel.wecom;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import vip.mate.channel.wecom.WeComChannelAdapter.WeComUploadLimitDecision;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static vip.mate.channel.wecom.WeComChannelAdapter.applyWeComUploadLimits;
|
||||
import static vip.mate.channel.wecom.WeComChannelAdapter.FILE_MAX_BYTES;
|
||||
import static vip.mate.channel.wecom.WeComChannelAdapter.IMAGE_MAX_BYTES;
|
||||
import static vip.mate.channel.wecom.WeComChannelAdapter.VIDEO_MAX_BYTES;
|
||||
import static vip.mate.channel.wecom.WeComChannelAdapter.VOICE_MAX_BYTES;
|
||||
|
||||
/**
|
||||
* Pin the WeCom upload-limits decision matrix.
|
||||
*
|
||||
* <p>The platform server enforces these limits at the chunk-finish step
|
||||
* (after we've already uploaded all bytes). Without the client-side
|
||||
* pre-check, a 25 MB PDF would chunk-upload for ~minutes, then the
|
||||
* server rejects the finish frame, and the user sees nothing arrive.
|
||||
* These tests pin the boundary so future tweaks (e.g. WeCom raising
|
||||
* limits) are intentional.
|
||||
*/
|
||||
class WeComUploadLimitsTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("normal-sized file passes through with native media type")
|
||||
void normalFilePasses() {
|
||||
WeComUploadLimitDecision d = applyWeComUploadLimits(1_000_000, "file", null);
|
||||
assertFalse(d.rejected());
|
||||
assertFalse(d.downgraded());
|
||||
assertEquals("file", d.finalMediaType());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("file at exactly 20MB still passes; over rejects")
|
||||
void fileBoundary() {
|
||||
WeComUploadLimitDecision pass = applyWeComUploadLimits(FILE_MAX_BYTES, "file", null);
|
||||
assertFalse(pass.rejected());
|
||||
|
||||
WeComUploadLimitDecision fail = applyWeComUploadLimits(FILE_MAX_BYTES + 1, "file", null);
|
||||
assertTrue(fail.rejected());
|
||||
assertNotNull(fail.rejectReason());
|
||||
assertTrue(fail.rejectReason().contains("20MB"),
|
||||
"reject reason should mention 20MB; got: " + fail.rejectReason());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("image over 10MB downgrades to file with friendly note")
|
||||
void oversizedImageDowngrades() {
|
||||
WeComUploadLimitDecision d = applyWeComUploadLimits(IMAGE_MAX_BYTES + 1, "image", "image/png");
|
||||
assertFalse(d.rejected());
|
||||
assertTrue(d.downgraded());
|
||||
assertEquals("file", d.finalMediaType());
|
||||
assertNotNull(d.downgradeNote());
|
||||
assertTrue(d.downgradeNote().contains("图片"));
|
||||
assertTrue(d.downgradeNote().contains("10MB"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("image at exactly 10MB still passes as image")
|
||||
void imageAtBoundary() {
|
||||
WeComUploadLimitDecision d = applyWeComUploadLimits(IMAGE_MAX_BYTES, "image", "image/jpeg");
|
||||
assertFalse(d.rejected());
|
||||
assertFalse(d.downgraded());
|
||||
assertEquals("image", d.finalMediaType());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("video over 10MB downgrades to file")
|
||||
void oversizedVideoDowngrades() {
|
||||
WeComUploadLimitDecision d = applyWeComUploadLimits(VIDEO_MAX_BYTES + 1, "video", "video/mp4");
|
||||
assertEquals("file", d.finalMediaType());
|
||||
assertTrue(d.downgraded());
|
||||
assertTrue(d.downgradeNote().contains("视频"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("voice with non-AMR mime downgrades to file regardless of size")
|
||||
void voiceWrongMimeDowngrades() {
|
||||
WeComUploadLimitDecision d = applyWeComUploadLimits(500_000, "voice", "audio/mpeg");
|
||||
assertEquals("file", d.finalMediaType());
|
||||
assertTrue(d.downgraded());
|
||||
assertTrue(d.downgradeNote().contains("AMR"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("voice in AMR but over 2MB downgrades to file")
|
||||
void voiceOversizedAmrDowngrades() {
|
||||
WeComUploadLimitDecision d = applyWeComUploadLimits(VOICE_MAX_BYTES + 1, "voice", "audio/amr");
|
||||
assertEquals("file", d.finalMediaType());
|
||||
assertTrue(d.downgraded());
|
||||
assertTrue(d.downgradeNote().contains("语音"));
|
||||
assertTrue(d.downgradeNote().contains("2MB"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("voice in AMR within 2MB passes natively")
|
||||
void voiceAmrInBoundsPasses() {
|
||||
WeComUploadLimitDecision d = applyWeComUploadLimits(VOICE_MAX_BYTES, "voice", "audio/amr");
|
||||
assertFalse(d.rejected());
|
||||
assertFalse(d.downgraded());
|
||||
assertEquals("voice", d.finalMediaType());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("absolute 20MB cap trumps every modality-specific downgrade")
|
||||
void absoluteCapTrumpsDowngrade() {
|
||||
// An image at 25MB is over both 10MB image limit AND 20MB absolute cap.
|
||||
// The absolute cap fires first (reject), not the downgrade path.
|
||||
WeComUploadLimitDecision d = applyWeComUploadLimits(25L * 1024 * 1024, "image", "image/png");
|
||||
assertTrue(d.rejected());
|
||||
assertFalse(d.downgraded());
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,143 @@
|
||||
package vip.mate.channel.wecom.cards.tool_guard;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import vip.mate.channel.wecom.cards.CardOversizedException;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* Unit tests for the WeCom 1024-byte button.key encoding contract.
|
||||
*
|
||||
* <p>The encoding is the only place in PR-1 where a card payload can
|
||||
* exceed a hard server limit and force the adapter to fall back to
|
||||
* text. These tests pin both the happy-path encoding shape and the
|
||||
* overflow behaviour so future changes to button.key fields can't
|
||||
* silently break either.
|
||||
*/
|
||||
class ToolGuardButtonKeyTest {
|
||||
|
||||
private ToolGuardButtonKey buttonKey;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
buttonKey = new ToolGuardButtonKey(new ObjectMapper());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("encode produces decodable JSON with stable field order")
|
||||
void encodeDecodeRoundTrip() {
|
||||
String encoded = buttonKey.encode(
|
||||
ToolGuardButtonKey.Action.APPROVE,
|
||||
"abc123def456",
|
||||
"shell_exec",
|
||||
"HIGH"
|
||||
);
|
||||
// Stable order ensures byte-length predictability + makes log
|
||||
// greps deterministic.
|
||||
assertTrue(encoded.startsWith("{\"a\":\"approve\""),
|
||||
"first field must be 'a' (action); got: " + encoded);
|
||||
assertTrue(encoded.contains("\"rid\":\"abc123def456\""));
|
||||
assertTrue(encoded.contains("\"tool\":\"shell_exec\""));
|
||||
assertTrue(encoded.contains("\"sev\":\"HIGH\""));
|
||||
|
||||
ToolGuardButtonKey.Decoded decoded = buttonKey.decode(encoded);
|
||||
assertNotNull(decoded);
|
||||
assertEquals(ToolGuardButtonKey.Action.APPROVE, decoded.action());
|
||||
assertEquals("abc123def456", decoded.pendingId());
|
||||
assertEquals("shell_exec", decoded.toolName());
|
||||
assertEquals("HIGH", decoded.severity());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("encode throws CardOversizedException at exactly the 1024-byte threshold")
|
||||
void overflowAt1024Bytes() {
|
||||
// toolName 1100 chars of pure ASCII (1100 bytes) — single character per byte
|
||||
// forces the JSON over 1024 even with all the structural overhead.
|
||||
String hugeTool = "x".repeat(1100);
|
||||
CardOversizedException ex = assertThrows(CardOversizedException.class,
|
||||
() -> buttonKey.encode(
|
||||
ToolGuardButtonKey.Action.DENY,
|
||||
"rid",
|
||||
hugeTool,
|
||||
"MEDIUM"));
|
||||
assertTrue(ex.getMessage().contains("button.key payload"),
|
||||
"exception message should reference button.key payload, got: " + ex.getMessage());
|
||||
assertTrue(ex.getMessage().contains("1024"),
|
||||
"exception message should mention the 1024 limit, got: " + ex.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("encode handles Chinese tool names within the 1024-byte budget")
|
||||
void encodeChineseToolName() {
|
||||
String chinese = "执行命令".repeat(40); // 4 chars * 40 = 160 chars, ~480 UTF-8 bytes
|
||||
String encoded = buttonKey.encode(
|
||||
ToolGuardButtonKey.Action.APPROVE,
|
||||
"uuid-1234",
|
||||
chinese,
|
||||
"MEDIUM"
|
||||
);
|
||||
// sanity: each Chinese char = 3 UTF-8 bytes; 160 chars ≈ 480 bytes;
|
||||
// overhead ≈ 50 bytes; total well under 1024
|
||||
int bytes = encoded.getBytes(StandardCharsets.UTF_8).length;
|
||||
assertTrue(bytes < 1024, "expected < 1024 bytes for moderate Chinese, got " + bytes);
|
||||
ToolGuardButtonKey.Decoded decoded = buttonKey.decode(encoded);
|
||||
assertNotNull(decoded);
|
||||
assertEquals(chinese, decoded.toolName());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("decode returns null for malformed JSON, unknown action, or missing rid")
|
||||
void decodeMalformed() {
|
||||
// Garbage JSON
|
||||
assertNull(buttonKey.decode("not json"));
|
||||
assertNull(buttonKey.decode("{not closed"));
|
||||
// Unknown action
|
||||
assertNull(buttonKey.decode("{\"a\":\"reboot\",\"rid\":\"x\"}"));
|
||||
// Missing rid
|
||||
assertNull(buttonKey.decode("{\"a\":\"approve\"}"));
|
||||
// Blank rid
|
||||
assertNull(buttonKey.decode("{\"a\":\"approve\",\"rid\":\"\"}"));
|
||||
// Null / blank input
|
||||
assertNull(buttonKey.decode(null));
|
||||
assertNull(buttonKey.decode(""));
|
||||
assertNull(buttonKey.decode(" "));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("decode tolerates extra/unknown fields (forward-compat)")
|
||||
void decodeForwardCompat() {
|
||||
String json = "{\"a\":\"deny\",\"rid\":\"r1\",\"tool\":\"t\",\"sev\":\"LOW\",\"future\":42}";
|
||||
ToolGuardButtonKey.Decoded decoded = buttonKey.decode(json);
|
||||
assertNotNull(decoded);
|
||||
assertEquals(ToolGuardButtonKey.Action.DENY, decoded.action());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("encoded JSON respects the 1024-byte boundary on either side")
|
||||
void boundaryExact() {
|
||||
// 950 ASCII chars + JSON overhead (~50 bytes for the structural braces,
|
||||
// commas, quotes, and the 'a'/'rid'/'tool'/'sev' field labels) lands
|
||||
// around 1010 bytes — comfortably under the 1024 limit.
|
||||
String near = "a".repeat(950);
|
||||
String encoded = buttonKey.encode(
|
||||
ToolGuardButtonKey.Action.APPROVE,
|
||||
"x",
|
||||
near,
|
||||
"M"
|
||||
);
|
||||
assertNotNull(encoded);
|
||||
assertTrue(encoded.getBytes(StandardCharsets.UTF_8).length <= ToolGuardButtonKey.MAX_KEY_BYTES,
|
||||
"950-char tool name must encode within 1024 bytes; got "
|
||||
+ encoded.getBytes(StandardCharsets.UTF_8).length);
|
||||
|
||||
// Push past the limit — must throw
|
||||
String over = "a".repeat(1100);
|
||||
assertThrows(CardOversizedException.class,
|
||||
() -> buttonKey.encode(ToolGuardButtonKey.Action.APPROVE, "x", over, "M"));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,198 @@
|
||||
package vip.mate.channel.wecom.cards.tool_guard;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mockito;
|
||||
import vip.mate.approval.ApprovalService;
|
||||
import vip.mate.approval.PendingApproval;
|
||||
import vip.mate.channel.ChannelMessage;
|
||||
import vip.mate.channel.wecom.WeComChannelAdapter;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* Tests for the validate-before-render invariant (RFC-32 v2.1 / R-5).
|
||||
*
|
||||
* <p>The earlier draft (v2.0) did "render resolved card → inject /approve →
|
||||
* router rejects unauthorized" — meaning a Lee click on Zhang's pending
|
||||
* would briefly show "✅ 已批准 by 李四" on the card before the router
|
||||
* silently dropped the command. v2.1 reorders to validate first, then
|
||||
* render the resolved card matching the validation result, then inject
|
||||
* the command only when authorized.
|
||||
*/
|
||||
class ToolGuardCardHandlerTest {
|
||||
|
||||
private ApprovalService approvalService;
|
||||
private WeComChannelAdapter adapter;
|
||||
private ToolGuardButtonKey buttonKey;
|
||||
private ToolGuardCardHandler handler;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
approvalService = Mockito.mock(ApprovalService.class);
|
||||
adapter = Mockito.mock(WeComChannelAdapter.class);
|
||||
buttonKey = new ToolGuardButtonKey(new ObjectMapper());
|
||||
handler = new ToolGuardCardHandler(approvalService, buttonKey);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("unauthorized click renders 'unauthorized' card and does NOT inject command")
|
||||
void unauthorizedClickDoesNotInject() {
|
||||
// Given: a pending whose original requester is "alice"
|
||||
PendingApproval pending = pendingFor("pid_xyz", "alice", "shell_exec");
|
||||
when(approvalService.getPending("pid_xyz")).thenReturn(Optional.of(pending));
|
||||
|
||||
// When: bob (NOT alice) clicks "approve"
|
||||
Map<String, Object> frame = inboundFrame("evt_req_1", buttonKey.encode(
|
||||
ToolGuardButtonKey.Action.APPROVE, "pid_xyz", "shell_exec", "HIGH"));
|
||||
handler.handle(adapter, frame, tce(frame), fromBlock("bob"));
|
||||
|
||||
// Then: card was updated to "unauthorized" state…
|
||||
ArgumentCaptor<Map<String, Object>> cardCaptor = cardArgCaptor();
|
||||
verify(adapter, times(1)).updateTemplateCard(eq("evt_req_1"), cardCaptor.capture());
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> mainTitle = (Map<String, Object>) cardCaptor.getValue().get("main_title");
|
||||
assertNotNull(mainTitle);
|
||||
String title = (String) mainTitle.get("title");
|
||||
assertTrue(title.contains("仅原请求者"),
|
||||
"unauthorized card must say '仅原请求者可审批'; got: " + title);
|
||||
|
||||
// …and CRITICALLY, no /approve command was injected
|
||||
verify(adapter, never()).injectSyntheticMessage(any(ChannelMessage.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("expired pending renders 'expired' card and does NOT inject command")
|
||||
void expiredPendingShowsExpiredCard() {
|
||||
when(approvalService.getPending("pid_old")).thenReturn(Optional.empty());
|
||||
|
||||
Map<String, Object> frame = inboundFrame("evt_req_2", buttonKey.encode(
|
||||
ToolGuardButtonKey.Action.APPROVE, "pid_old", "shell_exec", "MEDIUM"));
|
||||
handler.handle(adapter, frame, tce(frame), fromBlock("alice"));
|
||||
|
||||
ArgumentCaptor<Map<String, Object>> cardCaptor = cardArgCaptor();
|
||||
verify(adapter, times(1)).updateTemplateCard(eq("evt_req_2"), cardCaptor.capture());
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> mainTitle = (Map<String, Object>) cardCaptor.getValue().get("main_title");
|
||||
assertTrue(((String) mainTitle.get("title")).contains("过期"),
|
||||
"expired card title must mention 过期; got: " + mainTitle.get("title"));
|
||||
verify(adapter, never()).injectSyntheticMessage(any(ChannelMessage.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("authorized approve click: render resolved card AND inject /approve")
|
||||
void authorizedApproveInjectsCommand() {
|
||||
PendingApproval pending = pendingFor("pid_ok", "alice", "shell_exec");
|
||||
when(approvalService.getPending("pid_ok")).thenReturn(Optional.of(pending));
|
||||
|
||||
Map<String, Object> frame = inboundFrame("evt_req_3", buttonKey.encode(
|
||||
ToolGuardButtonKey.Action.APPROVE, "pid_ok", "shell_exec", "HIGH"));
|
||||
handler.handle(adapter, frame, tce(frame), fromBlock("alice"));
|
||||
|
||||
ArgumentCaptor<Map<String, Object>> cardCaptor = cardArgCaptor();
|
||||
verify(adapter, times(1)).updateTemplateCard(eq("evt_req_3"), cardCaptor.capture());
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> mainTitle = (Map<String, Object>) cardCaptor.getValue().get("main_title");
|
||||
assertTrue(((String) mainTitle.get("title")).contains("已批准"),
|
||||
"title must announce success; got: " + mainTitle.get("title"));
|
||||
|
||||
// Synthetic command should be injected with the right text
|
||||
ArgumentCaptor<ChannelMessage> msgCaptor = ArgumentCaptor.forClass(ChannelMessage.class);
|
||||
verify(adapter, times(1)).injectSyntheticMessage(msgCaptor.capture());
|
||||
ChannelMessage injected = msgCaptor.getValue();
|
||||
assertEquals("/approve pid_ok", injected.getContent());
|
||||
assertEquals("alice", injected.getSenderId());
|
||||
assertEquals("text", injected.getContentType());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("authorized deny click: injects /deny")
|
||||
void authorizedDenyInjectsCommand() {
|
||||
PendingApproval pending = pendingFor("pid_d", "alice", "shell_exec");
|
||||
when(approvalService.getPending("pid_d")).thenReturn(Optional.of(pending));
|
||||
|
||||
Map<String, Object> frame = inboundFrame("evt_req_4", buttonKey.encode(
|
||||
ToolGuardButtonKey.Action.DENY, "pid_d", "shell_exec", "HIGH"));
|
||||
handler.handle(adapter, frame, tce(frame), fromBlock("alice"));
|
||||
|
||||
ArgumentCaptor<ChannelMessage> msgCaptor = ArgumentCaptor.forClass(ChannelMessage.class);
|
||||
verify(adapter, times(1)).injectSyntheticMessage(msgCaptor.capture());
|
||||
assertEquals("/deny pid_d", msgCaptor.getValue().getContent());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("system-owned pending allows ANY clicker (no original requester)")
|
||||
void systemPendingAcceptsAnyClicker() {
|
||||
PendingApproval pending = pendingFor("pid_sys", "system", "shell_exec");
|
||||
when(approvalService.getPending("pid_sys")).thenReturn(Optional.of(pending));
|
||||
|
||||
Map<String, Object> frame = inboundFrame("evt_req_5", buttonKey.encode(
|
||||
ToolGuardButtonKey.Action.APPROVE, "pid_sys", "shell_exec", "MEDIUM"));
|
||||
handler.handle(adapter, frame, tce(frame), fromBlock("anyone"));
|
||||
|
||||
verify(adapter).injectSyntheticMessage(any(ChannelMessage.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("malformed event_key drops the event silently — no card update, no command")
|
||||
void malformedEventKeyIgnored() {
|
||||
Map<String, Object> frame = inboundFrame("evt_req_6", "{not json");
|
||||
handler.handle(adapter, frame, tce(frame), fromBlock("alice"));
|
||||
|
||||
verify(adapter, never()).updateTemplateCard(anyString(), any());
|
||||
verify(adapter, never()).injectSyntheticMessage(any(ChannelMessage.class));
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
private static PendingApproval pendingFor(String pendingId, String requester, String tool) {
|
||||
PendingApproval p = new PendingApproval(
|
||||
pendingId, "wecom:alice", requester, tool, "{}", "test approval");
|
||||
// Status defaults to "pending" via the constructor
|
||||
return p;
|
||||
}
|
||||
|
||||
private static Map<String, Object> inboundFrame(String reqId, String eventKey) {
|
||||
return Map.of(
|
||||
"cmd", "aibot_event_callback",
|
||||
"headers", Map.of("req_id", reqId),
|
||||
"body", Map.of(
|
||||
"chattype", "single",
|
||||
"chatid", "alice",
|
||||
"from", Map.of("userid", "alice"),
|
||||
"event", Map.of(
|
||||
"eventtype", "template_card_event",
|
||||
"template_card_event", Map.of(
|
||||
"task_id", "tg_approval_pid_xyz",
|
||||
"event_key", eventKey
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static Map<String, Object> tce(Map<String, Object> frame) {
|
||||
Map<String, Object> body = (Map<String, Object>) frame.get("body");
|
||||
Map<String, Object> event = (Map<String, Object>) body.get("event");
|
||||
return (Map<String, Object>) event.get("template_card_event");
|
||||
}
|
||||
|
||||
private static Map<String, Object> fromBlock(String userid) {
|
||||
return Map.of("userid", userid);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static ArgumentCaptor<Map<String, Object>> cardArgCaptor() {
|
||||
return (ArgumentCaptor<Map<String, Object>>) (ArgumentCaptor<?>) ArgumentCaptor.forClass(Map.class);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,104 @@
|
||||
package vip.mate.channel.wecom.cards.tool_guard;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import vip.mate.channel.notification.ApprovalNotice;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* Pin the WeCom button_interaction approval card payload shape.
|
||||
*
|
||||
* <p>The structure is server-validated — any drift (rename a field,
|
||||
* change button_list location, omit task_id prefix) silently fails on
|
||||
* the WeCom side at runtime. These tests catch that at compile-test
|
||||
* time so renames don't ship without protocol awareness.
|
||||
*/
|
||||
class ToolGuardCardRendererTest {
|
||||
|
||||
private final ToolGuardButtonKey buttonKey = new ToolGuardButtonKey(new ObjectMapper());
|
||||
private final ToolGuardCardRenderer renderer = new ToolGuardCardRenderer(buttonKey);
|
||||
|
||||
@Test
|
||||
@DisplayName("approval card has the WeCom button_interaction shape")
|
||||
@SuppressWarnings("unchecked")
|
||||
void approvalCardShape() {
|
||||
ApprovalNotice notice = new ApprovalNotice(
|
||||
"abc12345def67890",
|
||||
"shell_exec",
|
||||
"Run system command",
|
||||
"rm -rf /tmp/cache",
|
||||
"HIGH",
|
||||
List.of(),
|
||||
"/approve abc",
|
||||
"/deny abc"
|
||||
);
|
||||
|
||||
Map<String, Object> card = renderer.render(notice);
|
||||
|
||||
assertEquals("button_interaction", card.get("card_type"));
|
||||
assertEquals("tg_approval_abc12345def67890", card.get("task_id"),
|
||||
"task_id must carry the tg_approval_ prefix so the inbound dispatcher can route the click");
|
||||
|
||||
Map<String, Object> mainTitle = (Map<String, Object>) card.get("main_title");
|
||||
assertNotNull(mainTitle);
|
||||
assertEquals("🛡️ 工具审批", mainTitle.get("title"));
|
||||
String desc = (String) mainTitle.get("desc");
|
||||
assertTrue(desc.contains("shell_exec"), "subtitle must include tool name; got: " + desc);
|
||||
|
||||
List<Map<String, Object>> buttons = (List<Map<String, Object>>) card.get("button_list");
|
||||
assertNotNull(buttons);
|
||||
assertEquals(2, buttons.size());
|
||||
|
||||
Map<String, Object> approve = buttons.get(0);
|
||||
assertEquals("批准", approve.get("text"));
|
||||
assertEquals(1, approve.get("style"));
|
||||
String approveKey = (String) approve.get("key");
|
||||
ToolGuardButtonKey.Decoded a = buttonKey.decode(approveKey);
|
||||
assertNotNull(a);
|
||||
assertEquals(ToolGuardButtonKey.Action.APPROVE, a.action());
|
||||
assertEquals("abc12345def67890", a.pendingId());
|
||||
|
||||
Map<String, Object> deny = buttons.get(1);
|
||||
assertEquals("拒绝", deny.get("text"));
|
||||
assertEquals(2, deny.get("style"));
|
||||
ToolGuardButtonKey.Decoded d = buttonKey.decode((String) deny.get("key"));
|
||||
assertNotNull(d);
|
||||
assertEquals(ToolGuardButtonKey.Action.DENY, d.action());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("resolved card uses text_notice + carries non-zero card_action.type")
|
||||
@SuppressWarnings("unchecked")
|
||||
void resolvedCardShape() {
|
||||
Map<String, Object> resolved = ToolGuardCardRenderer.buildResolvedCard(
|
||||
"tg_approval_abc", "✅ 已批准", "Tool x 已批准 by 张三");
|
||||
|
||||
assertEquals("text_notice", resolved.get("card_type"));
|
||||
assertEquals("tg_approval_abc", resolved.get("task_id"));
|
||||
|
||||
Map<String, Object> cardAction = (Map<String, Object>) resolved.get("card_action");
|
||||
assertNotNull(cardAction, "WeCom rejects text_notice cards without card_action");
|
||||
assertEquals(1, cardAction.get("type"),
|
||||
"card_action.type must be 1 or 2; type=0 is rejected by the bot endpoint");
|
||||
assertNotNull(cardAction.get("url"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("resolved card truncates over-long desc to ~30 chars + ellipsis")
|
||||
@SuppressWarnings("unchecked")
|
||||
void resolvedDescTruncated() {
|
||||
String longDesc = "a".repeat(100);
|
||||
Map<String, Object> resolved = ToolGuardCardRenderer.buildResolvedCard(
|
||||
"tg_approval_x", "✅", longDesc);
|
||||
|
||||
Map<String, Object> mainTitle = (Map<String, Object>) resolved.get("main_title");
|
||||
String desc = (String) mainTitle.get("desc");
|
||||
assertTrue(desc.length() <= 30, "desc must be ≤30 chars after truncation, got " + desc.length());
|
||||
assertTrue(desc.endsWith("…"), "truncation marker must be present; got: " + desc);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,124 @@
|
||||
package vip.mate.cron.config;
|
||||
|
||||
import net.javacrumbs.shedlock.core.LockConfiguration;
|
||||
import net.javacrumbs.shedlock.core.LockProvider;
|
||||
import net.javacrumbs.shedlock.core.SimpleLock;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.test.context.TestPropertySource;
|
||||
import vip.mate.MateClawApplication;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* RFC-03 Lane G2 integration test — exercises the full path:
|
||||
*
|
||||
* <ol>
|
||||
* <li>Flyway migration {@code V74__shedlock_table.sql} ran successfully
|
||||
* against the in-memory H2 (otherwise context startup would fail).</li>
|
||||
* <li>{@link ShedLockConfig} wired a {@link LockProvider} bean.</li>
|
||||
* <li>The provider's lock/unlock semantics actually exclude concurrent
|
||||
* holders — i.e. node-A → node-B contention works as expected.</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>Single-node deployments hit only the trivial path (acquire from this
|
||||
* JVM always succeeds), so a CI test that only exercises one acquirer
|
||||
* would miss the multi-node behavior we actually shipped this for.
|
||||
* Simulating two nodes against the same H2 database catches the
|
||||
* contention path.
|
||||
*/
|
||||
@SpringBootTest(
|
||||
classes = MateClawApplication.class,
|
||||
webEnvironment = SpringBootTest.WebEnvironment.NONE
|
||||
)
|
||||
@TestPropertySource(properties = {
|
||||
"spring.datasource.url=jdbc:h2:mem:shedlock_test_${random.uuid};MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE;DB_CLOSE_DELAY=-1",
|
||||
"spring.ai.dashscope.api-key=test-key",
|
||||
"spring.main.web-application-type=none"
|
||||
})
|
||||
class ShedLockIntegrationTest {
|
||||
|
||||
@Autowired
|
||||
private LockProvider lockProvider;
|
||||
|
||||
@Autowired
|
||||
private JdbcTemplate jdbcTemplate;
|
||||
|
||||
@Test
|
||||
@DisplayName("V74 created the shedlock table with the expected columns")
|
||||
void shedlockTableExists() {
|
||||
// information_schema lookup works on H2 MySQL-mode and on MySQL itself.
|
||||
Long count = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM information_schema.tables WHERE table_name = 'shedlock'",
|
||||
Long.class);
|
||||
assertNotNull(count);
|
||||
assertEquals(1L, count, "shedlock table should be created by V74");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("acquire then release lets a sibling acquire immediately")
|
||||
void acquireAndRelease() {
|
||||
String name = "test-lock-acquire-release";
|
||||
// First node — acquires.
|
||||
Optional<SimpleLock> a = lockProvider.lock(new LockConfiguration(
|
||||
Instant.now(), name, Duration.ofMinutes(5), Duration.ZERO));
|
||||
assertTrue(a.isPresent(), "first acquirer should succeed");
|
||||
|
||||
// Sibling tries while A holds it — must be excluded.
|
||||
Optional<SimpleLock> b = lockProvider.lock(new LockConfiguration(
|
||||
Instant.now(), name, Duration.ofMinutes(5), Duration.ZERO));
|
||||
assertFalse(b.isPresent(), "second acquirer should be blocked while first holds the lock");
|
||||
|
||||
// A releases.
|
||||
a.get().unlock();
|
||||
|
||||
// Sibling tries again — should now succeed.
|
||||
Optional<SimpleLock> c = lockProvider.lock(new LockConfiguration(
|
||||
Instant.now(), name, Duration.ofMinutes(5), Duration.ZERO));
|
||||
assertTrue(c.isPresent(), "third acquirer should succeed after release");
|
||||
c.get().unlock();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("different lock names are independent — two jobs both proceed")
|
||||
void independentLocks() {
|
||||
Optional<SimpleLock> jobA = lockProvider.lock(new LockConfiguration(
|
||||
Instant.now(), "cron-job-A", Duration.ofMinutes(5), Duration.ZERO));
|
||||
Optional<SimpleLock> jobB = lockProvider.lock(new LockConfiguration(
|
||||
Instant.now(), "cron-job-B", Duration.ofMinutes(5), Duration.ZERO));
|
||||
|
||||
assertTrue(jobA.isPresent());
|
||||
assertTrue(jobB.isPresent(),
|
||||
"different lock names must not block each other — multi-job parallelism is the whole point");
|
||||
|
||||
jobA.get().unlock();
|
||||
jobB.get().unlock();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("lockAtLeastFor prevents instant re-acquire by the same caller")
|
||||
void lockAtLeastForHonored() {
|
||||
String name = "test-lock-at-least";
|
||||
// Hold the lock for at least 2 seconds even if we release immediately.
|
||||
Optional<SimpleLock> first = lockProvider.lock(new LockConfiguration(
|
||||
Instant.now(), name, Duration.ofMinutes(5), Duration.ofSeconds(2)));
|
||||
assertTrue(first.isPresent());
|
||||
first.get().unlock(); // unlock returns, but lockAtLeastFor still applies
|
||||
|
||||
// Immediate re-acquire should fail because lockAtLeastFor=2s hasn't elapsed.
|
||||
Optional<SimpleLock> second = lockProvider.lock(new LockConfiguration(
|
||||
Instant.now(), name, Duration.ofMinutes(5), Duration.ZERO));
|
||||
assertFalse(second.isPresent(),
|
||||
"lockAtLeastFor must keep the entry inaccessible for its duration even after unlock");
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,176 @@
|
||||
package vip.mate.cron.delivery;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.conditions.Wrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.ai.chat.messages.AssistantMessage;
|
||||
import vip.mate.cron.model.CronJobEntity;
|
||||
import vip.mate.dashboard.model.CronJobRunEntity;
|
||||
import vip.mate.dashboard.repository.CronJobRunMapper;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.stream.IntStream;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* RFC-063r §2.6.1: Template-Method invariants — SQL CAS claim, marker
|
||||
* methods after success / failure, exception propagation.
|
||||
*/
|
||||
class AbstractCronResultDeliveryTest {
|
||||
|
||||
private CronJobRunMapper runMapper;
|
||||
private CronJobEntity job;
|
||||
private CronJobRunEntity run;
|
||||
|
||||
/**
|
||||
* Pre-warm MyBatis Plus's lambda → column cache. Without this the
|
||||
* production code's {@code new LambdaUpdateWrapper<CronJobRunEntity>()}
|
||||
* throws "can not find lambda cache" — the cache is normally populated
|
||||
* during Spring context init, which we skip in unit tests.
|
||||
*/
|
||||
@BeforeAll
|
||||
static void initMpLambdaCache() {
|
||||
MybatisConfiguration cfg = new MybatisConfiguration();
|
||||
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(cfg, ""), CronJobRunEntity.class);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
runMapper = mock(CronJobRunMapper.class);
|
||||
job = new CronJobEntity();
|
||||
job.setId(1L);
|
||||
run = new CronJobRunEntity();
|
||||
run.setId(42L);
|
||||
run.setStatus("succeeded");
|
||||
}
|
||||
|
||||
@Test
|
||||
void deliver_claimsSuccessfully_marksDelivered() {
|
||||
// First update = the claim CAS, returns 1 (won the race)
|
||||
// Second update = the markDelivered, returns 1
|
||||
when(runMapper.update(any(), any(Wrapper.class))).thenReturn(1, 1);
|
||||
|
||||
AbstractCronResultDelivery strategy = new AbstractCronResultDelivery(runMapper) {
|
||||
@Override public boolean supports(CronJobEntity j) { return true; }
|
||||
@Override
|
||||
protected DeliveryOutcome doDeliver(CronJobEntity j, AssistantMessage r, CronJobRunEntity run) {
|
||||
return DeliveryOutcome.delivered("user-x");
|
||||
}
|
||||
};
|
||||
|
||||
DeliveryOutcome outcome = strategy.deliver(job, new AssistantMessage("hi"), run);
|
||||
|
||||
assertEquals(DeliveryOutcome.Status.DELIVERED, outcome.status());
|
||||
assertEquals("user-x", outcome.target());
|
||||
verify(runMapper, times(2)).update(any(), any(Wrapper.class)); // claim + markDelivered
|
||||
}
|
||||
|
||||
@Test
|
||||
void deliver_claimAlreadyTaken_returnsSkippedAndDoesNotInvokeDoDeliver() {
|
||||
// Claim returns 0 → another listener already won the CAS
|
||||
when(runMapper.update(any(), any(Wrapper.class))).thenReturn(0);
|
||||
|
||||
AtomicReference<Boolean> doDeliverInvoked = new AtomicReference<>(false);
|
||||
AbstractCronResultDelivery strategy = new AbstractCronResultDelivery(runMapper) {
|
||||
@Override public boolean supports(CronJobEntity j) { return true; }
|
||||
@Override
|
||||
protected DeliveryOutcome doDeliver(CronJobEntity j, AssistantMessage r, CronJobRunEntity run) {
|
||||
doDeliverInvoked.set(true);
|
||||
return DeliveryOutcome.delivered("never");
|
||||
}
|
||||
};
|
||||
|
||||
DeliveryOutcome outcome = strategy.deliver(job, new AssistantMessage("hi"), run);
|
||||
|
||||
assertEquals(DeliveryOutcome.Status.SKIPPED, outcome.status());
|
||||
assertEquals("already-claimed-by-other-instance", outcome.reason());
|
||||
assertFalse(doDeliverInvoked.get(), "doDeliver must not run after a failed CAS claim");
|
||||
verify(runMapper, times(1)).update(any(), any(Wrapper.class)); // only the failed claim
|
||||
}
|
||||
|
||||
@Test
|
||||
void deliver_doDeliverThrows_marksNotDeliveredAndRethrows() {
|
||||
// Claim returns 1, then markNotDelivered returns 1
|
||||
when(runMapper.update(any(), any(Wrapper.class))).thenReturn(1, 1);
|
||||
|
||||
RuntimeException oops = new RuntimeException("Slack 503 Service Unavailable");
|
||||
AbstractCronResultDelivery strategy = new AbstractCronResultDelivery(runMapper) {
|
||||
@Override public boolean supports(CronJobEntity j) { return true; }
|
||||
@Override
|
||||
protected DeliveryOutcome doDeliver(CronJobEntity j, AssistantMessage r, CronJobRunEntity run) {
|
||||
throw oops;
|
||||
}
|
||||
};
|
||||
|
||||
RuntimeException thrown = assertThrows(RuntimeException.class,
|
||||
() -> strategy.deliver(job, new AssistantMessage("hi"), run));
|
||||
assertSame(oops, thrown, "exception must propagate verbatim so the listener can audit it");
|
||||
verify(runMapper, times(2)).update(any(), any(Wrapper.class)); // claim + markNotDelivered
|
||||
}
|
||||
|
||||
@Test
|
||||
void claimRun_concurrentInvocations_onlyOneSucceeds() throws Exception {
|
||||
// Simulates the cluster scenario: the SQL CAS guarantees exactly one
|
||||
// listener instance wins. Mock the mapper so the FIRST update() call
|
||||
// returns 1, all subsequent return 0 — matches DB semantics.
|
||||
Set<Integer> winnerThreadIds = java.util.Collections.synchronizedSet(new HashSet<>());
|
||||
AtomicReference<Boolean> firstClaim = new AtomicReference<>(true);
|
||||
when(runMapper.update(any(), any(Wrapper.class))).thenAnswer(inv -> {
|
||||
// First caller wins, others lose
|
||||
return firstClaim.compareAndSet(true, false) ? 1 : 0;
|
||||
});
|
||||
|
||||
AbstractCronResultDelivery strategy = new AbstractCronResultDelivery(runMapper) {
|
||||
@Override public boolean supports(CronJobEntity j) { return true; }
|
||||
@Override
|
||||
protected DeliveryOutcome doDeliver(CronJobEntity j, AssistantMessage r, CronJobRunEntity run) {
|
||||
winnerThreadIds.add((int) Thread.currentThread().threadId());
|
||||
return DeliveryOutcome.delivered("winner");
|
||||
}
|
||||
};
|
||||
|
||||
int threadCount = 8;
|
||||
CountDownLatch start = new CountDownLatch(1);
|
||||
var pool = Executors.newFixedThreadPool(threadCount);
|
||||
try {
|
||||
var futures = IntStream.range(0, threadCount).mapToObj(i -> pool.submit(() -> {
|
||||
start.await();
|
||||
return strategy.deliver(job, new AssistantMessage("hi"), run);
|
||||
})).toList();
|
||||
start.countDown();
|
||||
|
||||
int delivered = 0;
|
||||
int skipped = 0;
|
||||
for (var f : futures) {
|
||||
try {
|
||||
DeliveryOutcome o = f.get();
|
||||
if (o.status() == DeliveryOutcome.Status.DELIVERED) delivered++;
|
||||
else skipped++;
|
||||
} catch (ExecutionException ignored) {
|
||||
// doDeliver throws are OK; counted as not-delivered
|
||||
}
|
||||
}
|
||||
|
||||
assertEquals(1, delivered,
|
||||
"Exactly one winner under concurrent claim — RFC-063r §2.6.1 invariant");
|
||||
assertEquals(threadCount - 1, skipped, "All others must observe SKIPPED");
|
||||
assertEquals(1, winnerThreadIds.size(),
|
||||
"doDeliver must execute on exactly one thread");
|
||||
} finally {
|
||||
pool.shutdownNow();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,117 @@
|
||||
package vip.mate.cron.delivery;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.conditions.Wrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.ai.chat.messages.AssistantMessage;
|
||||
import vip.mate.channel.ChannelManager;
|
||||
import vip.mate.channel.DeliveryOptions;
|
||||
import vip.mate.cron.model.CronJobEntity;
|
||||
import vip.mate.cron.model.DeliveryConfig;
|
||||
import vip.mate.dashboard.model.CronJobRunEntity;
|
||||
import vip.mate.dashboard.repository.CronJobRunMapper;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* RFC-063r §2.6: ChannelCronResultDelivery dispatch contract.
|
||||
*/
|
||||
class ChannelCronResultDeliveryTest {
|
||||
|
||||
private CronJobRunMapper runMapper;
|
||||
private ChannelManager channelManager;
|
||||
private ChannelCronResultDelivery strategy;
|
||||
|
||||
@BeforeAll
|
||||
static void initMpLambdaCache() {
|
||||
MybatisConfiguration cfg = new MybatisConfiguration();
|
||||
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(cfg, ""), CronJobRunEntity.class);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
runMapper = mock(CronJobRunMapper.class);
|
||||
channelManager = mock(ChannelManager.class);
|
||||
when(runMapper.update(any(), any(Wrapper.class))).thenReturn(1);
|
||||
strategy = new ChannelCronResultDelivery(runMapper, channelManager);
|
||||
}
|
||||
|
||||
@Test
|
||||
void supports_channelIdNull_returnsFalse() {
|
||||
CronJobEntity job = new CronJobEntity();
|
||||
job.setChannelId(null);
|
||||
job.setDeliveryConfig(new DeliveryConfig("u", null, null));
|
||||
assertFalse(strategy.supports(job),
|
||||
"web-origin runs (no channelId) must not match the channel strategy");
|
||||
}
|
||||
|
||||
@Test
|
||||
void supports_targetIdNull_returnsFalse() {
|
||||
CronJobEntity job = new CronJobEntity();
|
||||
job.setChannelId(9L);
|
||||
job.setDeliveryConfig(new DeliveryConfig(null, "thread-1", null));
|
||||
assertFalse(strategy.supports(job),
|
||||
"channel binding without targetId must not deliver");
|
||||
}
|
||||
|
||||
@Test
|
||||
void supports_targetIdBlank_returnsFalse() {
|
||||
CronJobEntity job = new CronJobEntity();
|
||||
job.setChannelId(9L);
|
||||
job.setDeliveryConfig(new DeliveryConfig(" ", null, null));
|
||||
assertFalse(strategy.supports(job),
|
||||
"blank targetId must be treated as missing");
|
||||
}
|
||||
|
||||
@Test
|
||||
void supports_channelAndTargetSet_returnsTrue() {
|
||||
CronJobEntity job = new CronJobEntity();
|
||||
job.setChannelId(9L);
|
||||
job.setDeliveryConfig(new DeliveryConfig("user-7", null, null));
|
||||
assertTrue(strategy.supports(job));
|
||||
}
|
||||
|
||||
@Test
|
||||
void doDeliver_callsChannelManagerWithDeliveryOptions() {
|
||||
CronJobEntity job = new CronJobEntity();
|
||||
job.setChannelId(9L);
|
||||
job.setDeliveryConfig(new DeliveryConfig("user-7", "thread-abc", "bot-001"));
|
||||
CronJobRunEntity run = new CronJobRunEntity();
|
||||
run.setId(42L);
|
||||
|
||||
DeliveryOutcome outcome = strategy.deliver(job, new AssistantMessage("Daily summary"), run);
|
||||
|
||||
assertEquals(DeliveryOutcome.Status.DELIVERED, outcome.status());
|
||||
assertEquals("user-7", outcome.target());
|
||||
verify(channelManager).sendToChannel(eq(9L), eq("user-7"), any(String.class),
|
||||
argThat(opts -> "thread-abc".equals(opts.threadId())
|
||||
&& "bot-001".equals(opts.accountId())));
|
||||
}
|
||||
|
||||
@Test
|
||||
void doDeliver_adapterDisabled_propagatesIllegalStateAndMarksNotDelivered() {
|
||||
CronJobEntity job = new CronJobEntity();
|
||||
job.setChannelId(9L);
|
||||
job.setDeliveryConfig(new DeliveryConfig("user-7", null, null));
|
||||
CronJobRunEntity run = new CronJobRunEntity();
|
||||
run.setId(42L);
|
||||
|
||||
// Simulate channel adapter unavailable — ChannelManager throws.
|
||||
IllegalStateException disabled = new IllegalStateException("Channel not active: 9");
|
||||
doThrow(disabled).when(channelManager)
|
||||
.sendToChannel(eq(9L), eq("user-7"), any(String.class), any(DeliveryOptions.class));
|
||||
|
||||
IllegalStateException thrown = assertThrows(IllegalStateException.class,
|
||||
() -> strategy.deliver(job, new AssistantMessage("hi"), run));
|
||||
assertSame(disabled, thrown);
|
||||
// Two updates: claim + markNotDelivered
|
||||
verify(runMapper, times(2)).update(any(), any(Wrapper.class));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,96 @@
|
||||
package vip.mate.cron.model;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import vip.mate.agent.context.ChannelTarget;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* RFC-063r §2.9: DeliveryConfig must round-trip through Jackson cleanly so
|
||||
* MyBatis Plus JacksonTypeHandler can persist + restore it on
|
||||
* {@code mate_cron_job.delivery_config}.
|
||||
*/
|
||||
class DeliveryConfigTest {
|
||||
|
||||
@Test
|
||||
void from_nullChannelTarget_returnsNull() {
|
||||
assertNull(DeliveryConfig.from(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void roundTripThroughChannelTarget() {
|
||||
ChannelTarget t = new ChannelTarget("user-1", "thread-a", "bot-x");
|
||||
DeliveryConfig dc = DeliveryConfig.from(t);
|
||||
assertEquals(t, dc.toChannelTarget());
|
||||
}
|
||||
|
||||
@Test
|
||||
void jsonRoundTrip_preservesAllFields() throws Exception {
|
||||
ObjectMapper om = new ObjectMapper();
|
||||
DeliveryConfig original = new DeliveryConfig("user-1", "thread-a", "bot-x");
|
||||
String json = om.writeValueAsString(original);
|
||||
DeliveryConfig restored = om.readValue(json, DeliveryConfig.class);
|
||||
assertEquals(original, restored);
|
||||
}
|
||||
|
||||
@Test
|
||||
void jsonDeserialize_unknownFieldsAreIgnored() throws Exception {
|
||||
ObjectMapper om = new ObjectMapper();
|
||||
String json = "{\"targetId\":\"u\",\"threadId\":null,\"accountId\":null,\"newFieldFromFuture\":\"y\"}";
|
||||
DeliveryConfig dc = om.readValue(json, DeliveryConfig.class);
|
||||
assertEquals("u", dc.targetId());
|
||||
}
|
||||
|
||||
// ── RFC-03 Lane C1: suppressAgentReply ─────────────────────────────────
|
||||
|
||||
@Test
|
||||
void suppressAgentReply_defaultsToFalse_legacyCtor3arg() {
|
||||
// Pre-RFC-03 callsite — no suppress arg means historical behavior.
|
||||
DeliveryConfig dc = new DeliveryConfig("u", null, null);
|
||||
assertFalse(dc.isAgentReplySuppressed());
|
||||
assertNull(dc.suppressAgentReply());
|
||||
}
|
||||
|
||||
@Test
|
||||
void suppressAgentReply_defaultsToFalse_legacyCtor4arg() {
|
||||
// 4-arg legacy ctor (post-userId, pre-suppress).
|
||||
DeliveryConfig dc = new DeliveryConfig("u", null, null, "sender");
|
||||
assertFalse(dc.isAgentReplySuppressed());
|
||||
assertNull(dc.suppressAgentReply());
|
||||
}
|
||||
|
||||
@Test
|
||||
void suppressAgentReply_explicitFalseStillDelivers() {
|
||||
DeliveryConfig dc = new DeliveryConfig("u", null, null, null, Boolean.FALSE);
|
||||
assertFalse(dc.isAgentReplySuppressed(),
|
||||
"explicit FALSE must be treated identically to null — both deliver");
|
||||
}
|
||||
|
||||
@Test
|
||||
void suppressAgentReply_trueShortCircuits() {
|
||||
DeliveryConfig dc = new DeliveryConfig("u", null, null, null, Boolean.TRUE);
|
||||
assertTrue(dc.isAgentReplySuppressed());
|
||||
}
|
||||
|
||||
@Test
|
||||
void suppressAgentReply_jsonRoundTrip() throws Exception {
|
||||
ObjectMapper om = new ObjectMapper();
|
||||
DeliveryConfig original = new DeliveryConfig("u", "t", "a", "sender", Boolean.TRUE);
|
||||
String json = om.writeValueAsString(original);
|
||||
DeliveryConfig restored = om.readValue(json, DeliveryConfig.class);
|
||||
assertEquals(original, restored);
|
||||
assertTrue(restored.isAgentReplySuppressed());
|
||||
}
|
||||
|
||||
@Test
|
||||
void suppressAgentReply_preV75JsonRow_treatedAsFalse() throws Exception {
|
||||
// Rows persisted before V75 don't have suppressAgentReply at all —
|
||||
// round-trip must surface as null and isAgentReplySuppressed=false.
|
||||
ObjectMapper om = new ObjectMapper();
|
||||
String legacyJson = "{\"targetId\":\"u\",\"threadId\":\"t\",\"accountId\":\"a\",\"userId\":\"sender\"}";
|
||||
DeliveryConfig dc = om.readValue(legacyJson, DeliveryConfig.class);
|
||||
assertNull(dc.suppressAgentReply());
|
||||
assertFalse(dc.isAgentReplySuppressed());
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,45 @@
|
||||
package vip.mate.cron.service;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import vip.mate.agent.context.ChannelTarget;
|
||||
import vip.mate.agent.context.ChatOrigin;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* RFC-063r §2.13 (Issue #25 — second symptom):
|
||||
* {@link CronJobRunner#wrapWithDeliveryGuard} must prepend a system note
|
||||
* for channel-bound cron runs and pass through web-origin runs unchanged.
|
||||
*/
|
||||
class CronJobRunnerDeliveryGuardTest {
|
||||
|
||||
@Test
|
||||
void channelBoundCron_prependsDeliveryGuard() {
|
||||
ChatOrigin channelOrigin = new ChatOrigin(
|
||||
/* agentId */ 7L, "cron_7", "system", 1L, null,
|
||||
/* channelId */ 9L, new ChannelTarget("group-a", null, null));
|
||||
String input = "提醒我喝水并发到微信";
|
||||
String wrapped = CronJobRunner.wrapWithDeliveryGuard(input, channelOrigin);
|
||||
|
||||
assertTrue(wrapped.contains("[系统说明]"),
|
||||
"Channel-bound cron must include system note (RFC-063r §2.13)");
|
||||
assertTrue(wrapped.contains("不要尝试调用 CLI"),
|
||||
"system note must explicitly forbid CLI hallucination");
|
||||
assertTrue(wrapped.endsWith(input),
|
||||
"user message must be appended after the system note");
|
||||
}
|
||||
|
||||
@Test
|
||||
void webOriginCron_passesThroughUnchanged() {
|
||||
ChatOrigin webOrigin = ChatOrigin.web("cron_1", "system", 1L, null);
|
||||
String input = "Daily wiki update";
|
||||
assertEquals(input, CronJobRunner.wrapWithDeliveryGuard(input, webOrigin),
|
||||
"web-origin cron must keep pre-RFC behavior");
|
||||
}
|
||||
|
||||
@Test
|
||||
void emptyOrigin_passesThroughUnchanged() {
|
||||
assertEquals("hello", CronJobRunner.wrapWithDeliveryGuard("hello", ChatOrigin.EMPTY));
|
||||
assertEquals("hello", CronJobRunner.wrapWithDeliveryGuard("hello", null));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,108 @@
|
||||
package vip.mate.hook.action;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
import java.net.URI;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* RFC-03 Lane H1 — covers {@link HttpAction#hmacSign(String)} and the
|
||||
* default-header convention used to deliver outbound webhook signatures.
|
||||
*
|
||||
* <p>Validating the signature on the receiver side requires the digest to be:
|
||||
* <ol>
|
||||
* <li>computed over the exact bytes that were sent (no JSON re-encode),</li>
|
||||
* <li>formatted as {@code "sha256=<lowercase-hex>"} so off-the-shelf
|
||||
* GitHub-style validators work without changes,</li>
|
||||
* <li>deterministic — same secret + same body always yields the same
|
||||
* digest (no timestamp / nonce mixed in here).</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>The reference vector is from RFC 4231 §4.7 (HMAC-SHA-256 with the
|
||||
* canonical "Test 7" inputs) so any divergence from the standard surfaces
|
||||
* here, not in production.
|
||||
*/
|
||||
class HttpActionHmacTest {
|
||||
|
||||
/** Build an HttpAction with the given secret; restClient is a no-op stub
|
||||
* because hmacSign() doesn't touch it. */
|
||||
private static HttpAction action(String secret) {
|
||||
return new HttpAction(
|
||||
RestClient.builder().build(),
|
||||
"POST",
|
||||
URI.create("https://hooks.example.com/test"),
|
||||
null,
|
||||
List.of("hooks.example.com"),
|
||||
3000L,
|
||||
secret,
|
||||
null);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("hmacSign produces lowercase-hex 'sha256=<digest>' format")
|
||||
void formatIsGitHubCompatible() {
|
||||
String sig = action("secret").hmacSign("hello");
|
||||
assertTrue(sig.startsWith("sha256="),
|
||||
"header value must be sha256-prefixed for GitHub-compatible validators");
|
||||
// SHA-256 hex digest is 64 lowercase chars, no separators.
|
||||
String hex = sig.substring("sha256=".length());
|
||||
assertEquals(64, hex.length());
|
||||
assertTrue(hex.matches("[0-9a-f]+"),
|
||||
"digest must be lowercase hex; got: " + hex);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Wikipedia reference vector — known input → known digest")
|
||||
void referenceVector() {
|
||||
// From the canonical HMAC-SHA-256 worked example
|
||||
// (Wikipedia "HMAC" article — same input/output as Bruce Schneier's
|
||||
// applied-cryptography vector). Hardcoding the expected digest catches
|
||||
// any divergence from the JCA reference impl — e.g. if someone later
|
||||
// swaps in a third-party Mac or a Bouncy Castle provider that returns
|
||||
// a different byte order.
|
||||
String sig = action("key").hmacSign("The quick brown fox jumps over the lazy dog");
|
||||
assertEquals(
|
||||
"sha256=f7bc83f430538424b13298e6aa6fb143ef4d59a14946175997479dbc2d1a3cd8",
|
||||
sig);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("same secret + same body → identical digest (deterministic)")
|
||||
void deterministic() {
|
||||
HttpAction a = action("shared-secret-123");
|
||||
String first = a.hmacSign("{\"event\":\"agent.completed\"}");
|
||||
String second = a.hmacSign("{\"event\":\"agent.completed\"}");
|
||||
assertEquals(first, second);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("different secrets → different digests")
|
||||
void secretMattersForDigest() {
|
||||
String body = "{\"event\":\"x\"}";
|
||||
String s1 = action("secret-A").hmacSign(body);
|
||||
String s2 = action("secret-B").hmacSign(body);
|
||||
assertTrue(!s1.equals(s2),
|
||||
"swapping the secret must change the digest — otherwise signing is theatre");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("different body bytes → different digests")
|
||||
void bodyMattersForDigest() {
|
||||
HttpAction a = action("secret");
|
||||
String s1 = a.hmacSign("{\"a\":1}");
|
||||
String s2 = a.hmacSign("{\"a\":2}");
|
||||
assertTrue(!s1.equals(s2),
|
||||
"swapping a byte must change the digest — otherwise tampering goes undetected");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("default signature header constant matches MateClaw convention")
|
||||
void defaultHeaderName() {
|
||||
assertEquals("X-MateClaw-Signature", HttpAction.DEFAULT_SIGNATURE_HEADER);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,80 @@
|
||||
package vip.mate.i18n;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.ai.chat.model.ToolContext;
|
||||
import org.springframework.ai.tool.ToolCallback;
|
||||
import org.springframework.ai.tool.definition.ToolDefinition;
|
||||
import org.springframework.ai.tool.metadata.ToolMetadata;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* RFC-063r §2.3 (P0): regression guard — {@link LocaleAwareToolCallback} must
|
||||
* forward both the input string and the ToolContext to the wrapped callback.
|
||||
* Pre-fix this class only overrode {@code call(String)}, silently dropping the
|
||||
* context (and thus ChatOrigin) for every builtin tool.
|
||||
*/
|
||||
class LocaleAwareToolCallbackToolContextTest {
|
||||
|
||||
@Test
|
||||
void callWithToolContext_forwardsToDelegate() {
|
||||
RecordingDelegate delegate = new RecordingDelegate();
|
||||
LocaleAwareToolCallback decorator =
|
||||
new LocaleAwareToolCallback(delegate, "本地化描述");
|
||||
|
||||
ToolContext ctx = new ToolContext(Map.of("k", "v"));
|
||||
String out = decorator.call("{\"x\":1}", ctx);
|
||||
|
||||
assertEquals("ok", out);
|
||||
assertEquals("{\"x\":1}", delegate.lastInput);
|
||||
assertSame(ctx, delegate.lastContext,
|
||||
"ToolContext must reach the underlying tool unchanged");
|
||||
}
|
||||
|
||||
@Test
|
||||
void getToolMetadata_isForwardedSoReturnDirectIsPreserved() {
|
||||
ToolMetadata directMetadata = ToolMetadata.builder().returnDirect(true).build();
|
||||
RecordingDelegate delegate = new RecordingDelegate();
|
||||
delegate.metadata = directMetadata;
|
||||
|
||||
LocaleAwareToolCallback decorator = new LocaleAwareToolCallback(delegate, "本地化描述");
|
||||
assertSame(directMetadata, decorator.getToolMetadata(),
|
||||
"decorator must not flip returnDirect by inheriting the framework default");
|
||||
}
|
||||
|
||||
private static final class RecordingDelegate implements ToolCallback {
|
||||
String lastInput;
|
||||
ToolContext lastContext;
|
||||
ToolMetadata metadata = ToolMetadata.builder().build();
|
||||
|
||||
@Override
|
||||
public ToolDefinition getToolDefinition() {
|
||||
return ToolDefinition.builder()
|
||||
.name("recording-tool")
|
||||
.description("...")
|
||||
.inputSchema("{}")
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public ToolMetadata getToolMetadata() {
|
||||
return metadata;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String call(String toolInput) {
|
||||
this.lastInput = toolInput;
|
||||
this.lastContext = null;
|
||||
return "ok";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String call(String toolInput, ToolContext toolContext) {
|
||||
this.lastInput = toolInput;
|
||||
this.lastContext = toolContext;
|
||||
return "ok";
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,79 @@
|
||||
package vip.mate.llm.anthropic.oauth;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* Header-construction guarantees for OAuth-authenticated Anthropic requests.
|
||||
*
|
||||
* <p>The two non-negotiable invariants Anthropic's edge enforces:
|
||||
* <ol>
|
||||
* <li>{@code anthropic-beta} must contain both {@code claude-code-20250219}
|
||||
* AND {@code oauth-2025-04-20}, comma-joined (no spaces).</li>
|
||||
* <li>{@code User-Agent} must be the bare {@code claude-cli/<ver>} —
|
||||
* NOT {@code claude-cli/<ver> (external, cli)}. The {@code (external, cli)}
|
||||
* suffix is what hermes-agent and other third-party clients append, and
|
||||
* Anthropic uses it as a fingerprint to rate-limit the anti-abuse path.
|
||||
* Real Claude Code emits the bare form via the official JS SDK.</li>
|
||||
* </ol>
|
||||
*/
|
||||
class ClaudeCodeApiHeadersTest {
|
||||
|
||||
private ClaudeCodeApiHeaders headers;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
// Stub detector returns a stable version string so assertions stay deterministic.
|
||||
ClaudeCodeVersionDetector stub = new ClaudeCodeVersionDetector() {
|
||||
@Override
|
||||
public String get() { return "2.1.114"; }
|
||||
};
|
||||
headers = new ClaudeCodeApiHeaders(stub);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("allBetas: common betas appear before OAuth-only betas (matches hermes-agent ordering)")
|
||||
void allBetas_orderedCommonFirst() {
|
||||
String result = headers.allBetas();
|
||||
int oauthIdx = result.indexOf("oauth-2025-04-20");
|
||||
int interleavedIdx = result.indexOf("interleaved-thinking-2025-05-14");
|
||||
assertTrue(oauthIdx >= 0, "oauth beta missing");
|
||||
assertTrue(interleavedIdx >= 0, "interleaved-thinking beta missing");
|
||||
assertTrue(interleavedIdx < oauthIdx, "common betas must precede OAuth-only betas");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("allBetas: comma-joined with no whitespace")
|
||||
void allBetas_commaJoined() {
|
||||
String result = headers.allBetas();
|
||||
// Anthropic's edge is strict — a stray space breaks the header parser.
|
||||
assertTrue(result.contains("claude-code-20250219"));
|
||||
assertTrue(result.contains("oauth-2025-04-20"));
|
||||
assertTrue(result.contains(","));
|
||||
assertEquals(-1, result.indexOf(", "));
|
||||
assertEquals(-1, result.indexOf(" ,"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("userAgent: bare claude-cli/<ver> (no suffix — anti-abuse fingerprint)")
|
||||
void userAgent_format() {
|
||||
// Critical: must NOT contain "(external, cli)" — see class javadoc.
|
||||
assertEquals("claude-cli/2.1.114", headers.userAgent());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("xApp: returns the literal cli identifier")
|
||||
void xApp() {
|
||||
assertEquals("cli", headers.xApp());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("bearerAuth: prepends Bearer prefix exactly once")
|
||||
void bearerAuth() {
|
||||
assertEquals("Bearer abc123", headers.bearerAuth("abc123"));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,145 @@
|
||||
package vip.mate.llm.anthropic.oauth;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* Covers the JSON parsing path of {@link ClaudeCodeCredentialsReader}, which
|
||||
* is the only path exercised on Linux/Windows servers. Keychain reading is
|
||||
* a macOS-only ProcessBuilder integration — left to manual / live testing.
|
||||
*/
|
||||
class ClaudeCodeCredentialsReaderTest {
|
||||
|
||||
private ClaudeCodeCredentialsReader reader;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
reader = new ClaudeCodeCredentialsReader(new ObjectMapper());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("parseCredentials extracts all fields from the canonical envelope")
|
||||
void parseCredentials_fullPayload() {
|
||||
String json = """
|
||||
{
|
||||
"claudeAiOauth": {
|
||||
"accessToken": "sk-ant-oat01-test",
|
||||
"refreshToken": "sk-ant-ort01-test",
|
||||
"expiresAt": 1735689600000,
|
||||
"scopes": ["user:inference", "user:profile"]
|
||||
}
|
||||
}
|
||||
""";
|
||||
Optional<ClaudeCodeCredentials> result =
|
||||
reader.parseCredentials(json, ClaudeCodeCredentials.Source.CREDENTIALS_FILE);
|
||||
assertTrue(result.isPresent());
|
||||
ClaudeCodeCredentials c = result.get();
|
||||
assertEquals("sk-ant-oat01-test", c.accessToken());
|
||||
assertEquals("sk-ant-ort01-test", c.refreshToken());
|
||||
assertEquals(1735689600000L, c.expiresAtMs());
|
||||
assertEquals(ClaudeCodeCredentials.Source.CREDENTIALS_FILE, c.source());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("parseCredentials returns empty when claudeAiOauth missing")
|
||||
void parseCredentials_missingEnvelope() {
|
||||
// Some users have only {primaryApiKey: "..."} in ~/.claude.json — that's
|
||||
// an Anthropic console managed key, not OAuth, so we must NOT pretend
|
||||
// it's a Claude Code credential.
|
||||
Optional<ClaudeCodeCredentials> result = reader.parseCredentials(
|
||||
"{\"primaryApiKey\":\"sk-ant-test\"}",
|
||||
ClaudeCodeCredentials.Source.CREDENTIALS_FILE);
|
||||
assertFalse(result.isPresent());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("parseCredentials returns empty when accessToken blank")
|
||||
void parseCredentials_blankToken() {
|
||||
String json = """
|
||||
{ "claudeAiOauth": { "accessToken": "", "refreshToken": "rt" } }
|
||||
""";
|
||||
Optional<ClaudeCodeCredentials> result =
|
||||
reader.parseCredentials(json, ClaudeCodeCredentials.Source.CREDENTIALS_FILE);
|
||||
assertFalse(result.isPresent());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("parseCredentials handles missing refreshToken gracefully")
|
||||
void parseCredentials_missingRefreshToken() {
|
||||
// Older Claude Code versions wrote the access token without a refresh
|
||||
// token. Reader must still surface those — refresh just won't be possible.
|
||||
String json = """
|
||||
{ "claudeAiOauth": { "accessToken": "at-only", "expiresAt": 0 } }
|
||||
""";
|
||||
Optional<ClaudeCodeCredentials> result =
|
||||
reader.parseCredentials(json, ClaudeCodeCredentials.Source.CREDENTIALS_FILE);
|
||||
assertTrue(result.isPresent());
|
||||
assertEquals("at-only", result.get().accessToken());
|
||||
assertFalse(result.get().canRefresh());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("parseCredentials rejects malformed JSON without throwing")
|
||||
void parseCredentials_badJson() {
|
||||
Optional<ClaudeCodeCredentials> result = reader.parseCredentials(
|
||||
"{not json", ClaudeCodeCredentials.Source.CREDENTIALS_FILE);
|
||||
assertFalse(result.isPresent());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("parseCredentials returns empty for null/blank input")
|
||||
void parseCredentials_blankInput() {
|
||||
assertFalse(reader.parseCredentials(null, ClaudeCodeCredentials.Source.CREDENTIALS_FILE).isPresent());
|
||||
assertFalse(reader.parseCredentials("", ClaudeCodeCredentials.Source.CREDENTIALS_FILE).isPresent());
|
||||
assertFalse(reader.parseCredentials(" ", ClaudeCodeCredentials.Source.CREDENTIALS_FILE).isPresent());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("readFromJsonFile returns empty for missing path")
|
||||
void readFromJsonFile_missing(@TempDir Path tmp) {
|
||||
Path absent = tmp.resolve("nonexistent.json");
|
||||
assertFalse(reader.readFromJsonFile(absent).isPresent());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("readFromJsonFile reads + parses an existing file")
|
||||
void readFromJsonFile_present(@TempDir Path tmp) throws IOException {
|
||||
Path file = tmp.resolve(".credentials.json");
|
||||
Files.writeString(file, """
|
||||
{ "claudeAiOauth": {
|
||||
"accessToken": "from-file",
|
||||
"refreshToken": "rt-from-file",
|
||||
"expiresAt": 0
|
||||
} }
|
||||
""", StandardCharsets.UTF_8);
|
||||
|
||||
Optional<ClaudeCodeCredentials> result = reader.readFromJsonFile(file);
|
||||
assertTrue(result.isPresent());
|
||||
assertEquals("from-file", result.get().accessToken());
|
||||
assertEquals(ClaudeCodeCredentials.Source.CREDENTIALS_FILE, result.get().source());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("readFromKeychain returns empty on non-macOS hosts")
|
||||
void readFromKeychain_nonMacOs() {
|
||||
// Override isMacOs() to false so the test passes regardless of CI host.
|
||||
ClaudeCodeCredentialsReader linux = new ClaudeCodeCredentialsReader(new ObjectMapper()) {
|
||||
@Override
|
||||
boolean isMacOs() { return false; }
|
||||
};
|
||||
assertFalse(linux.readFromKeychain().isPresent());
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,57 @@
|
||||
package vip.mate.llm.anthropic.oauth;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* Validates the pure-data invariants of {@link ClaudeCodeCredentials} —
|
||||
* specifically the {@code isValid(buffer)} expiry math and {@code canRefresh}
|
||||
* predicate. Exercising these here means downstream services can rely on the
|
||||
* record without re-implementing the same checks.
|
||||
*/
|
||||
class ClaudeCodeCredentialsTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("isValid: blank access token always invalid")
|
||||
void isValid_blankToken_false() {
|
||||
assertFalse(creds("", "rt", System.currentTimeMillis() + 60_000).isValid(0L));
|
||||
assertFalse(creds(null, "rt", System.currentTimeMillis() + 60_000).isValid(0L));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("isValid: expiresAt=0 means no expiry — always valid when token present")
|
||||
void isValid_zeroExpiry_alwaysValid() {
|
||||
assertTrue(creds("at", "rt", 0L).isValid(60_000L));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("isValid: returns false within buffer window")
|
||||
void isValid_withinBuffer_false() {
|
||||
long now = System.currentTimeMillis();
|
||||
// Token expires in 30s; buffer is 60s → invalid (must refresh before expiry).
|
||||
assertFalse(creds("at", "rt", now + 30_000L).isValid(60_000L));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("isValid: returns true outside buffer window")
|
||||
void isValid_outsideBuffer_true() {
|
||||
long now = System.currentTimeMillis();
|
||||
// Token expires in 5 minutes; 60s buffer → still valid.
|
||||
assertTrue(creds("at", "rt", now + 300_000L).isValid(60_000L));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("canRefresh: requires non-blank refresh token")
|
||||
void canRefresh() {
|
||||
assertTrue(creds("at", "rt", 0L).canRefresh());
|
||||
assertFalse(creds("at", "", 0L).canRefresh());
|
||||
assertFalse(creds("at", null, 0L).canRefresh());
|
||||
}
|
||||
|
||||
private static ClaudeCodeCredentials creds(String at, String rt, long expiresAt) {
|
||||
return new ClaudeCodeCredentials(at, rt, expiresAt, ClaudeCodeCredentials.Source.CREDENTIALS_FILE);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,182 @@
|
||||
package vip.mate.llm.anthropic.oauth;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* Validates the JSON-file write path of {@link ClaudeCodeCredentialsWriter},
|
||||
* with focus on the two correctness-critical behaviors:
|
||||
*
|
||||
* <ol>
|
||||
* <li>Concurrent-write defence: when Claude Code itself rewrites the file
|
||||
* while MateClaw is mid-refresh, the writer must NOT clobber.</li>
|
||||
* <li>Scope preservation: the writer must keep the {@code scopes} array
|
||||
* (Claude Code >= 2.1.81 needs {@code user:inference} or it shows
|
||||
* the user as logged-out).</li>
|
||||
* </ol>
|
||||
*/
|
||||
class ClaudeCodeCredentialsWriterTest {
|
||||
|
||||
private ObjectMapper mapper;
|
||||
private ClaudeCodeCredentialsWriter writer;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
mapper = new ObjectMapper();
|
||||
writer = new ClaudeCodeCredentialsWriter(mapper);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("writeJsonFile creates a new file when none exists")
|
||||
void writeJsonFile_createsNew(@TempDir Path tmp) throws IOException {
|
||||
Path target = tmp.resolve(".credentials.json");
|
||||
ClaudeCodeCredentials fresh = new ClaudeCodeCredentials(
|
||||
"new-access", "new-refresh", 9_999_999_999L,
|
||||
ClaudeCodeCredentials.Source.CREDENTIALS_FILE);
|
||||
|
||||
boolean ok = writer.writeJsonFile(target, null, fresh);
|
||||
assertTrue(ok);
|
||||
assertTrue(Files.exists(target));
|
||||
|
||||
JsonNode root = mapper.readTree(Files.readString(target, StandardCharsets.UTF_8));
|
||||
JsonNode oauth = root.path("claudeAiOauth");
|
||||
assertEquals("new-access", oauth.path("accessToken").asText());
|
||||
assertEquals("new-refresh", oauth.path("refreshToken").asText());
|
||||
assertEquals(9_999_999_999L, oauth.path("expiresAt").asLong());
|
||||
// Default scope must be present so Claude Code 2.1.81+ keeps recognising
|
||||
// the credential after MateClaw writes to it.
|
||||
assertTrue(oauth.path("scopes").isArray());
|
||||
assertEquals("user:inference", oauth.path("scopes").get(0).asText());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("writeJsonFile preserves existing scopes")
|
||||
void writeJsonFile_preservesScopes(@TempDir Path tmp) throws IOException {
|
||||
Path target = tmp.resolve(".credentials.json");
|
||||
Files.writeString(target, """
|
||||
{ "claudeAiOauth": {
|
||||
"accessToken": "old-token",
|
||||
"refreshToken": "old-refresh",
|
||||
"expiresAt": 1,
|
||||
"scopes": ["user:inference", "user:profile", "extra:scope"]
|
||||
} }
|
||||
""", StandardCharsets.UTF_8);
|
||||
|
||||
ClaudeCodeCredentials fresh = new ClaudeCodeCredentials(
|
||||
"new-access", "new-refresh", 9_999_999_999L,
|
||||
ClaudeCodeCredentials.Source.CREDENTIALS_FILE);
|
||||
boolean ok = writer.writeJsonFile(target, "old-token", fresh);
|
||||
assertTrue(ok);
|
||||
|
||||
JsonNode oauth = mapper.readTree(Files.readString(target, StandardCharsets.UTF_8))
|
||||
.path("claudeAiOauth");
|
||||
assertEquals("new-access", oauth.path("accessToken").asText());
|
||||
// All three original scopes survive — the writer mutates only the
|
||||
// fields it owns (access/refresh/expiresAt).
|
||||
assertEquals(3, oauth.path("scopes").size());
|
||||
assertEquals("user:profile", oauth.path("scopes").get(1).asText());
|
||||
assertEquals("extra:scope", oauth.path("scopes").get(2).asText());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("writeJsonFile preserves unknown top-level fields")
|
||||
void writeJsonFile_preservesUnknownFields(@TempDir Path tmp) throws IOException {
|
||||
// Defends against future Claude Code releases that add new fields:
|
||||
// we must not strip them on rewrite.
|
||||
Path target = tmp.resolve(".credentials.json");
|
||||
Files.writeString(target, """
|
||||
{
|
||||
"claudeAiOauth": { "accessToken": "x", "expiresAt": 1 },
|
||||
"futureField": { "foo": "bar" }
|
||||
}
|
||||
""", StandardCharsets.UTF_8);
|
||||
|
||||
ClaudeCodeCredentials fresh = new ClaudeCodeCredentials(
|
||||
"new-access", "new-refresh", 100L,
|
||||
ClaudeCodeCredentials.Source.CREDENTIALS_FILE);
|
||||
writer.writeJsonFile(target, "x", fresh);
|
||||
|
||||
JsonNode root = mapper.readTree(Files.readString(target, StandardCharsets.UTF_8));
|
||||
assertEquals("bar", root.path("futureField").path("foo").asText());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("writeJsonFile bails out when on-disk token already changed")
|
||||
void writeJsonFile_concurrentWriteDetected(@TempDir Path tmp) throws IOException {
|
||||
// Simulate: MateClaw started a refresh from token "T1", Claude Code
|
||||
// beat us to it and wrote "T2". MateClaw must NOT overwrite.
|
||||
Path target = tmp.resolve(".credentials.json");
|
||||
Files.writeString(target, """
|
||||
{ "claudeAiOauth": {
|
||||
"accessToken": "T2",
|
||||
"refreshToken": "rt2",
|
||||
"expiresAt": 99,
|
||||
"scopes": ["user:inference"]
|
||||
} }
|
||||
""", StandardCharsets.UTF_8);
|
||||
|
||||
ClaudeCodeCredentials fresh = new ClaudeCodeCredentials(
|
||||
"T3", "rt3", 100L,
|
||||
ClaudeCodeCredentials.Source.CREDENTIALS_FILE);
|
||||
boolean ok = writer.writeJsonFile(target, "T1", fresh);
|
||||
assertFalse(ok, "writer must refuse to overwrite a concurrently-updated file");
|
||||
|
||||
// Disk contents unchanged
|
||||
JsonNode oauth = mapper.readTree(Files.readString(target, StandardCharsets.UTF_8))
|
||||
.path("claudeAiOauth");
|
||||
assertEquals("T2", oauth.path("accessToken").asText());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("writeJsonFile proceeds when previousAccessToken is null (first-time write)")
|
||||
void writeJsonFile_nullPrevious_proceeds(@TempDir Path tmp) throws IOException {
|
||||
Path target = tmp.resolve(".credentials.json");
|
||||
Files.writeString(target, """
|
||||
{ "claudeAiOauth": { "accessToken": "existing", "scopes": ["user:inference"] } }
|
||||
""", StandardCharsets.UTF_8);
|
||||
|
||||
ClaudeCodeCredentials fresh = new ClaudeCodeCredentials(
|
||||
"fresh-token", "fresh-refresh", 0L,
|
||||
ClaudeCodeCredentials.Source.CREDENTIALS_FILE);
|
||||
// Null previous → caller doesn't have a baseline (e.g. first import)
|
||||
// → skip concurrency check and just write.
|
||||
assertTrue(writer.writeJsonFile(target, null, fresh));
|
||||
|
||||
JsonNode oauth = mapper.readTree(Files.readString(target, StandardCharsets.UTF_8))
|
||||
.path("claudeAiOauth");
|
||||
assertEquals("fresh-token", oauth.path("accessToken").asText());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("write rejects blank access tokens")
|
||||
void write_rejectsBlankToken() {
|
||||
ClaudeCodeCredentials blank = new ClaudeCodeCredentials(
|
||||
" ", "rt", 0L, ClaudeCodeCredentials.Source.CREDENTIALS_FILE);
|
||||
assertFalse(writer.write(null, blank));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("writeKeychain returns false on non-macOS hosts")
|
||||
void writeKeychain_nonMacOs() {
|
||||
ClaudeCodeCredentialsWriter linux = new ClaudeCodeCredentialsWriter(mapper) {
|
||||
@Override
|
||||
boolean isMacOs() { return false; }
|
||||
};
|
||||
ClaudeCodeCredentials creds = new ClaudeCodeCredentials(
|
||||
"at", "rt", 0L, ClaudeCodeCredentials.Source.MACOS_KEYCHAIN);
|
||||
assertFalse(linux.writeKeychain(null, creds));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,225 @@
|
||||
package vip.mate.llm.anthropic.oauth;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import vip.mate.exception.MateClawException;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* Tests the orchestration logic of {@link ClaudeCodeOAuthService} — the
|
||||
* decision tree for "return cached token" / "refresh + persist" / "fail with
|
||||
* actionable error". Uses test-double subclasses for Reader / Refresher /
|
||||
* Writer to avoid hitting the filesystem or network.
|
||||
*/
|
||||
class ClaudeCodeOAuthServiceTest {
|
||||
|
||||
private ObjectMapper mapper;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
mapper = new ObjectMapper();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("getValidToken returns existing token when still valid")
|
||||
void getValidToken_cached() {
|
||||
ClaudeCodeCredentials valid = new ClaudeCodeCredentials(
|
||||
"still-good", "rt", System.currentTimeMillis() + 600_000L,
|
||||
ClaudeCodeCredentials.Source.CREDENTIALS_FILE);
|
||||
ClaudeCodeOAuthService svc = serviceWith(valid, /* refreshShouldBeCalled */ false);
|
||||
assertEquals("still-good", svc.getValidToken());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("getValidToken refreshes when within buffer window")
|
||||
void getValidToken_refreshesNearExpiry() {
|
||||
// Token expires in 30s; buffer is 60s → must refresh.
|
||||
ClaudeCodeCredentials nearExpiry = new ClaudeCodeCredentials(
|
||||
"old-token", "rt", System.currentTimeMillis() + 30_000L,
|
||||
ClaudeCodeCredentials.Source.CREDENTIALS_FILE);
|
||||
|
||||
AtomicReference<String> capturedPreviousToken = new AtomicReference<>();
|
||||
AtomicReference<ClaudeCodeCredentials> capturedWritten = new AtomicReference<>();
|
||||
|
||||
ClaudeCodeCredentialsReader reader = stubReader(nearExpiry);
|
||||
ClaudeCodeTokenRefresher refresher = stubRefresher(rt -> new ClaudeCodeCredentials(
|
||||
"fresh-token", "fresh-rt", System.currentTimeMillis() + 3_600_000L,
|
||||
ClaudeCodeCredentials.Source.REFRESH_RESPONSE));
|
||||
ClaudeCodeCredentialsWriter writer = stubWriter((prev, creds) -> {
|
||||
capturedPreviousToken.set(prev);
|
||||
capturedWritten.set(creds);
|
||||
return true;
|
||||
});
|
||||
|
||||
ClaudeCodeOAuthService svc = new ClaudeCodeOAuthService(reader, refresher, writer);
|
||||
assertEquals("fresh-token", svc.getValidToken());
|
||||
|
||||
// Writer must receive the prior access token (for concurrency check)
|
||||
// AND the credential pinned to the original source — not REFRESH_RESPONSE.
|
||||
assertEquals("old-token", capturedPreviousToken.get());
|
||||
assertNotNull(capturedWritten.get());
|
||||
assertEquals("fresh-token", capturedWritten.get().accessToken());
|
||||
assertEquals(ClaudeCodeCredentials.Source.CREDENTIALS_FILE, capturedWritten.get().source(),
|
||||
"write must target the source the credential was originally read from");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("getValidToken throws actionable error when no credentials on disk")
|
||||
void getValidToken_noCredentials() {
|
||||
ClaudeCodeOAuthService svc = new ClaudeCodeOAuthService(
|
||||
stubReader(null),
|
||||
stubRefresher(rt -> { throw new IllegalStateException("should not be called"); }),
|
||||
stubWriter((prev, creds) -> { throw new IllegalStateException("should not be called"); }));
|
||||
|
||||
MateClawException ex = assertThrows(MateClawException.class, svc::getValidToken);
|
||||
assertEquals("err.anthropic.no_claude_code", ex.getMsgKey());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("getValidToken throws when token expired and no refresh available")
|
||||
void getValidToken_expiredNoRefresh() {
|
||||
ClaudeCodeCredentials expired = new ClaudeCodeCredentials(
|
||||
"expired", "", System.currentTimeMillis() - 60_000L,
|
||||
ClaudeCodeCredentials.Source.CREDENTIALS_FILE);
|
||||
ClaudeCodeOAuthService svc = serviceWith(expired, false);
|
||||
MateClawException ex = assertThrows(MateClawException.class, svc::getValidToken);
|
||||
assertEquals("err.anthropic.token_expired_no_refresh", ex.getMsgKey());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("getValidToken still returns fresh token when persistence fails")
|
||||
void getValidToken_writeFailureNonFatal() {
|
||||
// Writer returning false (e.g. concurrent-write detected) must NOT
|
||||
// turn into a request failure — the in-memory token is still good.
|
||||
ClaudeCodeCredentials nearExpiry = new ClaudeCodeCredentials(
|
||||
"stale", "rt", System.currentTimeMillis() - 60_000L,
|
||||
ClaudeCodeCredentials.Source.CREDENTIALS_FILE);
|
||||
ClaudeCodeOAuthService svc = new ClaudeCodeOAuthService(
|
||||
stubReader(nearExpiry),
|
||||
stubRefresher(rt -> new ClaudeCodeCredentials(
|
||||
"refreshed", "rt2", System.currentTimeMillis() + 600_000L,
|
||||
ClaudeCodeCredentials.Source.REFRESH_RESPONSE)),
|
||||
stubWriter((prev, creds) -> false));
|
||||
assertEquals("refreshed", svc.getValidToken());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("isLoggedIn reflects on-disk state without triggering refresh")
|
||||
void isLoggedIn() {
|
||||
ClaudeCodeCredentials valid = new ClaudeCodeCredentials(
|
||||
"tok", "rt", System.currentTimeMillis() + 600_000L,
|
||||
ClaudeCodeCredentials.Source.CREDENTIALS_FILE);
|
||||
assertTrue(serviceWith(valid, false).isLoggedIn());
|
||||
|
||||
// Expired token → not logged in (we don't auto-refresh from a status check).
|
||||
ClaudeCodeCredentials expired = new ClaudeCodeCredentials(
|
||||
"tok", "rt", System.currentTimeMillis() - 60_000L,
|
||||
ClaudeCodeCredentials.Source.CREDENTIALS_FILE);
|
||||
assertFalse(serviceWith(expired, false).isLoggedIn());
|
||||
|
||||
// No file → not logged in.
|
||||
ClaudeCodeOAuthService noCreds = new ClaudeCodeOAuthService(
|
||||
stubReader(null),
|
||||
stubRefresher(rt -> { throw new IllegalStateException(); }),
|
||||
stubWriter((p, c) -> { throw new IllegalStateException(); }));
|
||||
assertFalse(noCreds.isLoggedIn());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("getStatus surfaces source + expiry without exposing the token")
|
||||
void getStatus_disconnected() {
|
||||
ClaudeCodeOAuthService svc = new ClaudeCodeOAuthService(
|
||||
stubReader(null),
|
||||
stubRefresher(rt -> { throw new IllegalStateException(); }),
|
||||
stubWriter((p, c) -> { throw new IllegalStateException(); }));
|
||||
ClaudeCodeOAuthService.OAuthStatus status = svc.getStatus();
|
||||
assertFalse(status.connected());
|
||||
assertFalse(status.expired());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("getStatus reports expired flag correctly")
|
||||
void getStatus_expired() {
|
||||
ClaudeCodeCredentials expired = new ClaudeCodeCredentials(
|
||||
"tok", "rt", System.currentTimeMillis() - 1_000L,
|
||||
ClaudeCodeCredentials.Source.MACOS_KEYCHAIN);
|
||||
ClaudeCodeOAuthService svc = serviceWith(expired, false);
|
||||
ClaudeCodeOAuthService.OAuthStatus status = svc.getStatus();
|
||||
assertTrue(status.connected());
|
||||
assertTrue(status.expired());
|
||||
assertEquals(ClaudeCodeCredentials.Source.MACOS_KEYCHAIN, status.source());
|
||||
}
|
||||
|
||||
/* ---------- Test-double helpers ---------- */
|
||||
|
||||
/** Build a service whose reader returns the given credentials and whose refresher/writer fail loudly if invoked. */
|
||||
private ClaudeCodeOAuthService serviceWith(ClaudeCodeCredentials creds, boolean expectRefresh) {
|
||||
return new ClaudeCodeOAuthService(
|
||||
stubReader(creds),
|
||||
stubRefresher(rt -> {
|
||||
if (!expectRefresh) {
|
||||
throw new IllegalStateException("refresher should not have been called");
|
||||
}
|
||||
return new ClaudeCodeCredentials("refreshed", "rt2",
|
||||
System.currentTimeMillis() + 3_600_000L,
|
||||
ClaudeCodeCredentials.Source.REFRESH_RESPONSE);
|
||||
}),
|
||||
stubWriter((prev, c) -> {
|
||||
if (!expectRefresh) {
|
||||
throw new IllegalStateException("writer should not have been called");
|
||||
}
|
||||
return true;
|
||||
}));
|
||||
}
|
||||
|
||||
private ClaudeCodeCredentialsReader stubReader(ClaudeCodeCredentials toReturn) {
|
||||
return new ClaudeCodeCredentialsReader(mapper) {
|
||||
@Override
|
||||
public Optional<ClaudeCodeCredentials> read() {
|
||||
return Optional.ofNullable(toReturn);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
private interface RefreshFn {
|
||||
ClaudeCodeCredentials apply(String refreshToken);
|
||||
}
|
||||
|
||||
private ClaudeCodeTokenRefresher stubRefresher(RefreshFn fn) {
|
||||
ClaudeCodeVersionDetector ver = new ClaudeCodeVersionDetector() {
|
||||
@Override
|
||||
public String get() { return "2.1.114"; }
|
||||
};
|
||||
return new ClaudeCodeTokenRefresher(mapper, ver) {
|
||||
@Override
|
||||
public ClaudeCodeCredentials refresh(String refreshToken) {
|
||||
return fn.apply(refreshToken);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
private interface WriteFn {
|
||||
boolean apply(String previousAccessToken, ClaudeCodeCredentials creds);
|
||||
}
|
||||
|
||||
private ClaudeCodeCredentialsWriter stubWriter(WriteFn fn) {
|
||||
return new ClaudeCodeCredentialsWriter(mapper) {
|
||||
@Override
|
||||
public boolean write(String previousAccessToken, ClaudeCodeCredentials refreshed) {
|
||||
return fn.apply(previousAccessToken, refreshed);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,115 @@
|
||||
package vip.mate.llm.anthropic.oauth;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import vip.mate.exception.MateClawException;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* Validates the response-parsing logic of {@link ClaudeCodeTokenRefresher}.
|
||||
* Network-bound paths (the actual POST to platform.claude.com) require either
|
||||
* a wiremock or live fixtures and are out of scope for unit tests.
|
||||
*/
|
||||
class ClaudeCodeTokenRefresherTest {
|
||||
|
||||
private ClaudeCodeTokenRefresher refresher;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
ClaudeCodeVersionDetector versionStub = new ClaudeCodeVersionDetector() {
|
||||
@Override
|
||||
public String get() { return "2.1.114"; }
|
||||
};
|
||||
refresher = new ClaudeCodeTokenRefresher(new ObjectMapper(), versionStub);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("parseTokenResponse handles standard expires_in seconds")
|
||||
void parseTokenResponse_expiresIn() {
|
||||
long before = System.currentTimeMillis();
|
||||
String body = """
|
||||
{ "access_token": "fresh-at", "refresh_token": "fresh-rt", "expires_in": 3600 }
|
||||
""";
|
||||
ClaudeCodeCredentials c = refresher.parseTokenResponse(body, "old-rt");
|
||||
assertEquals("fresh-at", c.accessToken());
|
||||
assertEquals("fresh-rt", c.refreshToken());
|
||||
// expires_in=3600 → expiresAt should be ~1h from now.
|
||||
long expectedMin = before + 3_590_000L;
|
||||
long expectedMax = System.currentTimeMillis() + 3_610_000L;
|
||||
assertTrue(c.expiresAtMs() >= expectedMin && c.expiresAtMs() <= expectedMax,
|
||||
"expiresAtMs " + c.expiresAtMs() + " out of expected range");
|
||||
assertEquals(ClaudeCodeCredentials.Source.REFRESH_RESPONSE, c.source());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("parseTokenResponse uses absolute expires_at when provided")
|
||||
void parseTokenResponse_expiresAtMs() {
|
||||
// Some Anthropic deployments return expires_at as an absolute ms value.
|
||||
String body = """
|
||||
{ "access_token": "at2", "expires_at": 1234567890000 }
|
||||
""";
|
||||
ClaudeCodeCredentials c = refresher.parseTokenResponse(body, "old-rt");
|
||||
assertEquals(1234567890000L, c.expiresAtMs());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("parseTokenResponse falls back to old refresh_token when response omits one")
|
||||
void parseTokenResponse_keepsOldRefreshToken() {
|
||||
// Anthropic docs say refresh_token may be omitted on rotation-disabled
|
||||
// grants. We must NOT lose the original; otherwise the next refresh fails.
|
||||
String body = """
|
||||
{ "access_token": "at3", "expires_in": 3600 }
|
||||
""";
|
||||
ClaudeCodeCredentials c = refresher.parseTokenResponse(body, "preserved-rt");
|
||||
assertEquals("preserved-rt", c.refreshToken());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("parseTokenResponse rejects blank access_token")
|
||||
void parseTokenResponse_blankToken_throws() {
|
||||
// Edge case where Anthropic returns 200 with empty access_token —
|
||||
// surface as a domain error rather than persisting garbage.
|
||||
String body = """
|
||||
{ "access_token": "", "expires_in": 3600 }
|
||||
""";
|
||||
MateClawException ex = assertThrows(MateClawException.class,
|
||||
() -> refresher.parseTokenResponse(body, "rt"));
|
||||
assertEquals("err.anthropic.refresh_failed", ex.getMsgKey());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("parseTokenResponse wraps malformed JSON")
|
||||
void parseTokenResponse_badJson_throws() {
|
||||
MateClawException ex = assertThrows(MateClawException.class,
|
||||
() -> refresher.parseTokenResponse("not-json", "rt"));
|
||||
assertEquals("err.anthropic.refresh_failed", ex.getMsgKey());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("refresh rejects blank refresh_token without making a network call")
|
||||
void refresh_blankInput_throws() {
|
||||
MateClawException ex = assertThrows(MateClawException.class,
|
||||
() -> refresher.refresh(""));
|
||||
// No network call made — the failure mode here is "no refresh available",
|
||||
// not "refresh attempt failed".
|
||||
assertNotEquals("err.anthropic.refresh_failed", ex.getMsgKey());
|
||||
assertEquals("err.anthropic.token_expired_no_refresh", ex.getMsgKey());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("ENDPOINTS includes both platform.claude.com and console.anthropic.com")
|
||||
void endpoints_haveBothHosts() {
|
||||
// Constants pinned by RFC-062. If Anthropic deprecates one, change here
|
||||
// AND in the RFC; do not silently drop a fallback.
|
||||
assertTrue(ClaudeCodeTokenRefresher.ENDPOINTS.stream()
|
||||
.anyMatch(s -> s.contains("platform.claude.com")));
|
||||
assertTrue(ClaudeCodeTokenRefresher.ENDPOINTS.stream()
|
||||
.anyMatch(s -> s.contains("console.anthropic.com")));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,59 @@
|
||||
package vip.mate.llm.anthropic.oauth;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
|
||||
/**
|
||||
* Static-helper coverage for {@link ClaudeCodeVersionDetector#parseVersion}.
|
||||
*
|
||||
* <p>The {@code claude --version} output format has shifted between Claude Code
|
||||
* releases (early builds prefixed with the binary name; recent ones print just
|
||||
* the number). The regex must match both so MateClaw stays in sync without
|
||||
* manual config when users upgrade.
|
||||
*/
|
||||
class ClaudeCodeVersionDetectorTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("parseVersion accepts the modern bare-number format")
|
||||
void parseVersion_modern() {
|
||||
assertEquals("2.1.114", ClaudeCodeVersionDetector.parseVersion("2.1.114"));
|
||||
assertEquals("2.1.74", ClaudeCodeVersionDetector.parseVersion("2.1.74\n"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("parseVersion ignores trailing whitespace and extra suffix")
|
||||
void parseVersion_withSuffix() {
|
||||
assertEquals("2.1.114", ClaudeCodeVersionDetector.parseVersion("2.1.114 (Claude Code)"));
|
||||
assertEquals("2.1.114", ClaudeCodeVersionDetector.parseVersion(" 2.1.114 "));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("parseVersion accepts a two-segment version")
|
||||
void parseVersion_twoSegments() {
|
||||
// Some legacy --version outputs printed only major.minor.
|
||||
assertEquals("2.1", ClaudeCodeVersionDetector.parseVersion("2.1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("parseVersion rejects non-numeric prefixes")
|
||||
void parseVersion_rejectsNonNumeric() {
|
||||
assertNull(ClaudeCodeVersionDetector.parseVersion("claude-code v2.1.114"));
|
||||
assertNull(ClaudeCodeVersionDetector.parseVersion(""));
|
||||
assertNull(ClaudeCodeVersionDetector.parseVersion(null));
|
||||
assertNull(ClaudeCodeVersionDetector.parseVersion("not a version"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("FALLBACK_VERSION constant is a real semver-shape string")
|
||||
void fallbackVersion_isSemver() {
|
||||
// Sanity-check the static fallback so a bad edit (e.g. typo) is caught
|
||||
// before it ships in a User-Agent header.
|
||||
String parsed = ClaudeCodeVersionDetector.parseVersion(ClaudeCodeVersionDetector.FALLBACK_VERSION);
|
||||
assertNotNull(parsed);
|
||||
assertEquals(ClaudeCodeVersionDetector.FALLBACK_VERSION, parsed);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,71 @@
|
||||
package vip.mate.llm.chatmodel;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
/**
|
||||
* RFC-03 Lane B1 — covers {@link HttpTimeouts#resolveReadTimeout(Integer)},
|
||||
* the central resolver that backs {@code mate_model_config.request_timeout_seconds}.
|
||||
*
|
||||
* <p>Behavioral contract under test:
|
||||
* <ul>
|
||||
* <li>null / non-positive → 180s (the historical hardcoded default; preserves
|
||||
* behavior for every existing row before V75 ran).</li>
|
||||
* <li>positive integer → that many seconds, no clamp (caller decides
|
||||
* reasonable upper bound at the model-config level — we don't want to
|
||||
* silently rewrite a user's deliberate 30-min override).</li>
|
||||
* <li>connect timeout stays at 10s and is never overridable — long-tail
|
||||
* latency manifests on the read path, not on connect.</li>
|
||||
* </ul>
|
||||
*/
|
||||
class HttpTimeoutsTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("null override → default 180s read timeout")
|
||||
void nullFallsBack() {
|
||||
assertEquals(Duration.ofSeconds(180),
|
||||
HttpTimeouts.resolveReadTimeout(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("zero → default 180s (treated as unset)")
|
||||
void zeroFallsBack() {
|
||||
assertEquals(Duration.ofSeconds(180),
|
||||
HttpTimeouts.resolveReadTimeout(0));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("negative → default 180s (defensively treats nonsense values as unset)")
|
||||
void negativeFallsBack() {
|
||||
assertEquals(Duration.ofSeconds(180),
|
||||
HttpTimeouts.resolveReadTimeout(-30));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("positive integer → exact seconds, no clamp on either side")
|
||||
void positiveHonored() {
|
||||
assertEquals(Duration.ofSeconds(30),
|
||||
HttpTimeouts.resolveReadTimeout(30));
|
||||
assertEquals(Duration.ofSeconds(600),
|
||||
HttpTimeouts.resolveReadTimeout(600));
|
||||
// o1-pro / claude opus extended-thinking can legitimately need 30 min.
|
||||
assertEquals(Duration.ofSeconds(1800),
|
||||
HttpTimeouts.resolveReadTimeout(1800));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("connect timeout is the canonical 10s")
|
||||
void connectTimeoutIsCanonical() {
|
||||
assertEquals(Duration.ofSeconds(10), HttpTimeouts.CONNECT_TIMEOUT);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("default read timeout matches the legacy hardcoded 180s")
|
||||
void defaultMatchesLegacy() {
|
||||
assertEquals(Duration.ofSeconds(180), HttpTimeouts.DEFAULT_READ_TIMEOUT);
|
||||
}
|
||||
}
|
||||
@ -235,6 +235,9 @@ class ProviderInitProbeTest {
|
||||
p.setChatModel(protocol.getChatModelClass());
|
||||
p.setApiKey("sk-test");
|
||||
p.setBaseUrl("https://example.com");
|
||||
// RFC-074: probe filters out enabled=false rows. The pre-RFC-074 default
|
||||
// for these test fixtures was "everything participates" — preserve that.
|
||||
p.setEnabled(true);
|
||||
return p;
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,179 @@
|
||||
package vip.mate.llm.failover;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import vip.mate.llm.model.ModelProviderEntity;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* Issue #81: row-based required-fields decision. Replaces the v1 protocol-keyed
|
||||
* lookup, which couldn't tell OpenAI cloud (needs api_key) apart from llama.cpp
|
||||
* local (needs base_url) because both ride the OPENAI_COMPATIBLE protocol enum.
|
||||
*
|
||||
* <p>Each test is one cell of the truth table in the RFC §2.2 / §2.3.
|
||||
*/
|
||||
class ProviderRequirementsTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("OpenAI cloud: needs api key, no base url, no hint")
|
||||
void openaiCloud() {
|
||||
ProviderRequirements.Required r = ProviderRequirements.of(cloud("openai", true));
|
||||
assertTrue(r.needsApiKey());
|
||||
assertFalse(r.needsBaseUrl());
|
||||
assertNull(r.hintKey());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Kimi cloud: same shape as OpenAI")
|
||||
void kimiCloud() {
|
||||
ProviderRequirements.Required r = ProviderRequirements.of(cloud("kimi", true));
|
||||
assertTrue(r.needsApiKey());
|
||||
assertFalse(r.needsBaseUrl());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("DeepSeek cloud: same shape")
|
||||
void deepseekCloud() {
|
||||
ProviderRequirements.Required r = ProviderRequirements.of(cloud("deepseek", true));
|
||||
assertTrue(r.needsApiKey());
|
||||
assertFalse(r.needsBaseUrl());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("llama.cpp local: no api key, needs base url, llamacpp hint")
|
||||
void llamacppLocal() {
|
||||
ProviderRequirements.Required r = ProviderRequirements.of(local("llamacpp"));
|
||||
assertFalse(r.needsApiKey());
|
||||
assertTrue(r.needsBaseUrl());
|
||||
assertEquals("provider.hint.llamacppBaseUrlExample", r.hintKey());
|
||||
assertEquals("http://127.0.0.1:8080/v1", r.hintArgs().get("example"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Ollama local: ollama-specific hint")
|
||||
void ollamaLocal() {
|
||||
ProviderRequirements.Required r = ProviderRequirements.of(local("ollama"));
|
||||
assertFalse(r.needsApiKey());
|
||||
assertTrue(r.needsBaseUrl());
|
||||
assertEquals("provider.hint.ollamaBaseUrlExample", r.hintKey());
|
||||
assertEquals("http://127.0.0.1:11434", r.hintArgs().get("example"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("LM Studio local: lmstudio-specific hint, also matches lm-studio / lm_studio")
|
||||
void lmstudioLocal() {
|
||||
ProviderRequirements.Required r = ProviderRequirements.of(local("lmstudio"));
|
||||
assertEquals("provider.hint.lmstudioBaseUrlExample", r.hintKey());
|
||||
assertEquals("provider.hint.lmstudioBaseUrlExample",
|
||||
ProviderRequirements.of(local("lm-studio")).hintKey());
|
||||
assertEquals("provider.hint.lmstudioBaseUrlExample",
|
||||
ProviderRequirements.of(local("lm_studio")).hintKey());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("vLLM local: vllm-specific hint")
|
||||
void vllmLocal() {
|
||||
ProviderRequirements.Required r = ProviderRequirements.of(local("vllm"));
|
||||
assertEquals("provider.hint.vllmBaseUrlExample", r.hintKey());
|
||||
assertEquals("http://127.0.0.1:8000/v1", r.hintArgs().get("example"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Custom OpenAI-compat needing API key: needs both, generic hint")
|
||||
void customOpenAiCompatNeedingKey() {
|
||||
ModelProviderEntity p = new ModelProviderEntity();
|
||||
p.setProviderId("my-llm-server");
|
||||
p.setIsCustom(true);
|
||||
p.setIsLocal(false);
|
||||
p.setRequireApiKey(true);
|
||||
|
||||
ProviderRequirements.Required r = ProviderRequirements.of(p);
|
||||
assertTrue(r.needsApiKey());
|
||||
assertTrue(r.needsBaseUrl());
|
||||
assertEquals("provider.hint.openaiCompatBaseUrlExample", r.hintKey());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Custom OpenAI-compat without API key: only base url + generic hint")
|
||||
void customOpenAiCompatNoKey() {
|
||||
ModelProviderEntity p = new ModelProviderEntity();
|
||||
p.setProviderId("my-llm-server");
|
||||
p.setIsCustom(true);
|
||||
p.setRequireApiKey(false);
|
||||
|
||||
ProviderRequirements.Required r = ProviderRequirements.of(p);
|
||||
assertFalse(r.needsApiKey());
|
||||
assertTrue(r.needsBaseUrl());
|
||||
assertEquals("provider.hint.openaiCompatBaseUrlExample", r.hintKey());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("OAuth provider: no api key, no base url, no hint")
|
||||
void oauthProvider() {
|
||||
ModelProviderEntity p = new ModelProviderEntity();
|
||||
p.setProviderId("anthropic-claude-code");
|
||||
p.setAuthType("oauth");
|
||||
p.setRequireApiKey(true); // ignored under oauth
|
||||
p.setIsLocal(true); // ignored under oauth
|
||||
|
||||
ProviderRequirements.Required r = ProviderRequirements.of(p);
|
||||
assertFalse(r.needsApiKey());
|
||||
assertFalse(r.needsBaseUrl());
|
||||
assertNull(r.hintKey());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Generic OAuth (non-Claude-Code): same shape")
|
||||
void genericOauth() {
|
||||
ModelProviderEntity p = new ModelProviderEntity();
|
||||
p.setProviderId("some-oauth-provider");
|
||||
p.setAuthType("oauth");
|
||||
|
||||
ProviderRequirements.Required r = ProviderRequirements.of(p);
|
||||
assertFalse(r.needsApiKey());
|
||||
assertFalse(r.needsBaseUrl());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Null provider: safe defaults")
|
||||
void nullProvider() {
|
||||
ProviderRequirements.Required r = ProviderRequirements.of(null);
|
||||
assertFalse(r.needsApiKey());
|
||||
assertFalse(r.needsBaseUrl());
|
||||
assertNull(r.hintKey());
|
||||
assertNotNull(r.hintArgs());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("isCustom=true with empty providerId: still needs base url, generic hint")
|
||||
void customEmptyProviderId() {
|
||||
ModelProviderEntity p = new ModelProviderEntity();
|
||||
p.setIsCustom(true);
|
||||
p.setRequireApiKey(false);
|
||||
|
||||
ProviderRequirements.Required r = ProviderRequirements.of(p);
|
||||
assertTrue(r.needsBaseUrl());
|
||||
assertEquals("provider.hint.openaiCompatBaseUrlExample", r.hintKey());
|
||||
}
|
||||
|
||||
// ===== helpers =====
|
||||
|
||||
private static ModelProviderEntity cloud(String id, boolean requireApiKey) {
|
||||
ModelProviderEntity p = new ModelProviderEntity();
|
||||
p.setProviderId(id);
|
||||
p.setIsLocal(false);
|
||||
p.setIsCustom(false);
|
||||
p.setRequireApiKey(requireApiKey);
|
||||
return p;
|
||||
}
|
||||
|
||||
private static ModelProviderEntity local(String id) {
|
||||
ModelProviderEntity p = new ModelProviderEntity();
|
||||
p.setProviderId(id);
|
||||
p.setIsLocal(true);
|
||||
p.setIsCustom(false);
|
||||
p.setRequireApiKey(false);
|
||||
return p;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,68 @@
|
||||
package vip.mate.llm.model;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* Pinpoint regression tests for {@link ModelFamily#detect(String)}.
|
||||
*
|
||||
* <p>Each new model family added here should pin its detect rule so accidental
|
||||
* code-style cleanups (e.g. reordering branches in {@code detect()}) can't
|
||||
* silently route a thinking model to {@link ModelFamily#STANDARD} and break
|
||||
* reasoning_effort propagation.
|
||||
*/
|
||||
class ModelFamilyTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("DeepSeek V4 (flash + pro) → DEEPSEEK_V4_REASONING with reasoning_effort enabled")
|
||||
void deepSeekV4_reasoning() {
|
||||
// Critical assertion: V4 differs from v3.2 deepseek-reasoner — V4 ACCEPTS
|
||||
// the reasoning_effort field, while v3.2 doesn't (DeepSeek API rejects it).
|
||||
// Routing V4 to DEEPSEEK_REASONER would suppress the field and forfeit
|
||||
// openclaw's documented thinking control.
|
||||
assertEquals(ModelFamily.DEEPSEEK_V4_REASONING, ModelFamily.detect("deepseek-v4-flash"));
|
||||
assertEquals(ModelFamily.DEEPSEEK_V4_REASONING, ModelFamily.detect("deepseek-v4-pro"));
|
||||
assertTrue(ModelFamily.DEEPSEEK_V4_REASONING.supportsReasoningEffort(),
|
||||
"V4 must accept reasoning_effort (key differentiator from v3.2 reasoner)");
|
||||
assertTrue(ModelFamily.DEEPSEEK_V4_REASONING.isThinking(),
|
||||
"V4 is a thinking family — DeepSeekV4ThinkingDecorator gates on this");
|
||||
assertFalse(ModelFamily.DEEPSEEK_V4_REASONING.fixedTemperatureOne(),
|
||||
"V4 allows configurable temperature (unlike v3.2 reasoner)");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Legacy deepseek-reasoner stays in DEEPSEEK_REASONER family (does not catch V4 rule)")
|
||||
void deepSeekReasoner_unchanged() {
|
||||
// Defensive: if the V4 detect rule were too broad (e.g. startsWith "deepseek-")
|
||||
// it would catch deepseek-reasoner too and break that model's working config.
|
||||
assertEquals(ModelFamily.DEEPSEEK_REASONER, ModelFamily.detect("deepseek-reasoner"));
|
||||
assertFalse(ModelFamily.DEEPSEEK_REASONER.supportsReasoningEffort(),
|
||||
"v3.2 reasoner must NOT advertise reasoning_effort support");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("deepseek-chat stays STANDARD")
|
||||
void deepSeekChat_standard() {
|
||||
// Smoke check: non-reasoning DeepSeek model unaffected.
|
||||
assertEquals(ModelFamily.STANDARD, ModelFamily.detect("deepseek-chat"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Case + whitespace tolerance — uppercased / padded model name routes the same")
|
||||
void detect_caseInsensitive() {
|
||||
assertEquals(ModelFamily.DEEPSEEK_V4_REASONING, ModelFamily.detect("DeepSeek-V4-Flash"));
|
||||
assertEquals(ModelFamily.DEEPSEEK_V4_REASONING, ModelFamily.detect(" deepseek-v4-pro "));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Null / blank model name → STANDARD (no NPE)")
|
||||
void detect_nullSafe() {
|
||||
assertEquals(ModelFamily.STANDARD, ModelFamily.detect(null));
|
||||
assertEquals(ModelFamily.STANDARD, ModelFamily.detect(""));
|
||||
assertEquals(ModelFamily.STANDARD, ModelFamily.detect(" "));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,325 @@
|
||||
package vip.mate.llm.oauth;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.web.client.MockRestServiceServer;
|
||||
import org.springframework.web.client.RestClient;
|
||||
import vip.mate.exception.MateClawException;
|
||||
import vip.mate.llm.oauth.OpenAIDeviceCodeService.DeviceCodePollResult;
|
||||
import vip.mate.llm.oauth.OpenAIDeviceCodeService.DeviceCodeStartResult;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.springframework.test.web.client.match.MockRestRequestMatchers.content;
|
||||
import static org.springframework.test.web.client.match.MockRestRequestMatchers.header;
|
||||
import static org.springframework.test.web.client.match.MockRestRequestMatchers.jsonPath;
|
||||
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
|
||||
import static org.springframework.test.web.client.response.MockRestResponseCreators.withStatus;
|
||||
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
|
||||
|
||||
/**
|
||||
* Unit tests for the device authorization grant flow.
|
||||
*
|
||||
* <p>{@link OpenAIDeviceCodeService} is exercised through a mocked OpenAI endpoint
|
||||
* (via {@link MockRestServiceServer}). The token exchange path
|
||||
* ({@code OpenAIOAuthService#exchangeTokenWithVerifier}) is mocked so we never
|
||||
* touch the database — we only verify it is invoked with the correct args.
|
||||
*/
|
||||
class OpenAIDeviceCodeServiceTest {
|
||||
|
||||
private OpenAIOAuthService oauthService;
|
||||
private OpenAIDeviceCodeService deviceCodeService;
|
||||
private MockRestServiceServer mockServer;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
oauthService = mock(OpenAIOAuthService.class);
|
||||
deviceCodeService = new OpenAIDeviceCodeService(oauthService, new ObjectMapper());
|
||||
|
||||
// Tighten config knobs so tests don't sleep
|
||||
setField(deviceCodeService, "pollMinIntervalMs", 0L);
|
||||
setField(deviceCodeService, "defaultSessionTtlSeconds", 900L);
|
||||
setField(deviceCodeService, "userAgent", "test-agent/0.0");
|
||||
|
||||
RestClient.Builder builder = RestClient.builder();
|
||||
mockServer = MockRestServiceServer.bindTo(builder).build();
|
||||
deviceCodeService.setRestClient(builder.build());
|
||||
}
|
||||
|
||||
private static void setField(Object target, String name, Object value) throws Exception {
|
||||
Field f = OpenAIDeviceCodeService.class.getDeclaredField(name);
|
||||
f.setAccessible(true);
|
||||
f.set(target, value);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// start()
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
@DisplayName("start sends JSON body with client_id and parses all response fields")
|
||||
void start_parsesAllFields() {
|
||||
mockServer.expect(requestTo(OpenAIDeviceCodeService.DEVICE_USERCODE_URL))
|
||||
.andExpect(header(org.springframework.http.HttpHeaders.CONTENT_TYPE,
|
||||
MediaType.APPLICATION_JSON_VALUE))
|
||||
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
|
||||
.andExpect(jsonPath("$.client_id").value(OpenAIDeviceCodeService.CLIENT_ID))
|
||||
.andRespond(withSuccess(
|
||||
"{\"device_auth_id\":\"dev-abc-123\","
|
||||
+ "\"user_code\":\"WXYZ-1234\","
|
||||
+ "\"interval\":7,"
|
||||
+ "\"expires_in\":600,"
|
||||
+ "\"verification_uri\":\"https://auth.openai.com/codex/device\","
|
||||
+ "\"verification_uri_complete\":\"https://auth.openai.com/codex/device?user_code=WXYZ-1234\"}",
|
||||
MediaType.APPLICATION_JSON));
|
||||
|
||||
DeviceCodeStartResult result = deviceCodeService.start();
|
||||
|
||||
assertEquals("dev-abc-123", result.deviceAuthId());
|
||||
assertEquals("WXYZ-1234", result.userCode());
|
||||
assertEquals(7, result.intervalSeconds());
|
||||
assertEquals(600, result.expiresInSeconds());
|
||||
assertEquals("https://auth.openai.com/codex/device", result.verificationUrl());
|
||||
assertEquals("https://auth.openai.com/codex/device?user_code=WXYZ-1234",
|
||||
result.verificationUrlComplete());
|
||||
assertEquals(1, deviceCodeService.activeSessionCount());
|
||||
|
||||
mockServer.verify();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("start defaults verification URL when not returned by OpenAI")
|
||||
void start_defaultsVerificationUrl() {
|
||||
mockServer.expect(requestTo(OpenAIDeviceCodeService.DEVICE_USERCODE_URL))
|
||||
.andRespond(withSuccess(
|
||||
"{\"device_auth_id\":\"d1\",\"user_code\":\"AB-CD\","
|
||||
+ "\"interval\":5,\"expires_in\":300}",
|
||||
MediaType.APPLICATION_JSON));
|
||||
|
||||
DeviceCodeStartResult result = deviceCodeService.start();
|
||||
assertEquals(OpenAIDeviceCodeService.DEFAULT_VERIFICATION_URL, result.verificationUrl());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("start throws MateClawException on transport failure")
|
||||
void start_propagatesTransportFailures() {
|
||||
mockServer.expect(requestTo(OpenAIDeviceCodeService.DEVICE_USERCODE_URL))
|
||||
.andRespond(withStatus(HttpStatus.SERVICE_UNAVAILABLE));
|
||||
|
||||
MateClawException ex = assertThrows(MateClawException.class,
|
||||
() -> deviceCodeService.start());
|
||||
assertEquals("err.llm.device_code_start_failed", ex.getMsgKey());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("start throws when response is missing required fields")
|
||||
void start_rejectsIncompleteResponse() {
|
||||
mockServer.expect(requestTo(OpenAIDeviceCodeService.DEVICE_USERCODE_URL))
|
||||
.andRespond(withSuccess("{\"interval\":5}", MediaType.APPLICATION_JSON));
|
||||
|
||||
MateClawException ex = assertThrows(MateClawException.class,
|
||||
() -> deviceCodeService.start());
|
||||
assertEquals("err.llm.device_code_start_failed", ex.getMsgKey());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// poll()
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
@DisplayName("poll returns EXPIRED for unknown session")
|
||||
void poll_unknownSessionExpired() {
|
||||
assertEquals(DeviceCodePollResult.Status.EXPIRED,
|
||||
deviceCodeService.poll("not-a-real-session").status());
|
||||
assertEquals(DeviceCodePollResult.Status.EXPIRED,
|
||||
deviceCodeService.poll(null).status());
|
||||
assertEquals(DeviceCodePollResult.Status.EXPIRED,
|
||||
deviceCodeService.poll("").status());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("poll sends JSON body and returns PENDING for HTTP 403 (user has not finished yet)")
|
||||
void poll_403MapsToPending() {
|
||||
expectStart("dev-1", "USER-1");
|
||||
mockServer.expect(requestTo(OpenAIDeviceCodeService.DEVICE_TOKEN_URL))
|
||||
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
|
||||
.andExpect(jsonPath("$.device_auth_id").value("dev-1"))
|
||||
.andExpect(jsonPath("$.user_code").value("USER-1"))
|
||||
.andRespond(withStatus(HttpStatus.FORBIDDEN));
|
||||
|
||||
deviceCodeService.start();
|
||||
assertEquals(DeviceCodePollResult.Status.PENDING,
|
||||
deviceCodeService.poll("dev-1").status());
|
||||
verifyNoInteractions(oauthService);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("poll returns PENDING for HTTP 404 (per OpenAI deviceauth contract)")
|
||||
void poll_404MapsToPending() {
|
||||
expectStart("dev-1b", "USER-1B");
|
||||
mockServer.expect(requestTo(OpenAIDeviceCodeService.DEVICE_TOKEN_URL))
|
||||
.andRespond(withStatus(HttpStatus.NOT_FOUND));
|
||||
|
||||
deviceCodeService.start();
|
||||
assertEquals(DeviceCodePollResult.Status.PENDING,
|
||||
deviceCodeService.poll("dev-1b").status());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("poll still maps RFC 8628 400+authorization_pending to PENDING for forward-compat")
|
||||
void poll_rfcAuthorizationPendingMapsToPending() {
|
||||
expectStart("dev-1c", "USER-1C");
|
||||
mockServer.expect(requestTo(OpenAIDeviceCodeService.DEVICE_TOKEN_URL))
|
||||
.andRespond(withStatus(HttpStatus.BAD_REQUEST)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body("{\"error\":\"authorization_pending\"}"));
|
||||
|
||||
deviceCodeService.start();
|
||||
assertEquals(DeviceCodePollResult.Status.PENDING,
|
||||
deviceCodeService.poll("dev-1c").status());
|
||||
verifyNoInteractions(oauthService);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("poll returns PENDING when OpenAI replies 400 slow_down")
|
||||
void poll_slowDownMapsToPending() {
|
||||
expectStart("dev-2", "USER-2");
|
||||
mockServer.expect(requestTo(OpenAIDeviceCodeService.DEVICE_TOKEN_URL))
|
||||
.andRespond(withStatus(HttpStatus.BAD_REQUEST)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body("{\"error\":\"slow_down\"}"));
|
||||
|
||||
deviceCodeService.start();
|
||||
assertEquals(DeviceCodePollResult.Status.PENDING,
|
||||
deviceCodeService.poll("dev-2").status());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("poll returns EXPIRED + drops session when OpenAI replies 400 expired_token")
|
||||
void poll_expiredTokenDropsSession() {
|
||||
expectStart("dev-3", "USER-3");
|
||||
mockServer.expect(requestTo(OpenAIDeviceCodeService.DEVICE_TOKEN_URL))
|
||||
.andRespond(withStatus(HttpStatus.BAD_REQUEST)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body("{\"error\":\"expired_token\"}"));
|
||||
|
||||
deviceCodeService.start();
|
||||
assertEquals(DeviceCodePollResult.Status.EXPIRED,
|
||||
deviceCodeService.poll("dev-3").status());
|
||||
// session was removed — next poll returns EXPIRED without hitting the network
|
||||
assertEquals(DeviceCodePollResult.Status.EXPIRED,
|
||||
deviceCodeService.poll("dev-3").status());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("poll returns EXPIRED when user denies access")
|
||||
void poll_accessDeniedDropsSession() {
|
||||
expectStart("dev-4", "USER-4");
|
||||
mockServer.expect(requestTo(OpenAIDeviceCodeService.DEVICE_TOKEN_URL))
|
||||
.andRespond(withStatus(HttpStatus.BAD_REQUEST)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body("{\"error\":\"access_denied\"}"));
|
||||
|
||||
deviceCodeService.start();
|
||||
assertEquals(DeviceCodePollResult.Status.EXPIRED,
|
||||
deviceCodeService.poll("dev-4").status());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("poll returns COMPLETED + invokes token exchange when authorization_code arrives")
|
||||
void poll_completedExchangesToken() {
|
||||
expectStart("dev-5", "USER-5");
|
||||
mockServer.expect(requestTo(OpenAIDeviceCodeService.DEVICE_TOKEN_URL))
|
||||
.andRespond(withSuccess(
|
||||
"{\"authorization_code\":\"auth-code-xyz\","
|
||||
+ "\"code_verifier\":\"verifier-xyz\"}",
|
||||
MediaType.APPLICATION_JSON));
|
||||
|
||||
deviceCodeService.start();
|
||||
DeviceCodePollResult result = deviceCodeService.poll("dev-5");
|
||||
|
||||
assertEquals(DeviceCodePollResult.Status.COMPLETED, result.status());
|
||||
verify(oauthService).exchangeTokenWithVerifier(
|
||||
eq("auth-code-xyz"),
|
||||
eq("verifier-xyz"),
|
||||
eq(OpenAIDeviceCodeService.DEVICE_REDIRECT_URI));
|
||||
assertEquals(0, deviceCodeService.activeSessionCount());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("poll keeps session and returns PENDING when 200 body has no authorization_code")
|
||||
void poll_inlinePendingErrorMapsToPending() {
|
||||
expectStart("dev-6", "USER-6");
|
||||
// Some flavours of the endpoint reply 200 with {error: authorization_pending}
|
||||
mockServer.expect(requestTo(OpenAIDeviceCodeService.DEVICE_TOKEN_URL))
|
||||
.andRespond(withSuccess(
|
||||
"{\"error\":\"authorization_pending\"}",
|
||||
MediaType.APPLICATION_JSON));
|
||||
|
||||
deviceCodeService.start();
|
||||
assertEquals(DeviceCodePollResult.Status.PENDING,
|
||||
deviceCodeService.poll("dev-6").status());
|
||||
assertEquals(1, deviceCodeService.activeSessionCount());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("poll returns EXPIRED when authorization_code present but code_verifier missing")
|
||||
void poll_missingCodeVerifierDropsSession() {
|
||||
expectStart("dev-7", "USER-7");
|
||||
mockServer.expect(requestTo(OpenAIDeviceCodeService.DEVICE_TOKEN_URL))
|
||||
.andRespond(withSuccess(
|
||||
"{\"authorization_code\":\"only-code\"}",
|
||||
MediaType.APPLICATION_JSON));
|
||||
|
||||
deviceCodeService.start();
|
||||
assertEquals(DeviceCodePollResult.Status.EXPIRED,
|
||||
deviceCodeService.poll("dev-7").status());
|
||||
verifyNoInteractions(oauthService);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// cancel()
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
@DisplayName("cancel removes the session so subsequent poll returns EXPIRED")
|
||||
void cancel_dropsSession() {
|
||||
expectStart("dev-cancel", "USER-CANCEL");
|
||||
|
||||
deviceCodeService.start();
|
||||
assertEquals(1, deviceCodeService.activeSessionCount());
|
||||
|
||||
deviceCodeService.cancel("dev-cancel");
|
||||
assertEquals(0, deviceCodeService.activeSessionCount());
|
||||
assertEquals(DeviceCodePollResult.Status.EXPIRED,
|
||||
deviceCodeService.poll("dev-cancel").status());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("cancel handles null/missing IDs without throwing")
|
||||
void cancel_nullSafe() {
|
||||
assertDoesNotThrow(() -> deviceCodeService.cancel(null));
|
||||
assertDoesNotThrow(() -> deviceCodeService.cancel("never-existed"));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// helpers
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/** Register the usercode-endpoint expectation; caller must invoke start() afterwards. */
|
||||
private void expectStart(String deviceAuthId, String userCode) {
|
||||
mockServer.expect(requestTo(OpenAIDeviceCodeService.DEVICE_USERCODE_URL))
|
||||
.andRespond(withSuccess(
|
||||
"{\"device_auth_id\":\"" + deviceAuthId + "\","
|
||||
+ "\"user_code\":\"" + userCode + "\","
|
||||
+ "\"interval\":5,\"expires_in\":900}",
|
||||
MediaType.APPLICATION_JSON));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,180 @@
|
||||
package vip.mate.llm.oauth;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import vip.mate.exception.MateClawException;
|
||||
import vip.mate.llm.oauth.OpenAIOAuthService.OAuthFlowMode;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* Issue: OAuth callback fails on Linux server deployment because the
|
||||
* redirect_uri is hardcoded to http://localhost:1455/auth/callback. When the
|
||||
* user's browser hits this URL it tries to reach the user's own machine, not
|
||||
* the remote MateClaw server, so the auth code never reaches the server.
|
||||
*
|
||||
* <p>Tests focus on the deployment-mode resolution logic (Host header heuristic
|
||||
* + config override + paste-URL parser). Network-bound paths (token exchange,
|
||||
* Keychain reads) are out of scope here — they need either a wiremock or live
|
||||
* fixtures.
|
||||
*/
|
||||
class OpenAIOAuthServiceFlowModeTest {
|
||||
|
||||
private OpenAIOAuthService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
// null collaborators OK because the helpers we exercise (resolveFlowMode,
|
||||
// completeFromPastedUrl up to state validation) don't touch them. The
|
||||
// compile-time @RequiredArgsConstructor accepts nulls.
|
||||
service = new OpenAIOAuthService(null, new ObjectMapper(), null);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void clearOverride() {
|
||||
System.clearProperty("mateclaw.oauth.openai.deployment-mode");
|
||||
}
|
||||
|
||||
// ============== resolveFlowMode (private — accessed via reflection) ===
|
||||
|
||||
private OAuthFlowMode invokeResolve(String host) throws Exception {
|
||||
Method m = OpenAIOAuthService.class.getDeclaredMethod("resolveFlowMode", String.class);
|
||||
m.setAccessible(true);
|
||||
return (OAuthFlowMode) m.invoke(service, host);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("localhost variants resolve to LOCAL mode")
|
||||
void localhostHosts_resolveToLocal() throws Exception {
|
||||
assertEquals(OAuthFlowMode.LOCAL, invokeResolve("localhost"));
|
||||
assertEquals(OAuthFlowMode.LOCAL, invokeResolve("localhost:18088"));
|
||||
assertEquals(OAuthFlowMode.LOCAL, invokeResolve("127.0.0.1"));
|
||||
assertEquals(OAuthFlowMode.LOCAL, invokeResolve("127.0.0.1:18088"));
|
||||
assertEquals(OAuthFlowMode.LOCAL, invokeResolve("LocalHost")); // case-insensitive
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("public hosts resolve to DEVICE_CODE (browser-agnostic, no callback server needed)")
|
||||
void publicHosts_resolveToDeviceCode() throws Exception {
|
||||
assertEquals(OAuthFlowMode.DEVICE_CODE, invokeResolve("mateclaw.example.com"));
|
||||
assertEquals(OAuthFlowMode.DEVICE_CODE, invokeResolve("api.mate.vip"));
|
||||
assertEquals(OAuthFlowMode.DEVICE_CODE, invokeResolve("api.mate.vip:443"));
|
||||
assertEquals(OAuthFlowMode.DEVICE_CODE, invokeResolve("192.168.1.10"),
|
||||
"private LAN IP — not localhost, browser still won't reach server's localhost");
|
||||
assertEquals(OAuthFlowMode.DEVICE_CODE, invokeResolve("10.0.0.5:8080"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("null/blank host falls back to LOCAL (legacy behaviour preservation)")
|
||||
void nullOrBlankHost_legacyLocal() throws Exception {
|
||||
assertEquals(OAuthFlowMode.LOCAL, invokeResolve(null));
|
||||
assertEquals(OAuthFlowMode.LOCAL, invokeResolve(""));
|
||||
assertEquals(OAuthFlowMode.LOCAL, invokeResolve(" "));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("config override mateclaw.oauth.openai.deployment-mode=local forces LOCAL even on remote host")
|
||||
void configOverride_forcesLocal() throws Exception {
|
||||
System.setProperty("mateclaw.oauth.openai.deployment-mode", "local");
|
||||
assertEquals(OAuthFlowMode.LOCAL, invokeResolve("mateclaw.example.com"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("config override =device_code forces DEVICE_CODE even on localhost")
|
||||
void configOverride_forcesDeviceCode() throws Exception {
|
||||
System.setProperty("mateclaw.oauth.openai.deployment-mode", "device_code");
|
||||
assertEquals(OAuthFlowMode.DEVICE_CODE, invokeResolve("localhost"));
|
||||
|
||||
// 'server' kept as alias for backwards compatibility (now points to DEVICE_CODE)
|
||||
System.setProperty("mateclaw.oauth.openai.deployment-mode", "server");
|
||||
assertEquals(OAuthFlowMode.DEVICE_CODE, invokeResolve("localhost"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("config override =manual_paste forces MANUAL_PASTE")
|
||||
void configOverride_forcesManualPaste() throws Exception {
|
||||
System.setProperty("mateclaw.oauth.openai.deployment-mode", "manual_paste");
|
||||
assertEquals(OAuthFlowMode.MANUAL_PASTE, invokeResolve("localhost"));
|
||||
assertEquals(OAuthFlowMode.MANUAL_PASTE, invokeResolve("api.mate.vip"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("config override 'auto' or unknown falls back to heuristic")
|
||||
void configOverride_autoFallsThrough() throws Exception {
|
||||
System.setProperty("mateclaw.oauth.openai.deployment-mode", "auto");
|
||||
assertEquals(OAuthFlowMode.LOCAL, invokeResolve("localhost"));
|
||||
assertEquals(OAuthFlowMode.DEVICE_CODE, invokeResolve("api.mate.vip"));
|
||||
|
||||
System.setProperty("mateclaw.oauth.openai.deployment-mode", "garbage");
|
||||
assertEquals(OAuthFlowMode.LOCAL, invokeResolve("localhost"));
|
||||
}
|
||||
|
||||
// ============== completeFromPastedUrl ================================
|
||||
|
||||
@Test
|
||||
@DisplayName("completeFromPastedUrl rejects empty / null input")
|
||||
void pastedUrl_emptyRejected() {
|
||||
assertThrows(MateClawException.class, () -> service.completeFromPastedUrl(null));
|
||||
assertThrows(MateClawException.class, () -> service.completeFromPastedUrl(""));
|
||||
assertThrows(MateClawException.class, () -> service.completeFromPastedUrl(" "));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("completeFromPastedUrl rejects URL without query string")
|
||||
void pastedUrl_noQueryRejected() {
|
||||
MateClawException ex = assertThrows(MateClawException.class,
|
||||
() -> service.completeFromPastedUrl("http://localhost:1455/auth/callback"));
|
||||
assertTrue(ex.getMessage().contains("查询参数"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("completeFromPastedUrl rejects URL missing code")
|
||||
void pastedUrl_missingCode() {
|
||||
MateClawException ex = assertThrows(MateClawException.class,
|
||||
() -> service.completeFromPastedUrl(
|
||||
"http://localhost:1455/auth/callback?state=xyz"));
|
||||
assertTrue(ex.getMessage().contains("code"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("completeFromPastedUrl rejects URL missing state")
|
||||
void pastedUrl_missingState() {
|
||||
MateClawException ex = assertThrows(MateClawException.class,
|
||||
() -> service.completeFromPastedUrl(
|
||||
"http://localhost:1455/auth/callback?code=abc"));
|
||||
assertTrue(ex.getMessage().contains("state"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("completeFromPastedUrl strips fragment after #")
|
||||
void pastedUrl_stripsFragment() {
|
||||
// Should successfully extract code and state, but throw because
|
||||
// state isn't in pendingStates map (no real authorize was called).
|
||||
// We're verifying the parser gets past the parsing stage.
|
||||
MateClawException ex = assertThrows(MateClawException.class,
|
||||
() -> service.completeFromPastedUrl(
|
||||
"http://localhost:1455/auth/callback?code=abc&state=xyz#fragment"));
|
||||
// The error must be from exchangeToken (state not in pendingStates),
|
||||
// not from a parsing failure.
|
||||
assertTrue(ex.getMsgKey() != null && ex.getMsgKey().contains("oauth_state_invalid"),
|
||||
"Expected state validation failure (parser succeeded), got: " + ex.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("completeFromPastedUrl handles URL-encoded code values")
|
||||
void pastedUrl_handlesEncodedValues() {
|
||||
// The exchangeToken stage will fail, but parser must have decoded
|
||||
// the percent-encoded characters before getting there.
|
||||
MateClawException ex = assertThrows(MateClawException.class,
|
||||
() -> service.completeFromPastedUrl(
|
||||
"http://localhost:1455/auth/callback?code=abc%2B123&state=test%3Dvalue"));
|
||||
// Should fail at state validation, not parsing
|
||||
assertTrue(ex.getMsgKey() != null && ex.getMsgKey().contains("oauth_state_invalid"),
|
||||
"Parser should accept percent-encoded values; got: " + ex.getMessage());
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,207 @@
|
||||
package vip.mate.llm.routing;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import vip.mate.llm.model.ModelConfigEntity;
|
||||
import vip.mate.llm.routing.model.MultimodalRoutingDecision;
|
||||
import vip.mate.llm.service.ModelCapabilityService;
|
||||
import vip.mate.llm.service.ModelCapabilityService.Modality;
|
||||
import vip.mate.llm.service.ModelConfigService;
|
||||
import vip.mate.system.model.SystemSettingsDTO;
|
||||
import vip.mate.system.service.SystemSettingService;
|
||||
import vip.mate.workspace.conversation.model.MessageContentPart;
|
||||
|
||||
import java.util.EnumSet;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class MultimodalRouterTest {
|
||||
|
||||
@Mock
|
||||
private SystemSettingService systemSettingService;
|
||||
|
||||
@Mock
|
||||
private ModelConfigService modelConfigService;
|
||||
|
||||
@Mock
|
||||
private ModelCapabilityService capabilityService;
|
||||
|
||||
@InjectMocks
|
||||
private MultimodalRouter router;
|
||||
|
||||
private SystemSettingsDTO settings;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
settings = new SystemSettingsDTO();
|
||||
lenient().when(systemSettingService.getSettings()).thenReturn(settings);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("No attachments → strategy NONE, no reads to settings")
|
||||
void noAttachmentsReturnsNone() {
|
||||
// No capabilityService stubbing here — the router must short-circuit before
|
||||
// touching capabilities when no attachments are present.
|
||||
MultimodalRoutingDecision decision = router.route(
|
||||
List.of(), chatModel("deepseek", "deepseek-chat", null));
|
||||
assertEquals(MultimodalRoutingDecision.Strategy.NONE, decision.strategy());
|
||||
assertTrue(decision.skipped().isEmpty());
|
||||
assertNull(decision.sidecarModel());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Primary already supports vision → strategy NONE")
|
||||
void primaryCoversVisionReturnsNone() {
|
||||
ModelConfigEntity primary = chatModel("zhipu", "glm-4v", "[\"vision\"]");
|
||||
when(capabilityService.resolve("glm-4v", "[\"vision\"]"))
|
||||
.thenReturn(EnumSet.of(Modality.VISION));
|
||||
|
||||
MultimodalRoutingDecision decision = router.route(List.of(imagePart("a.png")), primary);
|
||||
assertEquals(MultimodalRoutingDecision.Strategy.NONE, decision.strategy());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Image attachment + text-only primary + configured vision sidecar → SIDECAR")
|
||||
void textPrimaryImageWithSidecarConfigured() {
|
||||
ModelConfigEntity primary = chatModel("deepseek", "deepseek-chat", null);
|
||||
ModelConfigEntity vision = chatModel("zhipu", "glm-4v", "[\"vision\"]");
|
||||
vision.setId(42L);
|
||||
vision.setEnabled(true);
|
||||
|
||||
when(capabilityService.resolve("deepseek-chat", null))
|
||||
.thenReturn(EnumSet.of(Modality.TEXT));
|
||||
settings.setDefaultVisionModelId(42L);
|
||||
when(modelConfigService.getModel(42L)).thenReturn(vision);
|
||||
when(capabilityService.supports(eq("glm-4v"), eq("[\"vision\"]"), eq(Modality.VISION)))
|
||||
.thenReturn(true);
|
||||
|
||||
MultimodalRoutingDecision decision = router.route(List.of(imagePart("a.png")), primary);
|
||||
|
||||
assertEquals(MultimodalRoutingDecision.Strategy.SIDECAR, decision.strategy());
|
||||
assertNotNull(decision.sidecarModel());
|
||||
assertEquals(42L, decision.sidecarModel().getId());
|
||||
assertTrue(decision.skipped().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Image + text-only primary + sidecar NOT configured → NONE with skipped reason")
|
||||
void textPrimaryImageNoSidecar() {
|
||||
ModelConfigEntity primary = chatModel("deepseek", "deepseek-chat", null);
|
||||
when(capabilityService.resolve("deepseek-chat", null))
|
||||
.thenReturn(EnumSet.of(Modality.TEXT));
|
||||
settings.setDefaultVisionModelId(null);
|
||||
|
||||
MultimodalRoutingDecision decision = router.route(List.of(imagePart("a.png")), primary);
|
||||
|
||||
assertEquals(MultimodalRoutingDecision.Strategy.NONE, decision.strategy());
|
||||
assertEquals(1, decision.skipped().size());
|
||||
assertEquals("vision_model_not_configured", decision.skipped().get(0).reason());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Image + sidecar configured but model disabled → NONE with vision_model_unavailable")
|
||||
void textPrimaryImageSidecarDisabled() {
|
||||
ModelConfigEntity primary = chatModel("deepseek", "deepseek-chat", null);
|
||||
ModelConfigEntity vision = chatModel("zhipu", "glm-4v", "[\"vision\"]");
|
||||
vision.setId(42L);
|
||||
vision.setEnabled(false);
|
||||
|
||||
when(capabilityService.resolve("deepseek-chat", null))
|
||||
.thenReturn(EnumSet.of(Modality.TEXT));
|
||||
settings.setDefaultVisionModelId(42L);
|
||||
when(modelConfigService.getModel(42L)).thenReturn(vision);
|
||||
|
||||
MultimodalRoutingDecision decision = router.route(List.of(imagePart("a.png")), primary);
|
||||
|
||||
assertEquals(MultimodalRoutingDecision.Strategy.NONE, decision.strategy());
|
||||
assertEquals("vision_model_unavailable", decision.skipped().get(0).reason());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Video attachment never sidecarred in v1 → NONE with reserved reason")
|
||||
void videoAttachmentSkippedInV1() {
|
||||
ModelConfigEntity primary = chatModel("deepseek", "deepseek-chat", null);
|
||||
when(capabilityService.resolve("deepseek-chat", null))
|
||||
.thenReturn(EnumSet.of(Modality.TEXT));
|
||||
|
||||
MultimodalRoutingDecision decision = router.route(List.of(videoPart("b.mp4")), primary);
|
||||
assertEquals(MultimodalRoutingDecision.Strategy.NONE, decision.strategy());
|
||||
assertEquals(1, decision.skipped().size());
|
||||
assertEquals("video_sidecar_not_supported_in_v1", decision.skipped().get(0).reason());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Configured sidecar that does not actually support VISION → fallback to NONE")
|
||||
void sidecarLacksClaimedCapability() {
|
||||
ModelConfigEntity primary = chatModel("deepseek", "deepseek-chat", null);
|
||||
ModelConfigEntity vision = chatModel("acme", "acme-chat", "[]");
|
||||
vision.setId(42L);
|
||||
vision.setEnabled(true);
|
||||
|
||||
when(capabilityService.resolve("deepseek-chat", null))
|
||||
.thenReturn(EnumSet.of(Modality.TEXT));
|
||||
settings.setDefaultVisionModelId(42L);
|
||||
when(modelConfigService.getModel(42L)).thenReturn(vision);
|
||||
when(capabilityService.supports(anyString(), anyString(), eq(Modality.VISION)))
|
||||
.thenReturn(false);
|
||||
|
||||
MultimodalRoutingDecision decision = router.route(List.of(imagePart("a.png")), primary);
|
||||
|
||||
assertEquals(MultimodalRoutingDecision.Strategy.NONE, decision.strategy());
|
||||
assertEquals("vision_model_unavailable", decision.skipped().get(0).reason());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Null primary → routing returns SIDECAR if vision configured, else NONE")
|
||||
void nullPrimaryHonorsSidecarConfig() {
|
||||
ModelConfigEntity vision = chatModel("zhipu", "glm-4v", "[\"vision\"]");
|
||||
vision.setId(42L);
|
||||
vision.setEnabled(true);
|
||||
settings.setDefaultVisionModelId(42L);
|
||||
when(modelConfigService.getModel(42L)).thenReturn(vision);
|
||||
when(capabilityService.supports(anyString(), anyString(), eq(Modality.VISION))).thenReturn(true);
|
||||
|
||||
MultimodalRoutingDecision decision = router.route(List.of(imagePart("a.png")), null);
|
||||
assertEquals(MultimodalRoutingDecision.Strategy.SIDECAR, decision.strategy());
|
||||
}
|
||||
|
||||
private static MessageContentPart imagePart(String fileName) {
|
||||
MessageContentPart part = new MessageContentPart();
|
||||
part.setType("image");
|
||||
part.setContentType("image/png");
|
||||
part.setFileName(fileName);
|
||||
return part;
|
||||
}
|
||||
|
||||
private static MessageContentPart videoPart(String fileName) {
|
||||
MessageContentPart part = new MessageContentPart();
|
||||
part.setType("video");
|
||||
part.setContentType("video/mp4");
|
||||
part.setFileName(fileName);
|
||||
return part;
|
||||
}
|
||||
|
||||
private static ModelConfigEntity chatModel(String provider, String modelName, String modalitiesJson) {
|
||||
ModelConfigEntity m = new ModelConfigEntity();
|
||||
m.setProvider(provider);
|
||||
m.setModelName(modelName);
|
||||
m.setModalities(modalitiesJson);
|
||||
m.setEnabled(true);
|
||||
return m;
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,261 @@
|
||||
package vip.mate.llm.service;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import vip.mate.llm.service.ModelCapabilityService.Modality;
|
||||
|
||||
import java.util.EnumSet;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* Pinpoint regression tests for {@link ModelCapabilityService}.
|
||||
*
|
||||
* <p>Per-model granularity is the whole point — the prior hardcoded
|
||||
* {@code n.contains("glm") && n.contains("v")} matcher (issue #44) collapsed
|
||||
* {@code glm-4v} and {@code glm-4v-plus} into the same bucket even though only
|
||||
* the latter accepts video. The cases below pin that boundary so a future
|
||||
* "let's just add another contains() rule" cleanup can't bring the bug back.
|
||||
*/
|
||||
class ModelCapabilityServiceTest {
|
||||
|
||||
private final ModelCapabilityService service = new ModelCapabilityService();
|
||||
|
||||
// ---------- Heuristic table: per-model granularity ----------
|
||||
|
||||
@Test
|
||||
@DisplayName("glm-4v-plus → VIDEO; glm-4v → no VIDEO (issue #44 root cause)")
|
||||
void glm4v_videoCapabilityDiffers() {
|
||||
assertTrue(service.supports("glm-4v-plus", null, Modality.VIDEO),
|
||||
"glm-4v-plus is multimodal incl. video");
|
||||
assertFalse(service.supports("glm-4v", null, Modality.VIDEO),
|
||||
"plain glm-4v is image-only — must NOT pass through video Media");
|
||||
assertFalse(service.supports("glm-4v-flash", null, Modality.VIDEO),
|
||||
"glm-4v-flash is image-only");
|
||||
// All three still support vision
|
||||
assertTrue(service.supports("glm-4v-plus", null, Modality.VISION));
|
||||
assertTrue(service.supports("glm-4v", null, Modality.VISION));
|
||||
assertTrue(service.supports("glm-4v-flash", null, Modality.VISION));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("glm-5v-turbo / glm-4.5v / glm-4.1v lines all support VIDEO")
|
||||
void glmNewGenerations_supportVideo() {
|
||||
// glm-5v-turbo regression: original heuristic table only had glm-4v lineage,
|
||||
// so a user uploading a video to glm-5v-turbo got a "model unsupported" notice
|
||||
// even though Zhipu's 5V line is built for video understanding.
|
||||
assertTrue(service.supports("glm-5v-turbo", null, Modality.VIDEO),
|
||||
"glm-5v-turbo is Zhipu's video-understanding model — must accept video");
|
||||
assertTrue(service.supports("glm-5v-flash", null, Modality.VIDEO));
|
||||
assertTrue(service.supports("glm-5v", null, Modality.VIDEO));
|
||||
assertTrue(service.supports("glm-4.5v", null, Modality.VIDEO));
|
||||
assertTrue(service.supports("glm-4.1v-thinking-flashx", null, Modality.VIDEO));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Longest-prefix-wins: glm-4v-plus does NOT degrade to glm-4v entry")
|
||||
void longestPrefixWins() {
|
||||
// If matcher used shortest-or-first, "glm-4v-plus" might match the "glm-4v" entry
|
||||
// first and lose its VIDEO modality. Pin the iteration order independence.
|
||||
EnumSet<Modality> caps = service.resolve("glm-4v-plus", null);
|
||||
assertTrue(caps.contains(Modality.VIDEO), "longest prefix glm-4v-plus must win");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Qwen-VL family: max → VIDEO, plus → image-only")
|
||||
void qwenVl_familyDiffers() {
|
||||
assertTrue(service.supports("qwen-vl-max", null, Modality.VIDEO));
|
||||
assertFalse(service.supports("qwen-vl-plus", null, Modality.VIDEO));
|
||||
assertTrue(service.supports("qwen-vl-plus", null, Modality.VISION));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Qwen omni line accepts vision + video + audio")
|
||||
void qwenOmni_fullyMultimodal() {
|
||||
EnumSet<Modality> caps = service.resolve("qwen3-omni", null);
|
||||
assertTrue(caps.contains(Modality.VISION));
|
||||
assertTrue(caps.contains(Modality.VIDEO));
|
||||
assertTrue(caps.contains(Modality.AUDIO));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("OpenAI: vision yes across the line, but native video NO (API limitation)")
|
||||
void openai_neverNativeVideo() {
|
||||
// The Chat Completions / Responses APIs do not accept video files for any
|
||||
// OpenAI model as of 2026-04. Granting VIDEO would cause patchVideoMediaContent
|
||||
// to send video_url, and OpenAI would 400. Pin this so a future "marketing-led"
|
||||
// table edit can't silently re-introduce the failure mode.
|
||||
assertTrue(service.supports("gpt-5", null, Modality.VISION));
|
||||
assertTrue(service.supports("gpt-4.1", null, Modality.VISION));
|
||||
assertTrue(service.supports("gpt-4o", null, Modality.VISION));
|
||||
assertTrue(service.supports("gpt-4o-mini", null, Modality.VISION));
|
||||
assertFalse(service.supports("gpt-5", null, Modality.VIDEO));
|
||||
assertFalse(service.supports("gpt-4.1", null, Modality.VIDEO));
|
||||
assertFalse(service.supports("gpt-4o", null, Modality.VIDEO));
|
||||
assertFalse(service.supports("gpt-4o-mini", null, Modality.VIDEO));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("DeepSeek V4 / V4-Pro → VIDEO; V3 (text-only) gets nothing")
|
||||
void deepseekV4_supportsVideo() {
|
||||
// DeepSeek V4 (Apr 2026) introduced native multimodal incl. video to the line.
|
||||
// V3 and earlier remain text-only and must NOT match the V4 entry.
|
||||
assertTrue(service.supports("deepseek-v4", null, Modality.VIDEO));
|
||||
assertTrue(service.supports("deepseek-v4-pro", null, Modality.VIDEO));
|
||||
assertTrue(service.supports("deepseek-v4-flash", null, Modality.VIDEO));
|
||||
assertFalse(service.supports("deepseek-v3", null, Modality.VIDEO),
|
||||
"V3 must NOT inherit V4 capabilities — text-only base differs from V4 entirely");
|
||||
assertFalse(service.supports("deepseek-v3.2", null, Modality.VIDEO));
|
||||
assertFalse(service.supports("deepseek-r1", null, Modality.VIDEO));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Qwen3-VL (all sizes) and Qwen3.5-Omni support VIDEO")
|
||||
void qwen3Generation_supportsVideo() {
|
||||
assertTrue(service.supports("qwen3-vl-8b-instruct", null, Modality.VIDEO));
|
||||
assertTrue(service.supports("qwen3-vl-235b-a22b", null, Modality.VIDEO));
|
||||
assertTrue(service.supports("qwen3.5-omni", null, Modality.VIDEO));
|
||||
assertTrue(service.supports("qwen3.5-omni", null, Modality.AUDIO));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Moonshot Kimi K2.6 → VIDEO; K2.5 → image only")
|
||||
void kimiK26_supportsVideo() {
|
||||
assertTrue(service.supports("kimi-k2.6", null, Modality.VIDEO));
|
||||
assertFalse(service.supports("kimi-k2.5", null, Modality.VIDEO));
|
||||
assertTrue(service.supports("kimi-k2.5", null, Modality.VISION));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("ByteDance Doubao Seed 2.0 supports VIDEO")
|
||||
void doubaoSeed2_supportsVideo() {
|
||||
assertTrue(service.supports("doubao-seed-2.0-pro", null, Modality.VIDEO));
|
||||
assertTrue(service.supports("doubao-seed-2.0", null, Modality.VIDEO));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Gemini 2.5 (pro/flash/flash-lite) is fully multimodal")
|
||||
void gemini25_fullyMultimodal() {
|
||||
assertTrue(service.supports("gemini-2.5-pro", null, Modality.VIDEO));
|
||||
assertTrue(service.supports("gemini-2.5-flash", null, Modality.VIDEO));
|
||||
assertTrue(service.supports("gemini-2.5-flash-lite", null, Modality.VIDEO));
|
||||
assertTrue(service.supports("gemini-2.5-flash", null, Modality.AUDIO));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Claude family: vision yes, native video no")
|
||||
void claude_visionOnly() {
|
||||
assertTrue(service.supports("claude-3.7-sonnet", null, Modality.VISION));
|
||||
assertTrue(service.supports("claude-opus-4-5", null, Modality.VISION));
|
||||
assertFalse(service.supports("claude-3.7-sonnet", null, Modality.VIDEO),
|
||||
"Claude does not natively ingest video frames");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Unknown model name: only TEXT, no vision/video/audio")
|
||||
void unknownModel_textOnly() {
|
||||
EnumSet<Modality> caps = service.resolve("totally-made-up-model-9000", null);
|
||||
assertEquals(EnumSet.of(Modality.TEXT), caps,
|
||||
"unknown model must default to text-only — failsafe for issue #44 silent skip");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Null/blank model name resolves cleanly to TEXT only")
|
||||
void nullModelName_safe() {
|
||||
assertEquals(EnumSet.of(Modality.TEXT), service.resolve(null, null));
|
||||
assertEquals(EnumSet.of(Modality.TEXT), service.resolve("", null));
|
||||
assertEquals(EnumSet.of(Modality.TEXT), service.resolve(" ", null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Case-insensitive model name match")
|
||||
void caseInsensitiveMatch() {
|
||||
assertTrue(service.supports("GLM-4V-PLUS", null, Modality.VIDEO));
|
||||
assertTrue(service.supports("Gpt-4o", null, Modality.VISION),
|
||||
"case-insensitive match still resolves the entry; OpenAI grants vision (not video)");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Llama 4 Scout / Maverick support VIDEO; Llama 3 does not")
|
||||
void llama4_supportsVideo() {
|
||||
assertTrue(service.supports("llama-4-scout", null, Modality.VIDEO));
|
||||
assertTrue(service.supports("llama-4-maverick", null, Modality.VIDEO));
|
||||
assertFalse(service.supports("llama-3.3-70b", null, Modality.VIDEO));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Mistral / Pixtral / Grok / Hunyuan vision: image yes, video no")
|
||||
void imageOnlyVendors() {
|
||||
assertTrue(service.supports("pixtral-12b", null, Modality.VISION));
|
||||
assertFalse(service.supports("pixtral-12b", null, Modality.VIDEO));
|
||||
assertTrue(service.supports("mistral-small-4", null, Modality.VISION));
|
||||
assertFalse(service.supports("mistral-small-4", null, Modality.VIDEO));
|
||||
assertTrue(service.supports("grok-3", null, Modality.VISION));
|
||||
assertFalse(service.supports("grok-3", null, Modality.VIDEO),
|
||||
"Grok Imagine is video generation, not input — pin this to prevent confusion");
|
||||
assertTrue(service.supports("hunyuan-vision", null, Modality.VISION));
|
||||
assertTrue(service.supports("hunyuan-large-vision", null, Modality.VISION));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("MiniMax-VL is vision-only (Hailuo / video-01 are generation, not input)")
|
||||
void minimaxVl_visionOnly() {
|
||||
assertTrue(service.supports("minimax-vl-01", null, Modality.VISION));
|
||||
assertFalse(service.supports("minimax-vl-01", null, Modality.VIDEO),
|
||||
"MiniMax video models generate video, they don't ingest it");
|
||||
}
|
||||
|
||||
// ---------- DB modalities override (user opt-in) ----------
|
||||
|
||||
@Test
|
||||
@DisplayName("DB modalities JSON overrides heuristics — user can grant video to image-only model")
|
||||
void dbOverride_grantsCapability() {
|
||||
// User declares glm-4v supports video (e.g. they tested a custom endpoint that does).
|
||||
// Override wins. TEXT always implicit.
|
||||
EnumSet<Modality> caps = service.resolve("glm-4v", "[\"vision\",\"video\"]");
|
||||
assertTrue(caps.contains(Modality.VIDEO),
|
||||
"DB override must take precedence — heuristic alone says no video");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("DB modalities JSON overrides heuristics — user can revoke capability")
|
||||
void dbOverride_revokesCapability() {
|
||||
// User declares gpt-4o as vision-only (e.g. their proxy strips video).
|
||||
EnumSet<Modality> caps = service.resolve("gpt-4o", "[\"vision\"]");
|
||||
assertFalse(caps.contains(Modality.VIDEO),
|
||||
"Empty modalities array means user explicitly opted out of video for this model");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("DB JSON case-insensitive on modality names")
|
||||
void dbOverride_caseInsensitive() {
|
||||
assertTrue(service.supports("anything", "[\"VIDEO\",\"Vision\"]", Modality.VIDEO));
|
||||
assertTrue(service.supports("anything", "[\"VIDEO\",\"Vision\"]", Modality.VISION));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Invalid JSON falls back to heuristics, does not throw")
|
||||
void dbOverride_invalidJson_fallsBack() {
|
||||
EnumSet<Modality> caps = service.resolve("glm-4v-plus", "this is not json");
|
||||
assertTrue(caps.contains(Modality.VIDEO),
|
||||
"When DB JSON is malformed, fall back to heuristics so service stays available");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Unknown modality string in JSON is logged and ignored, others still apply")
|
||||
void dbOverride_unknownModalityIgnored() {
|
||||
EnumSet<Modality> caps = service.resolve("anything", "[\"vision\",\"telepathy\"]");
|
||||
assertTrue(caps.contains(Modality.VISION));
|
||||
// unknown one silently skipped, no exception
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("TEXT is always implicit, even with empty DB declaration")
|
||||
void textAlwaysImplicit() {
|
||||
assertTrue(service.resolve("anything", "[]").contains(Modality.TEXT));
|
||||
assertTrue(service.resolve("anything", null).contains(Modality.TEXT));
|
||||
assertTrue(service.resolve(null, null).contains(Modality.TEXT));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,147 @@
|
||||
package vip.mate.llm.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import vip.mate.exception.MateClawException;
|
||||
import vip.mate.llm.model.ModelConfigEntity;
|
||||
import vip.mate.llm.repository.ModelConfigMapper;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* Regression tests for ModelConfigService.getDefaultModel() provider-availability filtering.
|
||||
*
|
||||
* Scenario: system has a default chat model but its provider is unconfigured (e.g. DashScope
|
||||
* marked as default but no API key). The method must skip it and return the first chat model
|
||||
* whose provider IS configured instead of blindly returning the unconfigured default.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class ModelConfigServiceDefaultModelTest {
|
||||
|
||||
@Mock
|
||||
private ModelConfigMapper modelConfigMapper;
|
||||
|
||||
@Mock
|
||||
private ApplicationEventPublisher eventPublisher;
|
||||
|
||||
@Mock
|
||||
private ModelProviderService modelProviderService;
|
||||
|
||||
@InjectMocks
|
||||
private ModelConfigService service;
|
||||
|
||||
@BeforeEach
|
||||
void injectLazyDep() {
|
||||
// Simulate the @Lazy @Autowired field injection Spring does at runtime.
|
||||
ReflectionTestUtils.setField(service, "modelProviderService", modelProviderService);
|
||||
}
|
||||
|
||||
private static ModelConfigEntity chatModel(String provider, String modelName, boolean isDefault) {
|
||||
ModelConfigEntity m = new ModelConfigEntity();
|
||||
m.setProvider(provider);
|
||||
m.setModelName(modelName);
|
||||
m.setIsDefault(isDefault);
|
||||
m.setEnabled(true);
|
||||
m.setModelType("chat");
|
||||
return m;
|
||||
}
|
||||
|
||||
// ── Scenario 1: default model available ────────────────────────────────────
|
||||
|
||||
@Test
|
||||
@DisplayName("configured default model is returned directly")
|
||||
void defaultModelConfigured_returnsIt() {
|
||||
ModelConfigEntity dashscopeDefault = chatModel("dashscope", "qwen-plus", true);
|
||||
|
||||
when(modelConfigMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(dashscopeDefault);
|
||||
when(modelProviderService.isProviderConfigured("dashscope")).thenReturn(true);
|
||||
|
||||
ModelConfigEntity result = service.getDefaultModel();
|
||||
|
||||
assertEquals("dashscope", result.getProvider());
|
||||
assertEquals("qwen-plus", result.getModelName());
|
||||
// Should not proceed to the full-scan fallback path.
|
||||
verify(modelConfigMapper, times(1)).selectOne(any());
|
||||
verify(modelConfigMapper, never()).selectList(any());
|
||||
}
|
||||
|
||||
// ── Scenario 2: default model provider unavailable → fallback ─────────────
|
||||
|
||||
@Test
|
||||
@DisplayName("default model provider unconfigured: falls back to first configured alternative")
|
||||
void defaultModelProviderUnconfigured_returnsFallback() {
|
||||
ModelConfigEntity dashscopeDefault = chatModel("dashscope", "qwen-plus", true);
|
||||
ModelConfigEntity zhipuModel = chatModel("zhipu", "glm-4", false);
|
||||
|
||||
// First selectOne → the is_default=true model
|
||||
when(modelConfigMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(dashscopeDefault);
|
||||
// dashscope is NOT configured, zhipu IS
|
||||
when(modelProviderService.isProviderConfigured("dashscope")).thenReturn(false);
|
||||
when(modelProviderService.isProviderConfigured("zhipu")).thenReturn(true);
|
||||
// Full-scan returns both; zhipu comes second but dashscope is skipped
|
||||
when(modelConfigMapper.selectList(any(LambdaQueryWrapper.class)))
|
||||
.thenReturn(List.of(dashscopeDefault, zhipuModel));
|
||||
|
||||
ModelConfigEntity result = service.getDefaultModel();
|
||||
|
||||
assertEquals("zhipu", result.getProvider());
|
||||
assertEquals("glm-4", result.getModelName());
|
||||
}
|
||||
|
||||
// ── Scenario 3: no configured provider at all ──────────────────────────────
|
||||
|
||||
@Test
|
||||
@DisplayName("all enabled chat model providers unconfigured: throws with clear message")
|
||||
void allProvidersUnconfigured_throws() {
|
||||
ModelConfigEntity dashscopeDefault = chatModel("dashscope", "qwen-plus", true);
|
||||
ModelConfigEntity zhipuModel = chatModel("zhipu", "glm-4", false);
|
||||
|
||||
when(modelConfigMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(dashscopeDefault);
|
||||
when(modelProviderService.isProviderConfigured(any())).thenReturn(false);
|
||||
when(modelConfigMapper.selectList(any(LambdaQueryWrapper.class)))
|
||||
.thenReturn(List.of(dashscopeDefault, zhipuModel));
|
||||
|
||||
MateClawException ex = assertThrows(MateClawException.class, () -> service.getDefaultModel());
|
||||
assertEquals("err.llm.no_configured_provider", ex.getMsgKey());
|
||||
}
|
||||
|
||||
// ── Scenario 4: no enabled model at all ───────────────────────────────────
|
||||
|
||||
@Test
|
||||
@DisplayName("no enabled chat model at all: throws no_available_model")
|
||||
void noEnabledModel_throws() {
|
||||
when(modelConfigMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(null);
|
||||
when(modelConfigMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of());
|
||||
|
||||
MateClawException ex = assertThrows(MateClawException.class, () -> service.getDefaultModel());
|
||||
assertEquals("err.llm.no_available_model", ex.getMsgKey());
|
||||
}
|
||||
|
||||
// ── Scenario 5: modelProviderService unavailable (bootstrap) ──────────────
|
||||
|
||||
@Test
|
||||
@DisplayName("modelProviderService null (bootstrap): default model returned without filtering")
|
||||
void providerServiceNull_returnsDefaultWithoutFilter() {
|
||||
ReflectionTestUtils.setField(service, "modelProviderService", null);
|
||||
ModelConfigEntity dashscopeDefault = chatModel("dashscope", "qwen-plus", true);
|
||||
|
||||
when(modelConfigMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(dashscopeDefault);
|
||||
|
||||
// With null providerService, isProviderConfigured returns true (lenient bootstrap)
|
||||
ModelConfigEntity result = service.getDefaultModel();
|
||||
assertEquals("dashscope", result.getProvider());
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,138 @@
|
||||
package vip.mate.llm.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import vip.mate.llm.model.ModelConfigEntity;
|
||||
import vip.mate.llm.repository.ModelConfigMapper;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* Tests for {@link ModelConfigService#resolveModel(String)} — the lookup
|
||||
* path used by {@code AgentGraphBuilder} to honor a per-Agent model override
|
||||
* (RFC-03 Lane G1).
|
||||
*
|
||||
* <p>Contract:
|
||||
* <ul>
|
||||
* <li>Blank / null name → fall back to {@link ModelConfigService#getDefaultModel()}</li>
|
||||
* <li>Name matches an enabled model → return that entity</li>
|
||||
* <li>Name does not match (deleted / disabled / typo) → fall back to default</li>
|
||||
* </ul>
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class ModelConfigServiceResolveModelTest {
|
||||
|
||||
@Mock
|
||||
private ModelConfigMapper modelConfigMapper;
|
||||
|
||||
@Mock
|
||||
private ApplicationEventPublisher eventPublisher;
|
||||
|
||||
@Mock
|
||||
private ModelProviderService modelProviderService;
|
||||
|
||||
@InjectMocks
|
||||
private ModelConfigService service;
|
||||
|
||||
@BeforeEach
|
||||
void injectLazyDep() {
|
||||
// Simulate the @Lazy @Autowired field injection Spring does at runtime.
|
||||
ReflectionTestUtils.setField(service, "modelProviderService", modelProviderService);
|
||||
}
|
||||
|
||||
private static ModelConfigEntity chatModel(String provider, String modelName, boolean isDefault) {
|
||||
ModelConfigEntity m = new ModelConfigEntity();
|
||||
m.setProvider(provider);
|
||||
m.setModelName(modelName);
|
||||
m.setIsDefault(isDefault);
|
||||
m.setEnabled(true);
|
||||
m.setModelType("chat");
|
||||
return m;
|
||||
}
|
||||
|
||||
// ── Blank input → fall back to default ─────────────────────────────────────
|
||||
|
||||
@Test
|
||||
@DisplayName("null name falls back to global default")
|
||||
void nullNameFallsBack() {
|
||||
ModelConfigEntity defaultModel = chatModel("dashscope", "qwen-plus", true);
|
||||
// resolveModel skips its own selectOne for null/blank input, then calls getDefaultModel(),
|
||||
// which itself runs one selectOne lookup for the default flag.
|
||||
when(modelConfigMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(defaultModel);
|
||||
when(modelProviderService.isProviderConfigured("dashscope")).thenReturn(true);
|
||||
|
||||
ModelConfigEntity result = service.resolveModel(null);
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals("qwen-plus", result.getModelName());
|
||||
// Exactly one lookup — the default-model query inside getDefaultModel().
|
||||
verify(modelConfigMapper, times(1)).selectOne(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("blank/whitespace name falls back to global default")
|
||||
void blankNameFallsBack() {
|
||||
ModelConfigEntity defaultModel = chatModel("dashscope", "qwen-plus", true);
|
||||
when(modelConfigMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(defaultModel);
|
||||
when(modelProviderService.isProviderConfigured("dashscope")).thenReturn(true);
|
||||
|
||||
ModelConfigEntity result = service.resolveModel(" ");
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals("qwen-plus", result.getModelName());
|
||||
verify(modelConfigMapper, times(1)).selectOne(any());
|
||||
}
|
||||
|
||||
// ── Match → return named model ─────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
@DisplayName("named model match returns the entity (no default fallback)")
|
||||
void namedMatchReturnsEntity() {
|
||||
ModelConfigEntity claude = chatModel("anthropic", "claude-3-5-sonnet", false);
|
||||
// resolveModel's first selectOne (lookup by name) hits.
|
||||
when(modelConfigMapper.selectOne(any(LambdaQueryWrapper.class))).thenReturn(claude);
|
||||
|
||||
ModelConfigEntity result = service.resolveModel("claude-3-5-sonnet");
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals("anthropic", result.getProvider());
|
||||
assertEquals("claude-3-5-sonnet", result.getModelName());
|
||||
// Exactly one lookup — getDefaultModel must NOT be called.
|
||||
verify(modelConfigMapper, times(1)).selectOne(any());
|
||||
verify(modelProviderService, never()).isProviderConfigured(any());
|
||||
}
|
||||
|
||||
// ── Unmatched → fall back to default ───────────────────────────────────────
|
||||
|
||||
@Test
|
||||
@DisplayName("named model not found (typo / deleted) falls back to default")
|
||||
void unmatchedNameFallsBack() {
|
||||
ModelConfigEntity defaultModel = chatModel("dashscope", "qwen-plus", true);
|
||||
// First call (lookup by name) returns null; second call (default) returns the default.
|
||||
when(modelConfigMapper.selectOne(any(LambdaQueryWrapper.class)))
|
||||
.thenReturn(null) // 1st: name lookup misses
|
||||
.thenReturn(defaultModel); // 2nd: default flag lookup
|
||||
when(modelProviderService.isProviderConfigured("dashscope")).thenReturn(true);
|
||||
|
||||
ModelConfigEntity result = service.resolveModel("ghost-model");
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals("qwen-plus", result.getModelName());
|
||||
// Two queries — one miss, then the default fallback.
|
||||
verify(modelConfigMapper, times(2)).selectOne(any());
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,191 @@
|
||||
package vip.mate.llm.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.web.client.MockRestServiceServer;
|
||||
import org.springframework.web.client.RestClient;
|
||||
import vip.mate.exception.MateClawException;
|
||||
import vip.mate.llm.model.ModelInfoDTO;
|
||||
import vip.mate.llm.model.ModelProviderEntity;
|
||||
import vip.mate.llm.oauth.OpenAIOAuthService;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.springframework.test.web.client.match.MockRestRequestMatchers.header;
|
||||
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
|
||||
import static org.springframework.test.web.client.response.MockRestResponseCreators.withStatus;
|
||||
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
|
||||
|
||||
/**
|
||||
* Unit tests for ChatGPT OAuth model discovery — the only protocol where we
|
||||
* call a separate endpoint with the user's OAuth bearer token instead of an
|
||||
* API key. Lower-protocol behaviour (filter, probe, dedupe) is exercised by
|
||||
* the rest of {@link ModelDiscoveryService} indirectly and out of scope here.
|
||||
*/
|
||||
class ModelDiscoveryServiceChatGPTOAuthTest {
|
||||
|
||||
private ModelDiscoveryService service;
|
||||
private OpenAIOAuthService oauthService;
|
||||
private MockRestServiceServer mockServer;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
ModelProviderService providerService = mock(ModelProviderService.class);
|
||||
ModelConfigService configService = mock(ModelConfigService.class);
|
||||
oauthService = mock(OpenAIOAuthService.class);
|
||||
when(oauthService.ensureValidAccessToken()).thenReturn("test-access-token");
|
||||
when(configService.listModelsByProvider(any())).thenReturn(List.of());
|
||||
|
||||
ModelProviderEntity provider = new ModelProviderEntity();
|
||||
provider.setProviderId("openai-chatgpt");
|
||||
provider.setChatModel("ChatGPTChatModel");
|
||||
provider.setSupportModelDiscovery(true);
|
||||
when(providerService.getProviderConfig("openai-chatgpt")).thenReturn(provider);
|
||||
|
||||
service = new ModelDiscoveryService(providerService, configService,
|
||||
new ObjectMapper(), oauthService);
|
||||
|
||||
RestClient.Builder builder = RestClient.builder();
|
||||
mockServer = MockRestServiceServer.bindTo(builder).build();
|
||||
service.setChatgptCodexClient(builder.build());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// parseChatGPTCodexModelsResponse — pure parsing tests
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
@DisplayName("parser drops supported_in_api=false and visibility=hide entries")
|
||||
void parser_dropsHiddenAndUnsupported() {
|
||||
String body = "{\"models\":["
|
||||
+ "{\"slug\":\"gpt-5.4\",\"supported_in_api\":true,\"visibility\":\"shown\",\"priority\":10},"
|
||||
+ "{\"slug\":\"gpt-internal\",\"supported_in_api\":false,\"priority\":5},"
|
||||
+ "{\"slug\":\"gpt-research\",\"supported_in_api\":true,\"visibility\":\"hide\",\"priority\":1},"
|
||||
+ "{\"slug\":\"gpt-5.4-mini\",\"supported_in_api\":true,\"visibility\":\"shown\",\"priority\":20}"
|
||||
+ "]}";
|
||||
|
||||
List<ModelInfoDTO> models = service.parseChatGPTCodexModelsResponse(body);
|
||||
List<String> ids = models.stream().map(ModelInfoDTO::getId).toList();
|
||||
|
||||
assertEquals(List.of("gpt-5.4", "gpt-5.4-mini"), ids);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("parser sorts by priority ascending")
|
||||
void parser_sortsByPriority() {
|
||||
String body = "{\"models\":["
|
||||
+ "{\"slug\":\"third\",\"supported_in_api\":true,\"priority\":30},"
|
||||
+ "{\"slug\":\"first\",\"supported_in_api\":true,\"priority\":1},"
|
||||
+ "{\"slug\":\"second\",\"supported_in_api\":true,\"priority\":15}"
|
||||
+ "]}";
|
||||
|
||||
List<String> ids = service.parseChatGPTCodexModelsResponse(body)
|
||||
.stream().map(ModelInfoDTO::getId).toList();
|
||||
assertEquals(List.of("first", "second", "third"), ids);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("parser tolerates missing or non-list bodies")
|
||||
void parser_tolerantOfBadInput() {
|
||||
assertTrue(service.parseChatGPTCodexModelsResponse(null).isEmpty());
|
||||
assertTrue(service.parseChatGPTCodexModelsResponse("").isEmpty());
|
||||
assertTrue(service.parseChatGPTCodexModelsResponse("{}").isEmpty());
|
||||
assertTrue(service.parseChatGPTCodexModelsResponse("{\"models\": \"not-a-list\"}").isEmpty());
|
||||
assertTrue(service.parseChatGPTCodexModelsResponse("not-json").isEmpty());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// addChatGPTForwardCompatModels — the synthesis layer
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
@DisplayName("forward-compat synthesizes gpt-5.5 when only gpt-5.4 is exposed")
|
||||
void forwardCompat_synthesizesGpt55FromGpt54() {
|
||||
List<ModelInfoDTO> input = List.of(new ModelInfoDTO("gpt-5.4", "gpt-5.4"));
|
||||
List<String> out = ModelDiscoveryService.addChatGPTForwardCompatModels(input)
|
||||
.stream().map(ModelInfoDTO::getId).toList();
|
||||
assertTrue(out.contains("gpt-5.5"), "Expected gpt-5.5 to be appended; got " + out);
|
||||
assertTrue(out.contains("gpt-5.4"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("forward-compat does not duplicate slugs already in the input")
|
||||
void forwardCompat_noDuplicates() {
|
||||
List<ModelInfoDTO> input = List.of(
|
||||
new ModelInfoDTO("gpt-5.5", "gpt-5.5"),
|
||||
new ModelInfoDTO("gpt-5.4", "gpt-5.4"));
|
||||
List<String> out = ModelDiscoveryService.addChatGPTForwardCompatModels(input)
|
||||
.stream().map(ModelInfoDTO::getId).toList();
|
||||
assertEquals(1, out.stream().filter("gpt-5.5"::equals).count());
|
||||
assertEquals(1, out.stream().filter("gpt-5.4"::equals).count());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("forward-compat is a no-op when no template ancestor is present")
|
||||
void forwardCompat_noOpOnEmptyOrUnrelated() {
|
||||
List<String> empty = ModelDiscoveryService.addChatGPTForwardCompatModels(List.of())
|
||||
.stream().map(ModelInfoDTO::getId).toList();
|
||||
assertTrue(empty.isEmpty());
|
||||
|
||||
List<String> unrelated = ModelDiscoveryService.addChatGPTForwardCompatModels(
|
||||
List.of(new ModelInfoDTO("gpt-3.5", "gpt-3.5")))
|
||||
.stream().map(ModelInfoDTO::getId).toList();
|
||||
assertEquals(List.of("gpt-3.5"), unrelated);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// discoverModels — end-to-end through the OAuth path
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
@DisplayName("discoverModels sends Bearer token and returns sorted+forward-compat catalog")
|
||||
void discoverModels_endToEnd() {
|
||||
mockServer.expect(requestTo(ModelDiscoveryService.CHATGPT_CODEX_MODELS_URL))
|
||||
.andExpect(header(HttpHeaders.AUTHORIZATION, "Bearer test-access-token"))
|
||||
.andRespond(withSuccess(
|
||||
"{\"models\":["
|
||||
+ "{\"slug\":\"gpt-5.4\",\"supported_in_api\":true,\"priority\":10},"
|
||||
+ "{\"slug\":\"gpt-5.4-mini\",\"supported_in_api\":true,\"priority\":20},"
|
||||
+ "{\"slug\":\"gpt-internal\",\"supported_in_api\":false,\"priority\":5}"
|
||||
+ "]}",
|
||||
MediaType.APPLICATION_JSON));
|
||||
|
||||
var result = service.discoverModels("openai-chatgpt");
|
||||
List<String> all = result.getDiscoveredModels().stream().map(ModelInfoDTO::getId).toList();
|
||||
|
||||
// priority-sorted real models, plus gpt-5.5 synthesised by forward-compat
|
||||
assertEquals(List.of("gpt-5.4", "gpt-5.4-mini", "gpt-5.5"), all);
|
||||
verify(oauthService).ensureValidAccessToken();
|
||||
mockServer.verify();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("discoverModels surfaces fetch failures as err.llm.chatgpt_models_fetch_failed")
|
||||
void discoverModels_surfacesFetchFailure() {
|
||||
mockServer.expect(requestTo(ModelDiscoveryService.CHATGPT_CODEX_MODELS_URL))
|
||||
.andRespond(withStatus(HttpStatus.UNAUTHORIZED));
|
||||
|
||||
MateClawException ex = assertThrows(MateClawException.class,
|
||||
() -> service.discoverModels("openai-chatgpt"));
|
||||
assertEquals("err.llm.chatgpt_models_fetch_failed", ex.getMsgKey());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("discoverModels propagates oauth_not_connected from OpenAIOAuthService unchanged")
|
||||
void discoverModels_propagatesOauthNotConnected() {
|
||||
when(oauthService.ensureValidAccessToken())
|
||||
.thenThrow(new MateClawException("err.llm.oauth_not_connected", "未连接"));
|
||||
|
||||
MateClawException ex = assertThrows(MateClawException.class,
|
||||
() -> service.discoverModels("openai-chatgpt"));
|
||||
assertEquals("err.llm.oauth_not_connected", ex.getMsgKey());
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,325 @@
|
||||
package vip.mate.llm.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import vip.mate.llm.anthropic.oauth.ClaudeCodeOAuthService;
|
||||
import vip.mate.llm.failover.AvailableProviderPool;
|
||||
import vip.mate.llm.failover.ProviderHealthProperties;
|
||||
import vip.mate.llm.failover.ProviderHealthTracker;
|
||||
import vip.mate.llm.failover.ProviderInitProbe;
|
||||
import vip.mate.llm.model.Liveness;
|
||||
import vip.mate.llm.model.ModelConfigEntity;
|
||||
import vip.mate.llm.model.ModelProviderEntity;
|
||||
import vip.mate.llm.model.ProviderInfoDTO;
|
||||
import vip.mate.llm.repository.ModelProviderMapper;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* Issue #81: row-based isProviderConfigured + applySuggestedAction. Each test is
|
||||
* one row of the truth table in RFC §2.3 (behavior diff vs. v1) and §7
|
||||
* (suggestedAction decision tree).
|
||||
*/
|
||||
class ModelProviderServiceConfiguredTest {
|
||||
|
||||
private ModelProviderMapper providerMapper;
|
||||
private ModelConfigService modelConfigService;
|
||||
private ApplicationEventPublisher eventPublisher;
|
||||
private ObjectProvider<ClaudeCodeOAuthService> claudeCodeOAuthProvider;
|
||||
private ClaudeCodeOAuthService claudeCodeOAuthService;
|
||||
private AvailableProviderPool pool;
|
||||
private ProviderHealthTracker healthTracker;
|
||||
private ProviderInitProbe initProbe;
|
||||
private ObjectProvider<ProviderInitProbe> initProbeProvider;
|
||||
|
||||
private ModelProviderService service;
|
||||
|
||||
@BeforeEach
|
||||
@SuppressWarnings("unchecked")
|
||||
void setUp() {
|
||||
providerMapper = mock(ModelProviderMapper.class);
|
||||
modelConfigService = mock(ModelConfigService.class);
|
||||
eventPublisher = mock(ApplicationEventPublisher.class);
|
||||
claudeCodeOAuthProvider = mock(ObjectProvider.class);
|
||||
claudeCodeOAuthService = mock(ClaudeCodeOAuthService.class);
|
||||
when(claudeCodeOAuthProvider.getIfAvailable()).thenReturn(null);
|
||||
pool = new AvailableProviderPool();
|
||||
ProviderHealthProperties props = new ProviderHealthProperties();
|
||||
props.setFailureThreshold(1);
|
||||
healthTracker = new ProviderHealthTracker(props);
|
||||
initProbe = mock(ProviderInitProbe.class);
|
||||
initProbeProvider = mock(ObjectProvider.class);
|
||||
when(initProbeProvider.getIfAvailable()).thenReturn(initProbe);
|
||||
// Default: every provider has been probed so liveness is computed normally.
|
||||
when(initProbe.hasBeenProbed(any())).thenReturn(true);
|
||||
|
||||
service = new ModelProviderService(providerMapper, modelConfigService, eventPublisher,
|
||||
claudeCodeOAuthProvider, pool, healthTracker, initProbeProvider);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Issue #81: llama.cpp local + empty Base URL → UNCONFIGURED + fill_base_url + hint")
|
||||
void llamacppEmptyBaseUrl() {
|
||||
ModelProviderEntity p = local("llamacpp");
|
||||
p.setBaseUrl("");
|
||||
seedProviderRow(p, false);
|
||||
|
||||
ProviderInfoDTO dto = singleResult();
|
||||
assertFalse(dto.getConfigured(), "empty Base URL must NOT be considered configured");
|
||||
assertEquals(Liveness.UNCONFIGURED, dto.getLiveness());
|
||||
assertEquals("fill_base_url", dto.getSuggestedAction());
|
||||
assertEquals("provider.hint.llamacppBaseUrlExample", dto.getSuggestedActionHintKey());
|
||||
assertEquals("http://127.0.0.1:8080/v1", dto.getSuggestedActionHintArgs().get("example"));
|
||||
assertEquals("baseUrl", dto.getMissingFields());
|
||||
assertEquals("NOT_REQUIRED", dto.getAuthStatus());
|
||||
assertFalse(dto.getBaseUrlComplete());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("llama.cpp local + Base URL filled but pool REMOVED → REMOVED + reprobe")
|
||||
void llamacppBaseUrlFilledButRemoved() {
|
||||
ModelProviderEntity p = local("llamacpp");
|
||||
p.setBaseUrl("http://127.0.0.1:8080/v1");
|
||||
seedProviderRow(p, true);
|
||||
pool.remove("llamacpp", AvailableProviderPool.RemovalSource.INIT_PROBE,
|
||||
"init probe failed: connection refused");
|
||||
|
||||
ProviderInfoDTO dto = singleResult();
|
||||
assertTrue(dto.getConfigured());
|
||||
assertEquals(Liveness.REMOVED, dto.getLiveness());
|
||||
assertEquals("reprobe", dto.getSuggestedAction());
|
||||
assertNull(dto.getSuggestedActionHintKey(), "REMOVED state should not carry a hint key");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Ollama local + LIVE + 0 models + supportModelDiscovery=true → pull_model")
|
||||
void ollamaLiveNoModels() {
|
||||
ModelProviderEntity p = local("ollama");
|
||||
p.setBaseUrl("http://127.0.0.1:11434");
|
||||
p.setSupportModelDiscovery(true);
|
||||
when(providerMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(p));
|
||||
when(modelConfigService.listModels()).thenReturn(List.of()); // no models registered
|
||||
pool.add("ollama");
|
||||
|
||||
ProviderInfoDTO dto = singleResult();
|
||||
assertEquals(Liveness.LIVE, dto.getLiveness());
|
||||
assertEquals("pull_model", dto.getSuggestedAction());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("OpenAI cloud + apiKey empty → UNCONFIGURED + fill_api_key + no hint")
|
||||
void openaiCloudEmptyApiKey() {
|
||||
ModelProviderEntity p = cloud("openai", true);
|
||||
p.setApiKey("");
|
||||
seedProviderRow(p, false);
|
||||
|
||||
ProviderInfoDTO dto = singleResult();
|
||||
assertFalse(dto.getConfigured());
|
||||
assertEquals(Liveness.UNCONFIGURED, dto.getLiveness());
|
||||
assertEquals("fill_api_key", dto.getSuggestedAction());
|
||||
assertNull(dto.getSuggestedActionHintKey(), "cloud providers don't need a base-url hint");
|
||||
assertEquals("MISSING", dto.getAuthStatus());
|
||||
assertEquals("apiKey", dto.getMissingFields());
|
||||
assertNull(dto.getBaseUrlComplete(), "cloud provider's baseUrlComplete should be null (n/a)");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("OpenAI cloud + apiKey filled + LIVE → none + CONFIGURED")
|
||||
void openaiCloudHealthy() {
|
||||
ModelProviderEntity p = cloud("openai", true);
|
||||
p.setApiKey("sk-test-1234567890");
|
||||
seedProviderRow(p, true);
|
||||
pool.add("openai");
|
||||
|
||||
ProviderInfoDTO dto = singleResult();
|
||||
assertTrue(dto.getConfigured());
|
||||
assertEquals(Liveness.LIVE, dto.getLiveness());
|
||||
assertEquals("none", dto.getSuggestedAction());
|
||||
assertEquals("CONFIGURED", dto.getAuthStatus());
|
||||
assertEquals("", dto.getMissingFields());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Kimi cloud + apiKey empty → fill_api_key (same shape as OpenAI)")
|
||||
void kimiCloudEmptyApiKey() {
|
||||
ModelProviderEntity p = cloud("kimi", true);
|
||||
p.setApiKey("");
|
||||
seedProviderRow(p, false);
|
||||
|
||||
ProviderInfoDTO dto = singleResult();
|
||||
assertFalse(dto.getConfigured());
|
||||
assertEquals("fill_api_key", dto.getSuggestedAction());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Custom OpenAI-compat + baseUrl empty + apiKey filled + requireApiKey=true → fill_base_url")
|
||||
void customOpenAiCompatEmptyBaseUrl() {
|
||||
ModelProviderEntity p = custom("my-server");
|
||||
p.setRequireApiKey(true);
|
||||
p.setBaseUrl("");
|
||||
p.setApiKey("sk-test-1234567890");
|
||||
seedProviderRow(p, false);
|
||||
|
||||
ProviderInfoDTO dto = singleResult();
|
||||
assertFalse(dto.getConfigured());
|
||||
assertEquals("fill_base_url", dto.getSuggestedAction());
|
||||
assertEquals("baseUrl", dto.getMissingFields());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Custom OpenAI-compat + baseUrl filled + apiKey empty + requireApiKey=true → fill_api_key")
|
||||
void customOpenAiCompatEmptyApiKey() {
|
||||
ModelProviderEntity p = custom("my-server");
|
||||
p.setRequireApiKey(true);
|
||||
p.setBaseUrl("http://x.example.com/v1");
|
||||
p.setApiKey("");
|
||||
seedProviderRow(p, false);
|
||||
|
||||
ProviderInfoDTO dto = singleResult();
|
||||
assertFalse(dto.getConfigured());
|
||||
assertEquals("fill_api_key", dto.getSuggestedAction());
|
||||
assertEquals("apiKey", dto.getMissingFields());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Custom OpenAI-compat + both empty + requireApiKey=true → configure_required_fields + both missing")
|
||||
void customOpenAiCompatBothEmpty() {
|
||||
ModelProviderEntity p = custom("my-server");
|
||||
p.setRequireApiKey(true);
|
||||
p.setBaseUrl("");
|
||||
p.setApiKey("");
|
||||
seedProviderRow(p, false);
|
||||
|
||||
ProviderInfoDTO dto = singleResult();
|
||||
assertFalse(dto.getConfigured());
|
||||
assertEquals("configure_required_fields", dto.getSuggestedAction());
|
||||
assertEquals("apiKey,baseUrl", dto.getMissingFields());
|
||||
// hint emitted because action is configure_required_fields
|
||||
assertEquals("provider.hint.openaiCompatBaseUrlExample", dto.getSuggestedActionHintKey());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Custom OpenAI-compat + both filled + LIVE → none")
|
||||
void customOpenAiCompatHealthy() {
|
||||
ModelProviderEntity p = custom("my-server");
|
||||
p.setRequireApiKey(true);
|
||||
p.setBaseUrl("http://x.example.com/v1");
|
||||
p.setApiKey("sk-test-1234567890");
|
||||
seedProviderRow(p, true);
|
||||
pool.add("my-server");
|
||||
|
||||
ProviderInfoDTO dto = singleResult();
|
||||
assertTrue(dto.getConfigured());
|
||||
assertEquals(Liveness.LIVE, dto.getLiveness());
|
||||
assertEquals("none", dto.getSuggestedAction());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("OAuth provider not connected → UNCONFIGURED + start_oauth + OAUTH_PENDING")
|
||||
void oauthNotConnected() {
|
||||
ModelProviderEntity p = new ModelProviderEntity();
|
||||
p.setProviderId("some-oauth");
|
||||
p.setName("Some OAuth");
|
||||
p.setAuthType("oauth");
|
||||
// No oauthAccessToken → not configured.
|
||||
seedProviderRow(p, false);
|
||||
|
||||
ProviderInfoDTO dto = singleResult();
|
||||
assertFalse(dto.getConfigured());
|
||||
assertEquals(Liveness.UNCONFIGURED, dto.getLiveness());
|
||||
assertEquals("start_oauth", dto.getSuggestedAction());
|
||||
assertEquals("OAUTH_PENDING", dto.getAuthStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("OAuth provider connected → LIVE + CONFIGURED")
|
||||
void oauthConnected() {
|
||||
ModelProviderEntity p = new ModelProviderEntity();
|
||||
p.setProviderId("some-oauth");
|
||||
p.setName("Some OAuth");
|
||||
p.setAuthType("oauth");
|
||||
p.setOauthAccessToken("ya29.test");
|
||||
seedProviderRow(p, true);
|
||||
pool.add("some-oauth");
|
||||
|
||||
ProviderInfoDTO dto = singleResult();
|
||||
assertTrue(dto.getConfigured());
|
||||
assertEquals(Liveness.LIVE, dto.getLiveness());
|
||||
assertEquals("CONFIGURED", dto.getAuthStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Default 'enabled' filter: providers without enabled=true are excluded")
|
||||
void defaultProviderRespectsEnabledFlag() {
|
||||
// Sanity: the existing infrastructure still gates on enabled when listProviders
|
||||
// is called. seedProviderRow sets enabled=true so this is just defensive.
|
||||
ModelProviderEntity p = local("ollama");
|
||||
p.setEnabled(true);
|
||||
seedProviderRow(p, true);
|
||||
pool.add("ollama");
|
||||
assertEquals(1, service.listProviders().size());
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Helpers
|
||||
// ============================================================
|
||||
|
||||
private void seedProviderRow(ModelProviderEntity p, boolean withModel) {
|
||||
if (p.getEnabled() == null) p.setEnabled(true);
|
||||
when(providerMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(p));
|
||||
if (withModel) {
|
||||
ModelConfigEntity m = new ModelConfigEntity();
|
||||
m.setProvider(p.getProviderId());
|
||||
m.setModelName(p.getProviderId() + "-model");
|
||||
m.setName(p.getProviderId() + "-model");
|
||||
m.setBuiltin(true);
|
||||
when(modelConfigService.listModels()).thenReturn(List.of(m));
|
||||
} else {
|
||||
when(modelConfigService.listModels()).thenReturn(List.of());
|
||||
}
|
||||
}
|
||||
|
||||
private static ModelProviderEntity cloud(String id, boolean requireApiKey) {
|
||||
ModelProviderEntity p = new ModelProviderEntity();
|
||||
p.setProviderId(id);
|
||||
p.setName(id);
|
||||
p.setIsLocal(false);
|
||||
p.setIsCustom(false);
|
||||
p.setRequireApiKey(requireApiKey);
|
||||
return p;
|
||||
}
|
||||
|
||||
private static ModelProviderEntity local(String id) {
|
||||
ModelProviderEntity p = new ModelProviderEntity();
|
||||
p.setProviderId(id);
|
||||
p.setName(id);
|
||||
p.setIsLocal(true);
|
||||
p.setIsCustom(false);
|
||||
p.setRequireApiKey(false);
|
||||
p.setBaseUrl("http://127.0.0.1:11434"); // overridden per test as needed
|
||||
return p;
|
||||
}
|
||||
|
||||
private static ModelProviderEntity custom(String id) {
|
||||
ModelProviderEntity p = new ModelProviderEntity();
|
||||
p.setProviderId(id);
|
||||
p.setName(id);
|
||||
p.setIsLocal(false);
|
||||
p.setIsCustom(true);
|
||||
return p;
|
||||
}
|
||||
|
||||
private ProviderInfoDTO singleResult() {
|
||||
List<ProviderInfoDTO> list = service.listProviders();
|
||||
assertEquals(1, list.size());
|
||||
return list.get(0);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,259 @@
|
||||
package vip.mate.llm.service;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import vip.mate.exception.MateClawException;
|
||||
import vip.mate.llm.anthropic.oauth.ClaudeCodeOAuthService;
|
||||
import vip.mate.llm.failover.AvailableProviderPool;
|
||||
import vip.mate.llm.failover.ProviderHealthProperties;
|
||||
import vip.mate.llm.failover.ProviderHealthTracker;
|
||||
import vip.mate.llm.failover.ProviderInitProbe;
|
||||
import vip.mate.llm.model.CreateCustomProviderRequest;
|
||||
import vip.mate.llm.model.ModelProviderEntity;
|
||||
import vip.mate.llm.model.ProviderConfigRequest;
|
||||
import vip.mate.llm.repository.ModelProviderMapper;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
/**
|
||||
* Issue #39 regression: provider id ends up as a single path segment in
|
||||
* {@code /custom-providers/{providerId}}, so any unsafe character (slash,
|
||||
* space, {@code #}, {@code ?}) makes Spring's PathPatternParser miss the
|
||||
* controller and fall through to the static-resource handler — symptom is
|
||||
* a {@code NoResourceFoundException} on the DELETE the user reported.
|
||||
*
|
||||
* <p>These tests pin the two layers of the fix:</p>
|
||||
* <ul>
|
||||
* <li>{@code createCustomProvider} rejects unsafe ids server-side, so a
|
||||
* non-UI client (curl / Electron / 3rd-party) cannot bypass the
|
||||
* front-end regex and persist a row that's later undeletable.</li>
|
||||
* <li>{@code deleteCustomProvider} itself doesn't care about the shape
|
||||
* of the id — it deletes by primary key. Anything that <em>did</em>
|
||||
* slip into the DB before the create-side guard existed can still be
|
||||
* cleaned up via the query-param controller variant.</li>
|
||||
* </ul>
|
||||
*/
|
||||
class ModelProviderServiceCustomProviderTest {
|
||||
|
||||
private ModelProviderMapper providerMapper;
|
||||
private ModelConfigService modelConfigService;
|
||||
private ApplicationEventPublisher eventPublisher;
|
||||
private ObjectProvider<ClaudeCodeOAuthService> claudeCodeOAuthProvider;
|
||||
private AvailableProviderPool pool;
|
||||
private ProviderHealthTracker healthTracker;
|
||||
private ProviderInitProbe initProbe;
|
||||
private ObjectProvider<ProviderInitProbe> initProbeProvider;
|
||||
|
||||
private ModelProviderService service;
|
||||
|
||||
@BeforeEach
|
||||
@SuppressWarnings("unchecked")
|
||||
void setUp() {
|
||||
providerMapper = mock(ModelProviderMapper.class);
|
||||
modelConfigService = mock(ModelConfigService.class);
|
||||
eventPublisher = mock(ApplicationEventPublisher.class);
|
||||
claudeCodeOAuthProvider = mock(ObjectProvider.class);
|
||||
when(claudeCodeOAuthProvider.getIfAvailable()).thenReturn(null);
|
||||
pool = new AvailableProviderPool();
|
||||
healthTracker = new ProviderHealthTracker(new ProviderHealthProperties());
|
||||
initProbe = mock(ProviderInitProbe.class);
|
||||
initProbeProvider = mock(ObjectProvider.class);
|
||||
when(initProbeProvider.getIfAvailable()).thenReturn(initProbe);
|
||||
|
||||
service = new ModelProviderService(providerMapper, modelConfigService, eventPublisher,
|
||||
claudeCodeOAuthProvider, pool, healthTracker, initProbeProvider);
|
||||
}
|
||||
|
||||
// ==================== create-side guard ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("createCustomProvider rejects ids containing '/' (issue #39 root cause)")
|
||||
void rejectsSlashInId() {
|
||||
CreateCustomProviderRequest req = req("google/gemma-4-e4b", "Local Gemma");
|
||||
|
||||
MateClawException ex = assertThrows(MateClawException.class,
|
||||
() -> service.createCustomProvider(req));
|
||||
|
||||
assertEquals("err.llm.provider_id_invalid", ex.getMsgKey());
|
||||
verify(providerMapper, never()).insert(any(ModelProviderEntity.class));
|
||||
verify(eventPublisher, never()).publishEvent(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("createCustomProvider rejects ids containing whitespace")
|
||||
void rejectsSpaceInId() {
|
||||
CreateCustomProviderRequest req = req("my provider", "Local Gemma");
|
||||
|
||||
MateClawException ex = assertThrows(MateClawException.class,
|
||||
() -> service.createCustomProvider(req));
|
||||
|
||||
assertEquals("err.llm.provider_id_invalid", ex.getMsgKey());
|
||||
verify(providerMapper, never()).insert(any(ModelProviderEntity.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("createCustomProvider rejects ids starting with '-' (regex requires alnum first char)")
|
||||
void rejectsLeadingHyphen() {
|
||||
CreateCustomProviderRequest req = req("-foo", "Local Gemma");
|
||||
|
||||
MateClawException ex = assertThrows(MateClawException.class,
|
||||
() -> service.createCustomProvider(req));
|
||||
|
||||
assertEquals("err.llm.provider_id_invalid", ex.getMsgKey());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("createCustomProvider rejects ids longer than 64 characters")
|
||||
void rejectsOverlongId() {
|
||||
// 65 chars: 'a' followed by 64 'b's.
|
||||
String tooLong = "a" + "b".repeat(64);
|
||||
CreateCustomProviderRequest req = req(tooLong, "Local Gemma");
|
||||
|
||||
MateClawException ex = assertThrows(MateClawException.class,
|
||||
() -> service.createCustomProvider(req));
|
||||
|
||||
assertEquals("err.llm.provider_id_invalid", ex.getMsgKey());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("createCustomProvider accepts a normal id (e.g. 'local-gemma') and persists")
|
||||
void acceptsNormalId() {
|
||||
CreateCustomProviderRequest req = req("local-gemma", "Local Gemma");
|
||||
when(providerMapper.selectById("local-gemma")).thenReturn(null);
|
||||
|
||||
service.createCustomProvider(req);
|
||||
|
||||
verify(providerMapper).insert(any(ModelProviderEntity.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("createCustomProvider accepts ids with dot/underscore/hyphen and digits")
|
||||
void acceptsRichButSafeChars() {
|
||||
CreateCustomProviderRequest req = req("My_Local-Gemma.v2", "Local Gemma");
|
||||
when(providerMapper.selectById("My_Local-Gemma.v2")).thenReturn(null);
|
||||
|
||||
service.createCustomProvider(req);
|
||||
|
||||
verify(providerMapper).insert(any(ModelProviderEntity.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("createCustomProvider persists requireApiKey=false for keyless internal OpenAI-compatible endpoints")
|
||||
void createCustomProviderCanDisableApiKeyRequirement() {
|
||||
CreateCustomProviderRequest req = req("internal-llm", "Internal LLM");
|
||||
req.setDefaultBaseUrl("http://llm.internal/v1");
|
||||
req.setRequireApiKey(false);
|
||||
when(providerMapper.selectById("internal-llm")).thenReturn(null);
|
||||
|
||||
service.createCustomProvider(req);
|
||||
|
||||
ArgumentCaptor<ModelProviderEntity> captor = ArgumentCaptor.forClass(ModelProviderEntity.class);
|
||||
verify(providerMapper).insert(captor.capture());
|
||||
assertFalse(captor.getValue().getRequireApiKey());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Empty id still produces 'fields_required' (existing guard, not the new regex)")
|
||||
void emptyIdStillReportsFieldsRequired() {
|
||||
CreateCustomProviderRequest req = req("", "Local Gemma");
|
||||
|
||||
MateClawException ex = assertThrows(MateClawException.class,
|
||||
() -> service.createCustomProvider(req));
|
||||
|
||||
assertEquals("err.llm.provider_fields_required", ex.getMsgKey());
|
||||
}
|
||||
|
||||
// ==================== delete-side: dirty data rescue ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("deleteCustomProvider works for an id with '/' once it reaches the service "
|
||||
+ "(query-param controller variant is the URL bridge)")
|
||||
void deletesIdContainingSlash() {
|
||||
String dirtyId = "google/gemma-4-e4b";
|
||||
ModelProviderEntity dirty = customProvider(dirtyId);
|
||||
when(providerMapper.selectById(dirtyId)).thenReturn(dirty);
|
||||
|
||||
service.deleteCustomProvider(dirtyId);
|
||||
|
||||
verify(modelConfigService).deleteModelsByProvider(dirtyId);
|
||||
verify(providerMapper).deleteById(dirtyId);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("deleteCustomProvider on a normal id (path-variant happy path) still works")
|
||||
void deletesNormalId() {
|
||||
String id = "local-gemma";
|
||||
ModelProviderEntity p = customProvider(id);
|
||||
when(providerMapper.selectById(id)).thenReturn(p);
|
||||
|
||||
service.deleteCustomProvider(id);
|
||||
|
||||
verify(modelConfigService).deleteModelsByProvider(id);
|
||||
verify(providerMapper).deleteById(id);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("deleteCustomProvider refuses to delete a built-in (non-custom) provider")
|
||||
void refusesToDeleteBuiltin() {
|
||||
String id = "openai";
|
||||
ModelProviderEntity builtin = customProvider(id);
|
||||
builtin.setIsCustom(false);
|
||||
when(providerMapper.selectById(id)).thenReturn(builtin);
|
||||
|
||||
MateClawException ex = assertThrows(MateClawException.class,
|
||||
() -> service.deleteCustomProvider(id));
|
||||
|
||||
assertEquals("err.llm.provider_builtin_readonly", ex.getMsgKey());
|
||||
verify(providerMapper, never()).deleteById(any(String.class));
|
||||
verify(modelConfigService, never()).deleteModelsByProvider(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("updateProviderConfig can switch an existing custom provider to keyless mode")
|
||||
void updateProviderConfigCanDisableApiKeyRequirement() {
|
||||
String id = "internal-llm";
|
||||
ModelProviderEntity existing = customProvider(id);
|
||||
existing.setBaseUrl("http://llm.internal/v1");
|
||||
existing.setRequireApiKey(true);
|
||||
when(providerMapper.selectById(id)).thenReturn(existing);
|
||||
when(modelConfigService.listModelsByProvider(id)).thenReturn(java.util.List.of());
|
||||
|
||||
ProviderConfigRequest req = new ProviderConfigRequest();
|
||||
req.setBaseUrl("http://llm.internal/v1");
|
||||
req.setProtocol("openai-compatible");
|
||||
req.setChatModel("OpenAIChatModel");
|
||||
req.setRequireApiKey(false);
|
||||
|
||||
service.updateProviderConfig(id, req);
|
||||
|
||||
assertFalse(existing.getRequireApiKey());
|
||||
verify(providerMapper).updateById(existing);
|
||||
}
|
||||
|
||||
// ==================== fixtures ====================
|
||||
|
||||
private static CreateCustomProviderRequest req(String id, String name) {
|
||||
CreateCustomProviderRequest r = new CreateCustomProviderRequest();
|
||||
r.setId(id);
|
||||
r.setName(name);
|
||||
r.setProtocol("openai-compatible");
|
||||
r.setChatModel("OpenAIChatModel");
|
||||
return r;
|
||||
}
|
||||
|
||||
private static ModelProviderEntity customProvider(String id) {
|
||||
ModelProviderEntity p = new ModelProviderEntity();
|
||||
p.setProviderId(id);
|
||||
p.setName(id);
|
||||
p.setIsCustom(true);
|
||||
p.setIsLocal(false);
|
||||
p.setEnabled(true);
|
||||
return p;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user