mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +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;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user