feat(channel): extensible magic commands (/new /help /status /stop)

This commit is contained in:
matevip 2026-07-21 10:28:41 +08:00
parent 0cfd8b133a
commit beb1a8c243
3 changed files with 378 additions and 86 deletions

View File

@ -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.
* <p>
* Matching rules:
* <ul>
* <li>Case-insensitive; the whole message is trimmed first.</li>
* <li>Bare aliases (no leading "/") match only when the entire message is
* exactly the alias "clear 一下北京天气" is normal prompt text.</li>
* <li>Slash-prefixed aliases may carry trailing arguments after the first
* whitespace; the remainder is passed through verbatim as args.</li>
* </ul>
*/
final class ChannelMagicCommand {
private static final Set<String> 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<String, Type> ALIASES = buildAliases();
private ChannelMagicCommand() {
}
static boolean isClearCommand(String text) {
String normalized = normalize(text);
return !normalized.isEmpty() && CLEAR_COMMANDS.contains(normalized);
static Optional<Parsed> 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<String, Type> buildAliases() {
Map<String, Type> 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<String, Type> 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;
}
}

View File

@ -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) {

View File

@ -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<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", "/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<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 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));
}
}
}