From beb1a8c2436fc34d756afc6b661809bf3d1edc70 Mon Sep 17 00:00:00 2001 From: matevip Date: Tue, 21 Jul 2026 10:28:41 +0800 Subject: [PATCH] feat(channel): extensible magic commands (/new /help /status /stop) --- .../vip/mate/channel/ChannelMagicCommand.java | 123 +++++++-- .../mate/channel/ChannelMessageRouter.java | 96 +++++-- .../mate/channel/ChannelMagicCommandTest.java | 245 +++++++++++++++--- 3 files changed, 378 insertions(+), 86 deletions(-) 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: + *

*/ 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)); + } } }