diff --git a/mateclaw-server/src/main/java/vip/mate/channel/ChannelMagicCommand.java b/mateclaw-server/src/main/java/vip/mate/channel/ChannelMagicCommand.java
index 5e21ef99..0ce3b0a6 100644
--- a/mateclaw-server/src/main/java/vip/mate/channel/ChannelMagicCommand.java
+++ b/mateclaw-server/src/main/java/vip/mate/channel/ChannelMagicCommand.java
@@ -1,47 +1,122 @@
package vip.mate.channel;
+import java.util.LinkedHashMap;
import java.util.Locale;
-import java.util.Set;
+import java.util.Map;
+import java.util.Optional;
/**
* User-typed channel control commands that should be handled by the platform
* instead of being sent to the agent as normal prompt text.
+ *
+ * Matching rules:
+ *
+ * - Case-insensitive; the whole message is trimmed first.
+ * - Bare aliases (no leading "/") match only when the entire message is
+ * exactly the alias — "clear 一下北京天气" is normal prompt text.
+ * - Slash-prefixed aliases may carry trailing arguments after the first
+ * whitespace; the remainder is passed through verbatim as args.
+ *
*/
final class ChannelMagicCommand {
- private static final Set CLEAR_COMMANDS = Set.of(
- "clear",
- "/clear",
- "reset",
- "/reset",
- "清空",
- "/清空",
- "清空上下文",
- "/清空上下文",
- "清理上下文",
- "/清理上下文",
- "清除上下文",
- "/清除上下文",
- "重置上下文",
- "/重置上下文"
- );
+ /** Platform-level command kinds, dispatched by {@link ChannelMessageRouter}. */
+ enum Type { CLEAR, NEW, HELP, STATUS, STOP }
+
+ /** A recognized command plus its raw (possibly empty) argument string. */
+ record Parsed(Type type, String args) {
+ }
+
+ /**
+ * Alias token → command type. LinkedHashMap keeps registration ordering
+ * stable. Every bare alias also registers its "/"-prefixed twin.
+ */
+ private static final Map ALIASES = buildAliases();
private ChannelMagicCommand() {
}
- static boolean isClearCommand(String text) {
- String normalized = normalize(text);
- return !normalized.isEmpty() && CLEAR_COMMANDS.contains(normalized);
+ static Optional parse(String text) {
+ String trimmed = text == null ? "" : text.trim();
+ if (trimmed.isEmpty()) {
+ return Optional.empty();
+ }
+ String lower = trimmed.toLowerCase(Locale.ROOT);
+ Type wholeMatch = ALIASES.get(lower);
+ if (wholeMatch != null) {
+ return Optional.of(new Parsed(wholeMatch, ""));
+ }
+ // Only slash-prefixed commands may carry arguments; bare words with a
+ // trailing remainder are ordinary prompts, never commands.
+ if (!lower.startsWith("/")) {
+ return Optional.empty();
+ }
+ int ws = indexOfWhitespace(lower);
+ if (ws < 0) {
+ return Optional.empty();
+ }
+ Type type = ALIASES.get(lower.substring(0, ws));
+ if (type == null) {
+ return Optional.empty();
+ }
+ return Optional.of(new Parsed(type, trimmed.substring(ws).trim()));
}
static String clearConfirmation() {
return "✅ 上下文已清理,后续消息会从新的上下文开始。";
}
- private static String normalize(String text) {
- if (text == null) {
- return "";
+ static String newConfirmation() {
+ return "✨ 已开启新会话,之前的上下文不会带入。";
+ }
+
+ static String stopConfirmation() {
+ return "⏹️ 已停止当前任务。";
+ }
+
+ static String stopNothingRunning() {
+ return "当前没有进行中的任务。";
+ }
+
+ static String helpText() {
+ return """
+ 🪄 可用命令:
+ /clear — 清空当前会话上下文(别名:/reset、清空上下文)
+ /new — 开启新会话(别名:新会话)
+ /stop — 停止当前进行中的任务(别名:停止)
+ /status — 查看当前会话状态(别名:状态)
+ /help — 显示本帮助(别名:帮助)""";
+ }
+
+ private static Map buildAliases() {
+ Map aliases = new LinkedHashMap<>();
+ register(aliases, Type.CLEAR,
+ "clear", "reset",
+ "清空", "清空上下文", "清理上下文", "清除上下文", "重置上下文");
+ register(aliases, Type.NEW,
+ "new", "新会话", "新对话");
+ register(aliases, Type.HELP,
+ "help", "帮助");
+ register(aliases, Type.STATUS,
+ "status", "状态");
+ register(aliases, Type.STOP,
+ "stop", "停止");
+ return aliases;
+ }
+
+ private static void register(Map aliases, Type type, String... names) {
+ for (String name : names) {
+ aliases.put(name, type);
+ aliases.put("/" + name, type);
}
- return text.trim().toLowerCase(Locale.ROOT);
+ }
+
+ private static int indexOfWhitespace(String text) {
+ for (int i = 0; i < text.length(); i++) {
+ if (Character.isWhitespace(text.charAt(i))) {
+ return i;
+ }
+ }
+ return -1;
}
}
diff --git a/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java b/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java
index 1402e1ec..cf400a0f 100644
--- a/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java
+++ b/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java
@@ -281,7 +281,7 @@ public class ChannelMessageRouter {
channelEntity = fresh;
String conversationId = buildConversationId(message);
- if (handleMagicCommand(message, adapter, conversationId)) {
+ if (handleMagicCommand(message, adapter, channelEntity, conversationId)) {
return;
}
@@ -605,16 +605,18 @@ public class ChannelMessageRouter {
}
channelEntity = fresh;
Long agentId = channelEntity.getAgentId();
- if (agentId == null) {
- log.warn("[{}] Channel {} has no associated agent at processing time; dropping message from {}",
- adapter.getChannelType(), channelEntity.getName(), message.getSenderId());
- return;
- }
log.info("[{}] Processing message: sender={}, conversationId={}, agentId={}",
adapter.getChannelType(), message.getSenderId(), conversationId, agentId);
try {
- if (handleMagicCommand(message, adapter, conversationId)) {
+ // Magic commands run before the agent-binding check so /help and
+ // /status still answer on a channel with no agent attached.
+ if (handleMagicCommand(message, adapter, channelEntity, conversationId)) {
+ return;
+ }
+ if (agentId == null) {
+ log.warn("[{}] Channel {} has no associated agent at processing time; dropping message from {}",
+ adapter.getChannelType(), channelEntity.getName(), message.getSenderId());
return;
}
@@ -797,8 +799,8 @@ public class ChannelMessageRouter {
if (adapter instanceof StreamingChannelAdapter streamingAdapter) {
savedAssistantId = processWithStreaming(message, streamingAdapter, conversationId, agentId, promptText, channelEntity, chatOrigin);
} else {
- // Sync path for non-streaming IM adapters (feishu / wecom / weixin /
- // slack / discord / qq / telegram). We can't use agentService.chat()
+ // Sync path for non-streaming IM adapters (weixin / slack /
+ // discord / qq / telegram). We can't use agentService.chat()
// because its collector filters out `delta.isEvent()` deltas — that
// would silently drop plan_created / plan_step_* events that the Web
// Console mirror needs to render PlanStepsPanel. Instead we consume
@@ -945,26 +947,82 @@ public class ChannelMessageRouter {
/**
* Handle channel-native control commands before the message is persisted
- * or forwarded to the agent. Mirrors QwenPaw's control-command dispatch:
- * a recognized command is terminal for this inbound message.
+ * or forwarded to the agent. A recognized command is terminal for this
+ * inbound message: it never reaches the debounce queue or the LLM.
*/
private boolean handleMagicCommand(ChannelMessage message, ChannelAdapter adapter,
- String conversationId) {
+ ChannelEntity channelEntity, String conversationId) {
String userText = message != null ? message.getContent() : null;
- if (!ChannelMagicCommand.isClearCommand(userText)) {
+ ChannelMagicCommand.Parsed command = ChannelMagicCommand.parse(userText).orElse(null);
+ if (command == null) {
return false;
}
- cancelPending(conversationId);
- conversationService.clearMessages(conversationId);
String replyTarget = resolveReplyTarget(message);
- if (replyTarget != null) {
- adapter.sendMessage(replyTarget, ChannelMagicCommand.clearConfirmation());
+ String reply = switch (command.type()) {
+ case CLEAR -> {
+ cancelPending(conversationId);
+ conversationService.clearMessages(conversationId);
+ yield ChannelMagicCommand.clearConfirmation();
+ }
+ case NEW -> {
+ // Channel conversation ids are deterministic (channelType:chatId),
+ // so "new session" cannot rotate the id — it clears the context
+ // like CLEAR and only differs in the confirmation wording.
+ cancelPending(conversationId);
+ conversationService.clearMessages(conversationId);
+ yield ChannelMagicCommand.newConfirmation();
+ }
+ case STOP -> {
+ cancelPending(conversationId);
+ boolean stopped = streamTracker.requestStop(conversationId);
+ yield stopped ? ChannelMagicCommand.stopConfirmation()
+ : ChannelMagicCommand.stopNothingRunning();
+ }
+ case HELP -> ChannelMagicCommand.helpText();
+ case STATUS -> buildStatusReply(channelEntity, conversationId);
+ };
+ if (replyTarget != null && reply != null) {
+ adapter.sendMessage(replyTarget, reply);
}
- log.info("[{}] Magic command handled: clear conversationId={}, sender={}",
- adapter.getChannelType(), conversationId, message != null ? message.getSenderId() : null);
+ log.info("[{}] Magic command handled: {} conversationId={}, sender={}",
+ adapter.getChannelType(), command.type(), conversationId,
+ message != null ? message.getSenderId() : null);
return true;
}
+ /** Build the /status reply; every lookup degrades gracefully to keep the command side-effect free. */
+ private String buildStatusReply(ChannelEntity channelEntity, String conversationId) {
+ StringBuilder sb = new StringBuilder("📊 会话状态\n");
+ sb.append("- 会话: ").append(conversationId).append('\n');
+ Long agentId = channelEntity != null ? channelEntity.getAgentId() : null;
+ if (agentId == null) {
+ sb.append("- 智能体: 未绑定\n");
+ } else {
+ try {
+ AgentEntity agent = agentService.getAgent(agentId);
+ if (agent != null) {
+ sb.append("- 智能体: ").append(agent.getName()).append('\n');
+ if (agent.getModelName() != null && !agent.getModelName().isBlank()) {
+ sb.append("- 模型: ").append(agent.getModelName()).append('\n');
+ }
+ } else {
+ sb.append("- 智能体: 未找到(id=").append(agentId).append(")\n");
+ }
+ } catch (Exception e) {
+ log.warn("Failed to load agent {} for /status: {}", agentId, e.getMessage());
+ sb.append("- 智能体: 查询失败\n");
+ }
+ }
+ try {
+ sb.append("- 历史消息数: ").append(conversationService.countMessages(conversationId)).append('\n');
+ } catch (Exception e) {
+ log.warn("Failed to count messages for /status: {}", e.getMessage());
+ }
+ boolean running = streamTracker.isRunning(conversationId);
+ sb.append("- 当前任务: ").append(running ? "进行中(可用 /stop 停止)" : "空闲");
+ return sb.toString();
+ }
+
private void cancelPending(String conversationId) {
PendingMessage pending;
synchronized (pendingMessages) {
diff --git a/mateclaw-server/src/test/java/vip/mate/channel/ChannelMagicCommandTest.java b/mateclaw-server/src/test/java/vip/mate/channel/ChannelMagicCommandTest.java
index 9c6c7c22..d1cd1f9a 100644
--- a/mateclaw-server/src/test/java/vip/mate/channel/ChannelMagicCommandTest.java
+++ b/mateclaw-server/src/test/java/vip/mate/channel/ChannelMagicCommandTest.java
@@ -5,6 +5,7 @@ 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;
@@ -15,66 +16,224 @@ import vip.mate.tts.TtsService;
import vip.mate.workspace.conversation.ConversationService;
import java.lang.reflect.Method;
+import java.util.Optional;
-import static org.junit.jupiter.api.Assertions.assertFalse;
+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() {
- assertTrue(ChannelMagicCommand.isClearCommand("clear"));
- assertTrue(ChannelMagicCommand.isClearCommand("/clear"));
- assertTrue(ChannelMagicCommand.isClearCommand(" 清空上下文 "));
- assertTrue(ChannelMagicCommand.isClearCommand("清理上下文"));
- assertTrue(ChannelMagicCommand.isClearCommand("/reset"));
-
- assertFalse(ChannelMagicCommand.isClearCommand("clear 一下北京天气"));
- assertFalse(ChannelMagicCommand.isClearCommand("请清理上下文后继续"));
- assertFalse(ChannelMagicCommand.isClearCommand("/approval approve abc123"));
+ 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("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 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", "/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 {
- AgentService agentService = mock(AgentService.class);
- ConversationService conversationService = mock(ConversationService.class);
- 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);
- ChatStreamTracker streamTracker = mock(ChatStreamTracker.class);
- ChannelChatOriginFactory chatOriginFactory = mock(ChannelChatOriginFactory.class);
- ChannelErrorClassifier errorClassifier = mock(ChannelErrorClassifier.class);
- ChannelMessageRouter router = new ChannelMessageRouter(agentService, conversationService,
- channelService, channelSessionStore, approvalService, approvalNotificationService,
- completionPublisher, ttsService, new ObjectMapper(), streamTracker,
- chatOriginFactory, errorClassifier);
+ Fixture f = new Fixture();
- ChannelAdapter adapter = mock(ChannelAdapter.class);
- when(adapter.getChannelType()).thenReturn("wecom");
- ChannelEntity channel = new ChannelEntity();
- channel.setAgentId(100L);
- ChannelMessage message = ChannelMessage.builder()
- .senderId("alice")
- .replyToken("reply-1")
- .content("/clear")
- .build();
+ f.process("/clear");
- 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");
+ verify(f.conversationService).clearMessages("wecom:alice");
+ verify(f.adapter).sendMessage(eq("reply-1"), contains("上下文已清理"));
+ f.verifyAgentNeverCalled();
+ }
- verify(conversationService).clearMessages("wecom:alice");
- verify(adapter).sendMessage(eq("reply-1"), contains("上下文已清理"));
- verify(conversationService, never()).saveMessage(anyString(), anyString(), anyString(), any(), anyString());
- verify(agentService, never()).chatStructuredStream(
- anyLong(), anyString(), anyString(), anyString(), any(ChatOrigin.class));
+ @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).sendMessage(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).sendMessage(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).sendMessage(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).sendMessage(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).sendMessage(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).sendMessage(eq("reply-1"), contains("/clear"));
+ verify(f.conversationService, never()).clearMessages(anyString());
+ f.verifyAgentNeverCalled();
+ }
+
+ // ==================== helpers ====================
+
+ private static void assertParsed(String text, ChannelMagicCommand.Type expected) {
+ Optional 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 ChannelAdapter adapter = mock(ChannelAdapter.class);
+ final ChannelEntity channel = new ChannelEntity();
+ final ChannelMessageRouter router;
+
+ Fixture() {
+ 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);
+ when(adapter.getChannelType()).thenReturn("wecom");
+ channel.setAgentId(100L);
+ }
+
+ 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));
+ }
}
}