mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-16 04:18:17 +08:00
feat(channel): extensible magic commands (/new /help /status /stop)
This commit is contained in:
parent
0cfd8b133a
commit
beb1a8c243
@ -1,47 +1,122 @@
|
|||||||
package vip.mate.channel;
|
package vip.mate.channel;
|
||||||
|
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
import java.util.Locale;
|
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
|
* User-typed channel control commands that should be handled by the platform
|
||||||
* instead of being sent to the agent as normal prompt text.
|
* 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 {
|
final class ChannelMagicCommand {
|
||||||
|
|
||||||
private static final Set<String> CLEAR_COMMANDS = Set.of(
|
/** Platform-level command kinds, dispatched by {@link ChannelMessageRouter}. */
|
||||||
"clear",
|
enum Type { CLEAR, NEW, HELP, STATUS, STOP }
|
||||||
"/clear",
|
|
||||||
"reset",
|
/** A recognized command plus its raw (possibly empty) argument string. */
|
||||||
"/reset",
|
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() {
|
private ChannelMagicCommand() {
|
||||||
}
|
}
|
||||||
|
|
||||||
static boolean isClearCommand(String text) {
|
static Optional<Parsed> parse(String text) {
|
||||||
String normalized = normalize(text);
|
String trimmed = text == null ? "" : text.trim();
|
||||||
return !normalized.isEmpty() && CLEAR_COMMANDS.contains(normalized);
|
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() {
|
static String clearConfirmation() {
|
||||||
return "✅ 上下文已清理,后续消息会从新的上下文开始。";
|
return "✅ 上下文已清理,后续消息会从新的上下文开始。";
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String normalize(String text) {
|
static String newConfirmation() {
|
||||||
if (text == null) {
|
return "✨ 已开启新会话,之前的上下文不会带入。";
|
||||||
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -281,7 +281,7 @@ public class ChannelMessageRouter {
|
|||||||
channelEntity = fresh;
|
channelEntity = fresh;
|
||||||
|
|
||||||
String conversationId = buildConversationId(message);
|
String conversationId = buildConversationId(message);
|
||||||
if (handleMagicCommand(message, adapter, conversationId)) {
|
if (handleMagicCommand(message, adapter, channelEntity, conversationId)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -605,16 +605,18 @@ public class ChannelMessageRouter {
|
|||||||
}
|
}
|
||||||
channelEntity = fresh;
|
channelEntity = fresh;
|
||||||
Long agentId = channelEntity.getAgentId();
|
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={}",
|
log.info("[{}] Processing message: sender={}, conversationId={}, agentId={}",
|
||||||
adapter.getChannelType(), message.getSenderId(), conversationId, agentId);
|
adapter.getChannelType(), message.getSenderId(), conversationId, agentId);
|
||||||
|
|
||||||
try {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -797,8 +799,8 @@ public class ChannelMessageRouter {
|
|||||||
if (adapter instanceof StreamingChannelAdapter streamingAdapter) {
|
if (adapter instanceof StreamingChannelAdapter streamingAdapter) {
|
||||||
savedAssistantId = processWithStreaming(message, streamingAdapter, conversationId, agentId, promptText, channelEntity, chatOrigin);
|
savedAssistantId = processWithStreaming(message, streamingAdapter, conversationId, agentId, promptText, channelEntity, chatOrigin);
|
||||||
} else {
|
} else {
|
||||||
// Sync path for non-streaming IM adapters (feishu / wecom / weixin /
|
// Sync path for non-streaming IM adapters (weixin / slack /
|
||||||
// slack / discord / qq / telegram). We can't use agentService.chat()
|
// discord / qq / telegram). We can't use agentService.chat()
|
||||||
// because its collector filters out `delta.isEvent()` deltas — that
|
// because its collector filters out `delta.isEvent()` deltas — that
|
||||||
// would silently drop plan_created / plan_step_* events that the Web
|
// would silently drop plan_created / plan_step_* events that the Web
|
||||||
// Console mirror needs to render PlanStepsPanel. Instead we consume
|
// 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
|
* Handle channel-native control commands before the message is persisted
|
||||||
* or forwarded to the agent. Mirrors QwenPaw's control-command dispatch:
|
* or forwarded to the agent. A recognized command is terminal for this
|
||||||
* a recognized command is terminal for this inbound message.
|
* inbound message: it never reaches the debounce queue or the LLM.
|
||||||
*/
|
*/
|
||||||
private boolean handleMagicCommand(ChannelMessage message, ChannelAdapter adapter,
|
private boolean handleMagicCommand(ChannelMessage message, ChannelAdapter adapter,
|
||||||
String conversationId) {
|
ChannelEntity channelEntity, String conversationId) {
|
||||||
String userText = message != null ? message.getContent() : null;
|
String userText = message != null ? message.getContent() : null;
|
||||||
if (!ChannelMagicCommand.isClearCommand(userText)) {
|
ChannelMagicCommand.Parsed command = ChannelMagicCommand.parse(userText).orElse(null);
|
||||||
|
if (command == null) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
cancelPending(conversationId);
|
|
||||||
conversationService.clearMessages(conversationId);
|
|
||||||
String replyTarget = resolveReplyTarget(message);
|
String replyTarget = resolveReplyTarget(message);
|
||||||
if (replyTarget != null) {
|
String reply = switch (command.type()) {
|
||||||
adapter.sendMessage(replyTarget, ChannelMagicCommand.clearConfirmation());
|
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={}",
|
log.info("[{}] Magic command handled: {} conversationId={}, sender={}",
|
||||||
adapter.getChannelType(), conversationId, message != null ? message.getSenderId() : null);
|
adapter.getChannelType(), command.type(), conversationId,
|
||||||
|
message != null ? message.getSenderId() : null);
|
||||||
return true;
|
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) {
|
private void cancelPending(String conversationId) {
|
||||||
PendingMessage pending;
|
PendingMessage pending;
|
||||||
synchronized (pendingMessages) {
|
synchronized (pendingMessages) {
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import org.junit.jupiter.api.DisplayName;
|
|||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import vip.mate.agent.AgentService;
|
import vip.mate.agent.AgentService;
|
||||||
import vip.mate.agent.context.ChatOrigin;
|
import vip.mate.agent.context.ChatOrigin;
|
||||||
|
import vip.mate.agent.model.AgentEntity;
|
||||||
import vip.mate.approval.ApprovalWorkflowService;
|
import vip.mate.approval.ApprovalWorkflowService;
|
||||||
import vip.mate.channel.model.ChannelEntity;
|
import vip.mate.channel.model.ChannelEntity;
|
||||||
import vip.mate.channel.notification.ApprovalNotificationService;
|
import vip.mate.channel.notification.ApprovalNotificationService;
|
||||||
@ -15,66 +16,224 @@ import vip.mate.tts.TtsService;
|
|||||||
import vip.mate.workspace.conversation.ConversationService;
|
import vip.mate.workspace.conversation.ConversationService;
|
||||||
|
|
||||||
import java.lang.reflect.Method;
|
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.junit.jupiter.api.Assertions.assertTrue;
|
||||||
import static org.mockito.ArgumentMatchers.*;
|
import static org.mockito.ArgumentMatchers.*;
|
||||||
import static org.mockito.Mockito.*;
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
class ChannelMagicCommandTest {
|
class ChannelMagicCommandTest {
|
||||||
|
|
||||||
|
// ==================== parse matrix ====================
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("clear aliases are recognized as channel magic commands")
|
@DisplayName("clear aliases are recognized as channel magic commands")
|
||||||
void clearAliasesAreRecognized() {
|
void clearAliasesAreRecognized() {
|
||||||
assertTrue(ChannelMagicCommand.isClearCommand("clear"));
|
assertParsed("clear", ChannelMagicCommand.Type.CLEAR);
|
||||||
assertTrue(ChannelMagicCommand.isClearCommand("/clear"));
|
assertParsed("/clear", ChannelMagicCommand.Type.CLEAR);
|
||||||
assertTrue(ChannelMagicCommand.isClearCommand(" 清空上下文 "));
|
assertParsed(" 清空上下文 ", ChannelMagicCommand.Type.CLEAR);
|
||||||
assertTrue(ChannelMagicCommand.isClearCommand("清理上下文"));
|
assertParsed("清理上下文", ChannelMagicCommand.Type.CLEAR);
|
||||||
assertTrue(ChannelMagicCommand.isClearCommand("/reset"));
|
assertParsed("/reset", ChannelMagicCommand.Type.CLEAR);
|
||||||
|
assertParsed("CLEAR", ChannelMagicCommand.Type.CLEAR);
|
||||||
assertFalse(ChannelMagicCommand.isClearCommand("clear 一下北京天气"));
|
|
||||||
assertFalse(ChannelMagicCommand.isClearCommand("请清理上下文后继续"));
|
|
||||||
assertFalse(ChannelMagicCommand.isClearCommand("/approval approve abc123"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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
|
@Test
|
||||||
@DisplayName("clear command wipes current channel conversation and does not call agent")
|
@DisplayName("clear command wipes current channel conversation and does not call agent")
|
||||||
void clearCommandClearsConversationWithoutCallingAgent() throws Exception {
|
void clearCommandClearsConversationWithoutCallingAgent() throws Exception {
|
||||||
AgentService agentService = mock(AgentService.class);
|
Fixture f = new Fixture();
|
||||||
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);
|
|
||||||
|
|
||||||
ChannelAdapter adapter = mock(ChannelAdapter.class);
|
f.process("/clear");
|
||||||
when(adapter.getChannelType()).thenReturn("wecom");
|
|
||||||
ChannelEntity channel = new ChannelEntity();
|
|
||||||
channel.setAgentId(100L);
|
|
||||||
ChannelMessage message = ChannelMessage.builder()
|
|
||||||
.senderId("alice")
|
|
||||||
.replyToken("reply-1")
|
|
||||||
.content("/clear")
|
|
||||||
.build();
|
|
||||||
|
|
||||||
Method process = ChannelMessageRouter.class.getDeclaredMethod(
|
verify(f.conversationService).clearMessages("wecom:alice");
|
||||||
"processMessage", ChannelMessage.class, ChannelAdapter.class, ChannelEntity.class, String.class);
|
verify(f.adapter).sendMessage(eq("reply-1"), contains("上下文已清理"));
|
||||||
process.setAccessible(true);
|
f.verifyAgentNeverCalled();
|
||||||
process.invoke(router, message, adapter, channel, "wecom:alice");
|
}
|
||||||
|
|
||||||
verify(conversationService).clearMessages("wecom:alice");
|
@Test
|
||||||
verify(adapter).sendMessage(eq("reply-1"), contains("上下文已清理"));
|
@DisplayName("new command clears conversation with its own confirmation")
|
||||||
verify(conversationService, never()).saveMessage(anyString(), anyString(), anyString(), any(), anyString());
|
void newCommandClearsConversation() throws Exception {
|
||||||
verify(agentService, never()).chatStructuredStream(
|
Fixture f = new Fixture();
|
||||||
anyLong(), anyString(), anyString(), anyString(), any(ChatOrigin.class));
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user