mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 11:13:43 +08:00
396 lines
17 KiB
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));
|
|
}
|
|
}
|
|
}
|