mateclaw/mateclaw-server/src/test/java/vip/mate/channel/ChannelMagicCommandTest.java

396 lines
17 KiB
Java

package vip.mate.channel;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import vip.mate.agent.AgentService;
import vip.mate.agent.context.ChatOrigin;
import vip.mate.agent.model.AgentEntity;
import vip.mate.approval.ApprovalWorkflowService;
import vip.mate.channel.model.ChannelEntity;
import vip.mate.channel.notification.ApprovalNotificationService;
import vip.mate.channel.service.ChannelService;
import vip.mate.channel.web.ChatStreamTracker;
import vip.mate.llm.model.ModelConfigEntity;
import vip.mate.llm.service.ModelConfigService;
import vip.mate.memory.event.ConversationCompletionPublisher;
import vip.mate.tts.TtsService;
import vip.mate.workspace.conversation.ConversationService;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class ChannelMagicCommandTest {
// ==================== parse matrix ====================
@Test
@DisplayName("clear aliases are recognized as channel magic commands")
void clearAliasesAreRecognized() {
assertParsed("clear", ChannelMagicCommand.Type.CLEAR);
assertParsed("/clear", ChannelMagicCommand.Type.CLEAR);
assertParsed(" 清空上下文 ", ChannelMagicCommand.Type.CLEAR);
assertParsed("清理上下文", ChannelMagicCommand.Type.CLEAR);
assertParsed("/reset", ChannelMagicCommand.Type.CLEAR);
assertParsed("CLEAR", ChannelMagicCommand.Type.CLEAR);
}
@Test
@DisplayName("new/help/status/stop aliases are recognized")
void extendedAliasesAreRecognized() {
assertParsed("/new", ChannelMagicCommand.Type.NEW);
assertParsed("新会话", ChannelMagicCommand.Type.NEW);
assertParsed("/新对话", ChannelMagicCommand.Type.NEW);
assertParsed("/help", ChannelMagicCommand.Type.HELP);
assertParsed("帮助", ChannelMagicCommand.Type.HELP);
assertParsed("/status", ChannelMagicCommand.Type.STATUS);
assertParsed("状态", ChannelMagicCommand.Type.STATUS);
assertParsed("/stop", ChannelMagicCommand.Type.STOP);
assertParsed("停止", ChannelMagicCommand.Type.STOP);
assertParsed("stop", ChannelMagicCommand.Type.STOP);
}
@Test
@DisplayName("model command is slash-only: bare word stays ordinary prose")
void modelCommandIsSlashOnly() {
assertParsed("/model", ChannelMagicCommand.Type.MODEL);
assertParsed("/模型", ChannelMagicCommand.Type.MODEL);
Optional<ChannelMagicCommand.Parsed> withArgs = ChannelMagicCommand.parse("/model qwen-max");
assertTrue(withArgs.isPresent());
assertEquals(ChannelMagicCommand.Type.MODEL, withArgs.get().type());
assertEquals("qwen-max", withArgs.get().args());
// "model"/"模型" are common standalone words — never commands bare.
assertNotParsed("model");
assertNotParsed("模型");
assertNotParsed("模型是什么");
}
@Test
@DisplayName("bare aliases with trailing text are ordinary prompts, not commands")
void bareAliasesWithRemainderDoNotMatch() {
assertNotParsed("clear 一下北京天气");
assertNotParsed("请清理上下文后继续");
assertNotParsed("stop the server");
assertNotParsed("status report 写一份");
assertNotParsed("帮助我写周报");
assertNotParsed("new year plan");
assertNotParsed("/approval approve abc123");
assertNotParsed("");
assertNotParsed(null);
}
@Test
@DisplayName("slash-prefixed commands may carry trailing arguments")
void slashCommandsCarryArgs() {
Optional<ChannelMagicCommand.Parsed> parsed = ChannelMagicCommand.parse("/stop now please");
assertTrue(parsed.isPresent());
assertEquals(ChannelMagicCommand.Type.STOP, parsed.get().type());
assertEquals("now please", parsed.get().args());
}
@Test
@DisplayName("help text lists every registered command")
void helpTextListsAllCommands() {
String help = ChannelMagicCommand.helpText();
for (String name : new String[]{"/clear", "/new", "/stop", "/status", "/model", "/help"}) {
assertTrue(help.contains(name), "help text missing " + name);
}
}
// ==================== router dispatch behavior ====================
@Test
@DisplayName("clear command wipes current channel conversation and does not call agent")
void clearCommandClearsConversationWithoutCallingAgent() throws Exception {
Fixture f = new Fixture();
f.process("/clear");
verify(f.conversationService).clearMessages("wecom:alice");
// Confirmation must ride renderAndSend so adapters that pre-post a
// "thinking..." placeholder (WeCom reply_stream) consume it — a plain
// sendMessage leaves the placeholder bubble dangling forever.
verify(f.adapter).renderAndSend(eq("reply-1"), contains("上下文已清理"));
verify(f.adapter, never()).sendMessage(anyString(), anyString());
f.verifyAgentNeverCalled();
}
@Test
@DisplayName("new command clears conversation with its own confirmation")
void newCommandClearsConversation() throws Exception {
Fixture f = new Fixture();
f.process("/new");
verify(f.conversationService).clearMessages("wecom:alice");
verify(f.adapter).renderAndSend(eq("reply-1"), contains("新会话"));
f.verifyAgentNeverCalled();
}
@Test
@DisplayName("stop command requests stream stop and reports whether anything was running")
void stopCommandRequestsStop() throws Exception {
Fixture f = new Fixture();
when(f.streamTracker.requestStop("wecom:alice")).thenReturn(true);
f.process("/stop");
verify(f.streamTracker).requestStop("wecom:alice");
verify(f.adapter).renderAndSend(eq("reply-1"), contains("已停止"));
verify(f.conversationService, never()).clearMessages(anyString());
f.verifyAgentNeverCalled();
}
@Test
@DisplayName("stop command with no running task replies idle hint")
void stopCommandNothingRunning() throws Exception {
Fixture f = new Fixture();
when(f.streamTracker.requestStop("wecom:alice")).thenReturn(false);
f.process("/stop");
verify(f.adapter).renderAndSend(eq("reply-1"), contains("没有进行中的任务"));
}
@Test
@DisplayName("status command reports agent, message count, and running state")
void statusCommandReportsState() throws Exception {
Fixture f = new Fixture();
AgentEntity agent = new AgentEntity();
agent.setName("会议助理");
agent.setModelName("qwen-max");
when(f.agentService.getAgent(100L)).thenReturn(agent);
when(f.conversationService.countMessages("wecom:alice")).thenReturn(12L);
when(f.streamTracker.isRunning("wecom:alice")).thenReturn(true);
f.process("/status");
verify(f.adapter).renderAndSend(eq("reply-1"), argThat(text ->
text.contains("会议助理") && text.contains("qwen-max")
&& text.contains("12") && text.contains("进行中")));
f.verifyAgentNeverCalled();
}
@Test
@DisplayName("status command degrades gracefully when channel has no agent")
void statusCommandWithoutAgent() throws Exception {
Fixture f = new Fixture();
f.channel.setAgentId(null);
f.process("/status");
verify(f.adapter).renderAndSend(eq("reply-1"), contains("未绑定"));
}
@Test
@DisplayName("help command replies command list without touching conversation")
void helpCommandRepliesList() throws Exception {
Fixture f = new Fixture();
f.process("/help");
verify(f.adapter).renderAndSend(eq("reply-1"), contains("/clear"));
verify(f.conversationService, never()).clearMessages(anyString());
f.verifyAgentNeverCalled();
}
@Test
@DisplayName("model command lists enabled models with pin/reset usage")
void modelCommandListsModels() throws Exception {
Fixture f = new Fixture();
when(f.modelConfigService.listEnabledModels()).thenReturn(List.of(
model("dashscope", "qwen-max"), model("anthropic", "claude-sonnet-5")));
f.process("/model");
verify(f.adapter).renderAndSend(eq("reply-1"), argThat(text ->
text.contains("qwen-max") && text.contains("claude-sonnet-5")
&& text.contains("/model reset")));
f.verifyAgentNeverCalled();
}
@Test
@DisplayName("model switch pins the matched model on the conversation (creating it first)")
void modelSwitchPinsConversationModel() throws Exception {
Fixture f = new Fixture();
when(f.modelConfigService.listEnabledModels()).thenReturn(List.of(
model("dashscope", "qwen-max"), model("anthropic", "claude-sonnet-5")));
f.process("/model qwen-max");
// Magic commands run before processMessage's get-or-create, so the
// switch must ensure the row exists before pinning — otherwise a
// /model sent as the very first message is silently lost.
verify(f.conversationService).getOrCreateSharedConversation("wecom:alice", 100L, null);
verify(f.conversationService).updateConversationModel("wecom:alice", "dashscope", "qwen-max");
verify(f.adapter).renderAndSend(eq("reply-1"), contains("已切换"));
f.verifyAgentNeverCalled();
}
@Test
@DisplayName("ambiguous bare model name asks for a provider prefix instead of guessing")
void modelSwitchAmbiguousAsksForPrefix() throws Exception {
Fixture f = new Fixture();
when(f.modelConfigService.listEnabledModels()).thenReturn(List.of(
model("dashscope", "qwen-max"), model("mirror", "qwen-max")));
f.process("/model qwen-max");
verify(f.adapter).renderAndSend(eq("reply-1"), contains("多个 provider"));
verify(f.conversationService, never()).updateConversationModel(anyString(), anyString(), anyString());
}
@Test
@DisplayName("model list caps rows and points to keyword search")
void modelListCapsRows() throws Exception {
Fixture f = new Fixture();
List<ModelConfigEntity> many = new ArrayList<>();
for (int i = 0; i < 25; i++) {
many.add(model("p" + i, "m" + i));
}
when(f.modelConfigService.listEnabledModels()).thenReturn(many);
f.process("/model");
// A 180+-row catalog would otherwise segment into several IM bubbles.
verify(f.adapter).renderAndSend(eq("reply-1"), argThat(text ->
text.contains("共 25 个") && !text.contains("- p24:m24")));
}
@Test
@DisplayName("no exact match but partial hits → fuzzy suggestions, no pin")
void modelSwitchFuzzySuggests() throws Exception {
Fixture f = new Fixture();
when(f.modelConfigService.listEnabledModels()).thenReturn(List.of(
model("dashscope", "qwen-max"), model("dashscope", "qwen-plus"),
model("openai", "gpt-4o")));
f.process("/model qwen");
verify(f.adapter).renderAndSend(eq("reply-1"), argThat(text ->
text.contains("相近") && text.contains("qwen-max")
&& text.contains("qwen-plus") && !text.contains("gpt-4o")));
verify(f.conversationService, never()).updateConversationModel(anyString(), anyString(), anyString());
}
@Test
@DisplayName("model reset clears the conversation pin")
void modelResetClearsPin() throws Exception {
Fixture f = new Fixture();
f.process("/model reset");
verify(f.conversationService).clearConversationModel("wecom:alice");
verify(f.adapter).renderAndSend(eq("reply-1"), contains("恢复默认"));
verify(f.conversationService, never()).updateConversationModel(anyString(), anyString(), anyString());
}
@Test
@DisplayName("unknown model name replies with a lookup hint, never pins")
void modelSwitchUnknownName() throws Exception {
Fixture f = new Fixture();
when(f.modelConfigService.listEnabledModels()).thenReturn(List.of(
model("dashscope", "qwen-max")));
f.process("/model gpt-99");
verify(f.adapter).renderAndSend(eq("reply-1"), contains("未找到"));
verify(f.conversationService, never()).updateConversationModel(anyString(), anyString(), anyString());
}
@Test
@DisplayName("model switch without a bound agent replies binding hint")
void modelSwitchWithoutAgent() throws Exception {
Fixture f = new Fixture();
f.channel.setAgentId(null);
when(f.modelConfigService.listEnabledModels()).thenReturn(List.of(
model("dashscope", "qwen-max")));
f.process("/model qwen-max");
verify(f.adapter).renderAndSend(eq("reply-1"), contains("未绑定"));
verify(f.conversationService, never()).updateConversationModel(anyString(), anyString(), anyString());
}
// ==================== helpers ====================
private static ModelConfigEntity model(String provider, String name) {
ModelConfigEntity m = new ModelConfigEntity();
m.setProvider(provider);
m.setModelName(name);
return m;
}
private static void assertParsed(String text, ChannelMagicCommand.Type expected) {
Optional<ChannelMagicCommand.Parsed> parsed = ChannelMagicCommand.parse(text);
assertTrue(parsed.isPresent(), "expected command match for: " + text);
assertEquals(expected, parsed.get().type(), "wrong type for: " + text);
}
private static void assertNotParsed(String text) {
assertTrue(ChannelMagicCommand.parse(text).isEmpty(),
"expected no command match for: " + text);
}
/** Mocks + router wiring shared by the dispatch tests. */
private static final class Fixture {
final AgentService agentService = mock(AgentService.class);
final ConversationService conversationService = mock(ConversationService.class);
final ChatStreamTracker streamTracker = mock(ChatStreamTracker.class);
final ModelConfigService modelConfigService = mock(ModelConfigService.class);
final ChannelAdapter adapter = mock(ChannelAdapter.class);
final ChannelEntity channel = new ChannelEntity();
final ChannelMessageRouter router;
Fixture() throws Exception {
ChannelService channelService = mock(ChannelService.class);
ChannelSessionStore channelSessionStore = mock(ChannelSessionStore.class);
ApprovalWorkflowService approvalService = mock(ApprovalWorkflowService.class);
ApprovalNotificationService approvalNotificationService = mock(ApprovalNotificationService.class);
ConversationCompletionPublisher completionPublisher = mock(ConversationCompletionPublisher.class);
TtsService ttsService = mock(TtsService.class);
ChannelChatOriginFactory chatOriginFactory = mock(ChannelChatOriginFactory.class);
ChannelErrorClassifier errorClassifier = mock(ChannelErrorClassifier.class);
router = new ChannelMessageRouter(agentService, conversationService,
channelService, channelSessionStore, approvalService, approvalNotificationService,
completionPublisher, ttsService, new ObjectMapper(), streamTracker,
chatOriginFactory, errorClassifier,
new InboundMessageDeduplicator(new ChannelDedupProperties()));
when(adapter.getChannelType()).thenReturn("wecom");
channel.setAgentId(100L);
// modelConfigService is field-injected on the real router (optional
// dep); mirror that wiring here via reflection.
Field mcs = ChannelMessageRouter.class.getDeclaredField("modelConfigService");
mcs.setAccessible(true);
mcs.set(router, modelConfigService);
}
void process(String content) throws Exception {
ChannelMessage message = ChannelMessage.builder()
.senderId("alice")
.replyToken("reply-1")
.content(content)
.build();
Method process = ChannelMessageRouter.class.getDeclaredMethod(
"processMessage", ChannelMessage.class, ChannelAdapter.class,
ChannelEntity.class, String.class);
process.setAccessible(true);
process.invoke(router, message, adapter, channel, "wecom:alice");
}
void verifyAgentNeverCalled() {
verify(conversationService, never()).saveMessage(anyString(), anyString(), anyString(), any(), anyString());
verify(agentService, never()).chatStructuredStream(
anyLong(), anyString(), anyString(), anyString(), any(ChatOrigin.class));
}
}
}