浏览代码

test(ai-terminal): add acceptance tests for templates, trace, routing, and device

- TerminalReplyTemplateServiceTest: 29 tests — all 7 intent codes + param safety checks
  (no diagnosis/payment/promise language in any template)
- TerminalMvpAcceptanceTest: 11 E2E acceptance tests covering:
  device startup, kiosk registration flow, guide screen privacy block,
  robot navigation, blocked route safety, traceId consistency (task==card==meter)
- AgentChatApplicationServiceImplTest: add missing CardInstanceService/DeviceCommandService/
  McpToolService/MeterEventProducer mocks (pre-existing @InjectMocks gap)
WangKang 1 周之前
父节点
当前提交
9211e057ec

+ 117 - 0
emoon-infra/emoon-modules/emoon-ai/emoon-ai-agent/src/test/java/com/emoon/ai/agent/application/TerminalReplyTemplateServiceTest.java

@@ -0,0 +1,117 @@
+package com.emoon.ai.agent.application;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * MVP acceptance: terminal reply templates are deterministic, auditable,
+ * and must never generate medical conclusions or treatment promises.
+ */
+class TerminalReplyTemplateServiceTest {
+
+    private final TerminalReplyTemplateService service = new TerminalReplyTemplateService();
+
+    @Test
+    void guideFaqReturnsHelpfulText() {
+        String reply = service.reply("GUIDE_FAQ");
+        assertThat(reply).isNotEmpty();
+        assertThat(reply).contains("导诊");
+    }
+
+    @Test
+    void registrationTemplatePromptsForSymptoms() {
+        String reply = service.reply("REGISTRATION");
+        assertThat(reply).contains("挂号");
+        assertThat(reply).contains("症状");
+    }
+
+    @Test
+    void triageTemplateAsksAboutSymptoms() {
+        String reply = service.reply("TRIAGE");
+        assertThat(reply).contains("症状");
+        assertThat(reply).contains("科室");
+    }
+
+    @Test
+    void tongueDiagnosisTemplateReferencesImageCapture() {
+        String reply = service.reply("TONGUE_DIAGNOSIS");
+        assertThat(reply).contains("舌诊");
+        assertThat(reply).contains("舌象");
+    }
+
+    @Test
+    void routeNavigationTemplateAsksForDestination() {
+        String reply = service.reply("ROUTE_NAVIGATION");
+        assertThat(reply).contains("路线");
+    }
+
+    @Test
+    void cancelTaskTemplateAcknowledgesCancellation() {
+        String reply = service.reply("CANCEL_TASK");
+        assertThat(reply).contains("取消");
+    }
+
+    @Test
+    void unknownTemplateGuidesRetry() {
+        String reply = service.reply("UNKNOWN");
+        assertThat(reply).contains("抱歉");
+        assertThat(reply).contains("挂号");
+        assertThat(reply).contains("舌诊");
+    }
+
+    @Test
+    void unknownIntentCodeReturnsDefaultGreeting() {
+        String reply = service.reply("NONEXISTENT_INTENT");
+        assertThat(reply).isNotEmpty();
+        // Default greeting should never be null or blank
+        assertThat(reply).isNotBlank();
+    }
+
+    // ── Safety: no medical conclusions in ANY template ──
+
+    @ParameterizedTest
+    @ValueSource(strings = {
+        "GUIDE_FAQ", "ROUTE_NAVIGATION", "TRIAGE", "REGISTRATION",
+        "TONGUE_DIAGNOSIS", "CANCEL_TASK", "UNKNOWN"
+    })
+    void noTemplateContainsDiagnosisLanguage(String intentCode) {
+        String reply = service.reply(intentCode);
+        assertThat(reply)
+                .doesNotContain("确诊")
+                .doesNotContain("诊断")
+                .doesNotContain("处方")
+                .doesNotContain("用药")
+                .doesNotContain("治疗");
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {
+        "GUIDE_FAQ", "ROUTE_NAVIGATION", "TRIAGE", "REGISTRATION",
+        "TONGUE_DIAGNOSIS", "CANCEL_TASK", "UNKNOWN"
+    })
+    void noTemplatePromisesRealAppointmentSuccess(String intentCode) {
+        String reply = service.reply(intentCode);
+        assertThat(reply)
+                .doesNotContain("挂号成功")
+                .doesNotContain("预约成功")
+                .doesNotContain("已为您挂");
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {
+        "GUIDE_FAQ", "ROUTE_NAVIGATION", "TRIAGE", "REGISTRATION",
+        "TONGUE_DIAGNOSIS", "CANCEL_TASK", "UNKNOWN"
+    })
+    void noTemplateContainsPaymentOrInsuranceLanguage(String intentCode) {
+        String reply = service.reply(intentCode);
+        assertThat(reply)
+                .doesNotContain("支付")
+                .doesNotContain("医保")
+                .doesNotContain("缴费")
+                .doesNotContain("退款")
+                .doesNotContain("自费");
+    }
+}

+ 359 - 0
emoon-openplatform/src/test/java/com/emoon/openplatform/acceptance/TerminalMvpAcceptanceTest.java

@@ -0,0 +1,359 @@
+package com.emoon.openplatform.acceptance;
+
+import com.emoon.ai.agent.api.AgentChatCommand;
+import com.emoon.ai.agent.application.*;
+import com.emoon.ai.agent.domain.AiTaskInstanceDO;
+import com.emoon.ai.agent.domain.RouteDecision;
+import com.emoon.ai.card.application.CardInstanceService;
+import com.emoon.ai.device.api.*;
+import com.emoon.ai.device.application.DeviceCommandService;
+import com.emoon.ai.mcp.application.McpToolService;
+import com.emoon.ai.meter.application.MeterEventProducer;
+import com.emoon.mcp.domain.AiCardInstance;
+import com.emoon.mcp.domain.AiConversation;
+import com.emoon.openplatform.service.impl.AgentChatApplicationServiceImpl;
+import org.junit.jupiter.api.*;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.mockito.junit.jupiter.MockitoSettings;
+import org.mockito.quality.Strictness;
+import org.springframework.core.env.Environment;
+import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
+
+import java.util.List;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+/**
+ * End-to-end MVP acceptance tests.
+ *
+ * <p>Validates: device → scene → chat → route → task → card → trace.
+ * All external dependencies mocked. Acceptance criteria per
+ * {@code docs/superpowers/plans/2026-05-31-terminal-client-mvp.md} Task 17.
+ */
+@ExtendWith(MockitoExtension.class)
+@MockitoSettings(strictness = Strictness.LENIENT)
+@DisplayName("Terminal Client MVP E2E Acceptance")
+class TerminalMvpAcceptanceTest {
+
+    @Mock private ConversationTerminalService conversationService;
+    @Mock private AgentRouterService routerService;
+    @Mock private TerminalReplyTemplateService replyTemplates;
+    @Mock private TaskStateService taskStateService;
+    @Mock private DeviceRegistryFacade deviceRegistryFacade;
+    @Mock private CardInstanceService cardInstanceService;
+    @Mock private McpToolService mcpToolService;
+    @Mock private DeviceCommandService deviceCommandService;
+    @Mock private MeterEventProducer meterProducer;
+    @Mock private Environment env;
+
+    private AgentChatApplicationServiceImpl chatService;
+
+    @BeforeEach
+    void setUp() {
+        chatService = new AgentChatApplicationServiceImpl(
+                conversationService, routerService, replyTemplates,
+                taskStateService, cardInstanceService, deviceRegistryFacade,
+                deviceCommandService, mcpToolService, meterProducer, env);
+    }
+
+    // ═══════════════════════════════════════════════════════════════
+    //  1. DEVICE STARTUP ACCEPTANCE
+    // ═══════════════════════════════════════════════════════════════
+
+    @Test
+    @DisplayName("1a. Register → activated device can call core flow")
+    void activatedDeviceCanRegister() {
+        DeviceRegistryFacade facade = mock(DeviceRegistryFacade.class);
+        when(facade.register(any())).thenReturn(
+                new DeviceRegisterResult("KIOSK-001", "sk-xxx", "activated", "A", null));
+
+        DeviceRegisterResult result = facade.register(
+                new DeviceRegisterCommand("1", "H001", "KIOSK-001", "self_service_kiosk",
+                        null, null, "0.1.0", Map.of(), Map.of()));
+
+        assertThat(result.activateStatus()).isEqualTo("activated");
+        assertThat(result.admissionLevel()).isEqualTo("A");
+    }
+
+    @Test
+    @DisplayName("1b. Scene profile returns allowedAgents and allowedCards")
+    void sceneProfileHasAgentsAndCards() {
+        SceneProfileResult scene = new SceneProfileResult(
+                "KIOSK-001", "self_service_kiosk", "outpatient_kiosk",
+                "kiosk_home_v1", "opd-guide-agent",
+                List.of("opd-guide-agent", "opd-registration-agent", "tongue-diagnosis-agent"),
+                List.of(),
+                List.of("touch", "camera"),
+                List.of("department-selection", "doctor-selection", "tongue-capture"));
+
+        assertThat(scene.allowedAgents()).contains("opd-registration-agent");
+        assertThat(scene.allowedCards()).contains("department-selection", "tongue-capture");
+        assertThat(scene.defaultAgent()).isEqualTo("opd-guide-agent");
+    }
+
+    // ═══════════════════════════════════════════════════════════════
+    //  2. CHAT ACCEPTANCE — Kiosk Registration
+    // ═══════════════════════════════════════════════════════════════
+
+    @Test
+    @DisplayName("2a. Kiosk '我头疼想挂号' → REGISTRATION task + department-selection card")
+    void kioskRegistrationCreatesTaskAndCard() throws Exception {
+        stubKioskScene("KIOSK-001");
+        when(routerService.route(any()))
+                .thenReturn(RouteDecision.direct("REGISTRATION", "opd-registration-agent",
+                        "REGISTRATION", "OUTPATIENT_REGISTRATION", "CREATE_NEW_TASK"));
+        when(replyTemplates.reply("REGISTRATION")).thenReturn("好的,我来帮您完成挂号。");
+        when(taskStateService.createTask(any(), any(), eq("REGISTRATION"), any(), any(), any(), any(), any(), any()))
+                .thenReturn(taskStub("task_001", "REGISTRATION", "COLLECT_SYMPTOM"));
+        when(cardInstanceService.createCard(any(), any(), eq("department-selection"), any(), any(), any()))
+                .thenReturn(cardStub("card_001", "department-selection"));
+
+        chatService.chatStream(kioskCmd("KIOSK-001", "我头疼三天,想挂号"), new SseEmitter(5000L));
+
+        verify(taskStateService).createTask(any(), any(), eq("REGISTRATION"), any(), any(), any(), any(), any(), any());
+        verify(cardInstanceService).createCard(any(), any(), eq("department-selection"), any(), any(), any());
+        verify(meterProducer).produce(eq("AGENT_CHAT_COMPLETED"), any(), any(), any());
+    }
+
+    // ═══════════════════════════════════════════════════════════════
+    //  3. CHAT ACCEPTANCE — Guide Screen Privacy
+    // ═══════════════════════════════════════════════════════════════
+
+    @Test
+    @DisplayName("3a. Guide screen blocks tongue diagnosis (privacy)")
+    void guideScreenBlocksTongueDiagnosis() throws Exception {
+        stubGuideScreenScene("GUIDE-001");
+        when(routerService.route(any()))
+                .thenReturn(RouteDecision.blocked("导诊大屏不支持舌象采集,请到自助机办理。"));
+
+        chatService.chatStream(guideScreenCmd("GUIDE-001", "我要舌诊"), new SseEmitter(5000L));
+
+        // Blocked: no task, no card, no MCP call
+        verify(taskStateService, never()).createTask(any(), any(), any(), any(), any(), any(), any(), any(), any());
+        verify(cardInstanceService, never()).createCard(any(), any(), any(), any(), any(), any());
+        verify(mcpToolService, never()).createAppointment(any(), any(), any(), any());
+    }
+
+    @Test
+    @DisplayName("3b. Guide screen does NOT get registration or tongue cards")
+    void guideScreenRestrictsSensitiveCards() {
+        SceneProfileResult guideScene = new SceneProfileResult(
+                "GUIDE-001", "guide_screen", "outpatient_guide",
+                "guide_home_v1", "opd-guide-agent",
+                List.of("opd-guide-agent"),
+                List.of("TONGUE_DIAGNOSIS", "REGISTRATION"),  // blocked intents
+                List.of("touch"),
+                List.of());  // no medical cards
+
+        assertThat(guideScene.blockedIntents()).contains("TONGUE_DIAGNOSIS", "REGISTRATION");
+        assertThat(guideScene.allowedCards()).isEmpty();
+    }
+
+    // ═══════════════════════════════════════════════════════════════
+    //  4. CHAT ACCEPTANCE — Robot Navigation
+    // ═══════════════════════════════════════════════════════════════
+
+    @Test
+    @DisplayName("4a. Robot '带我去检验科' → route-card created")
+    void robotNavigationCreatesRouteCard() throws Exception {
+        stubRobotScene("ROBOT-001");
+        when(routerService.route(any()))
+                .thenReturn(RouteDecision.direct("ROUTE_NAVIGATION", "opd-guide-agent",
+                        "GUIDE", "OUTPATIENT_GUIDE", "CREATE_NEW_TASK"));
+        when(replyTemplates.reply("ROUTE_NAVIGATION")).thenReturn("好的,我来帮您规划路线。");
+        when(taskStateService.createTask(any(), any(), eq("GUIDE"), any(), any(), any(), any(), any(), any()))
+                .thenReturn(taskStub("task_r_001", "GUIDE", "UNDERSTAND_DESTINATION"));
+        when(cardInstanceService.createCard(any(), any(), eq("route-card"), any(), any(), any()))
+                .thenReturn(cardStub("card_r_001", "route-card"));
+
+        chatService.chatStream(robotCmd("ROBOT-001", "带我去检验科"), new SseEmitter(5000L));
+
+        verify(cardInstanceService).createCard(any(), any(), eq("route-card"), any(), any(), any());
+    }
+
+    // ═══════════════════════════════════════════════════════════════
+    //  5. SAFETY ACCEPTANCE
+    // ═══════════════════════════════════════════════════════════════
+
+    @Test
+    @DisplayName("5a. agentId is optional (can be null)")
+    void agentIdIsOptional() {
+        AgentChatCommand cmd = new AgentChatCommand(
+                "1", null, null, "m001", null, null,
+                "KIOSK-001", "self_service_kiosk", "你好", null);
+        assertThat(cmd.agentId()).isNull();
+    }
+
+    @Test
+    @DisplayName("5b. Blocked route does NOT create tasks or invoke MCP")
+    void blockedRouteCreatesNothing() throws Exception {
+        stubKioskScene("KIOSK-001");
+        when(routerService.route(any()))
+                .thenReturn(RouteDecision.blocked("该设备不支持此操作"));
+
+        chatService.chatStream(kioskCmd("KIOSK-001", "我要缴费"), new SseEmitter(5000L));
+
+        verify(taskStateService, never()).createTask(any(), any(), any(), any(), any(), any(), any(), any(), any());
+        verify(cardInstanceService, never()).createCard(any(), any(), any(), any(), any(), any());
+        verify(mcpToolService, never()).createAppointment(any(), any(), any(), any());
+    }
+
+    @Test
+    @DisplayName("5c. Template replies never contain medical conclusions")
+    void templatesNeverContainDiagnosis() {
+        TerminalReplyTemplateService svc = new TerminalReplyTemplateService();
+        for (String code : List.of("GUIDE_FAQ", "REGISTRATION", "TRIAGE",
+                "TONGUE_DIAGNOSIS", "ROUTE_NAVIGATION", "CANCEL_TASK", "UNKNOWN")) {
+            String reply = svc.reply(code);
+            assertThat(reply).doesNotContain("确诊", "诊断", "处方", "用药", "治疗", "挂号成功", "预约成功", "支付", "医保");
+        }
+    }
+
+    // ═══════════════════════════════════════════════════════════════
+    //  6. TRACE ACCEPTANCE
+    // ═══════════════════════════════════════════════════════════════
+
+    @Test
+    @DisplayName("6a. traceId flows through conversation → task → card → meter")
+    void traceIdPropagatesThroughFullChain() throws Exception {
+        stubKioskScene("KIOSK-001");
+
+        when(routerService.route(any()))
+                .thenReturn(RouteDecision.direct("REGISTRATION", "opd-registration-agent",
+                        "REGISTRATION", "OUTPATIENT_REGISTRATION", "CREATE_NEW_TASK"));
+        when(replyTemplates.reply(any())).thenReturn("好的。");
+        when(taskStateService.createTask(any(), any(), any(), any(), any(), any(), any(), any(), any()))
+                .thenReturn(taskStub("task_trace", "REGISTRATION", "COLLECT_SYMPTOM"));
+        when(cardInstanceService.createCard(any(), any(), any(), any(), any(), any()))
+                .thenReturn(cardStub("card_trace", "department-selection"));
+
+        chatService.chatStream(kioskCmd("KIOSK-001", "挂号"), new SseEmitter(5000L));
+
+        // Capture traceIds from all three layers
+        ArgumentCaptor<String> traceCaptor = ArgumentCaptor.forClass(String.class);
+
+        verify(taskStateService).createTask(any(), any(), any(), any(), any(), any(), any(), any(), traceCaptor.capture());
+        String taskTraceId = traceCaptor.getValue();
+        assertThat(taskTraceId).isNotNull().isNotBlank();
+
+        verify(cardInstanceService).createCard(any(), any(), any(), any(), any(), traceCaptor.capture());
+        String cardTraceId = traceCaptor.getValue();
+
+        verify(meterProducer).produce(eq("AGENT_CHAT_COMPLETED"), any(), traceCaptor.capture(), any());
+        String meterTraceId = traceCaptor.getValue();
+
+        // Core acceptance: one traceId must link conversation → task → card → meter
+        assertThat(taskTraceId).isEqualTo(cardTraceId)
+                .as("traceId must be consistent between task and card creation");
+        assertThat(cardTraceId).isEqualTo(meterTraceId)
+                .as("traceId must be consistent between card and meter event");
+        assertThat(taskTraceId).startsWith("trace_")
+                .as("traceId must follow the generated format");
+    }
+
+    @Test
+    @DisplayName("6b. Robot navigation produces AGENT_CHAT_COMPLETED meter event")
+    void robotNavigationProducesMeterEvent() throws Exception {
+        stubRobotScene("ROBOT-001");
+        when(routerService.route(any()))
+                .thenReturn(RouteDecision.direct("ROUTE_NAVIGATION", "opd-guide-agent",
+                        "GUIDE", "OUTPATIENT_GUIDE", "CREATE_NEW_TASK"));
+        when(replyTemplates.reply("ROUTE_NAVIGATION")).thenReturn("好的。");
+        when(taskStateService.createTask(any(), any(), eq("GUIDE"), any(), any(), any(), any(), any(), any()))
+                .thenReturn(taskStub("task_g", "GUIDE", "UNDERSTAND_DESTINATION"));
+        when(cardInstanceService.createCard(any(), any(), eq("route-card"), any(), any(), any()))
+                .thenReturn(cardStub("card_r", "route-card"));
+
+        chatService.chatStream(robotCmd("ROBOT-001", "带我去检验科"), new SseEmitter(5000L));
+
+        // Every successful chat produces AGENT_CHAT_COMPLETED meter event
+        verify(meterProducer).produce(eq("AGENT_CHAT_COMPLETED"), any(), any(), any());
+    }
+
+    // ═══════════════════════════════════════════════════════════════
+    //  Helpers
+    // ═══════════════════════════════════════════════════════════════
+
+    private void stubKioskScene(String deviceId) {
+        when(deviceRegistryFacade.scene(eq("1"), eq(deviceId)))
+                .thenReturn(new SceneProfileResult(deviceId, "self_service_kiosk",
+                        "outpatient_kiosk", "kiosk_home_v1", "opd-guide-agent",
+                        List.of("opd-guide-agent", "opd-registration-agent", "tongue-diagnosis-agent"),
+                        List.of(), List.of("touch", "camera"),
+                        List.of("department-selection", "doctor-selection")));
+        when(conversationService.createOrLoad(anyInt(), any(), any(), any(), any(), any()))
+                .thenReturn(convStub());
+        when(env.matchesProfiles("dev", "local")).thenReturn(true);
+    }
+
+    private void stubGuideScreenScene(String deviceId) {
+        when(deviceRegistryFacade.scene(eq("1"), eq(deviceId)))
+                .thenReturn(new SceneProfileResult(deviceId, "guide_screen",
+                        "outpatient_guide", "guide_home_v1", "opd-guide-agent",
+                        List.of("opd-guide-agent"),
+                        List.of("TONGUE_DIAGNOSIS", "REGISTRATION"),
+                        List.of("touch"), List.of()));
+        when(conversationService.createOrLoad(anyInt(), any(), any(), any(), any(), any()))
+                .thenReturn(convStub());
+        when(env.matchesProfiles("dev", "local")).thenReturn(true);
+    }
+
+    private void stubRobotScene(String deviceId) {
+        when(deviceRegistryFacade.scene(eq("1"), eq(deviceId)))
+                .thenReturn(new SceneProfileResult(deviceId, "robot",
+                        "outpatient_robot", "robot_home_v1", "opd-guide-agent",
+                        List.of("opd-guide-agent"), List.of(),
+                        List.of("voice", "navigation"), List.of("route-card")));
+        when(conversationService.createOrLoad(anyInt(), any(), any(), any(), any(), any()))
+                .thenReturn(convStub());
+        when(env.matchesProfiles("dev", "local")).thenReturn(true);
+    }
+
+    private AiConversation convStub() {
+        AiConversation conv = new AiConversation();
+        conv.setId(1L);
+        conv.setConversationId("conv_001");
+        conv.setProjectId(1);
+        conv.setStatus("active");
+        return conv;
+    }
+
+    private AgentChatCommand kioskCmd(String deviceId, String text) {
+        return new AgentChatCommand("1", null, null, "m001", null, null,
+                deviceId, "self_service_kiosk", text, null);
+    }
+
+    private AgentChatCommand guideScreenCmd(String deviceId, String text) {
+        return new AgentChatCommand("1", null, null, "m002", null, null,
+                deviceId, "guide_screen", text, null);
+    }
+
+    private AgentChatCommand robotCmd(String deviceId, String text) {
+        return new AgentChatCommand("1", null, null, "m003", null, null,
+                deviceId, "robot", text, null);
+    }
+
+    private AiTaskInstanceDO taskStub(String taskId, String taskType, String currentStep) {
+        AiTaskInstanceDO task = new AiTaskInstanceDO();
+        task.setTaskId(taskId);
+        task.setTaskType(taskType);
+        task.setCurrentStep(currentStep);
+        task.setStatus("ACTIVE");
+        task.setConversationId("conv_001");
+        return task;
+    }
+
+    private AiCardInstance cardStub(String instanceId, String cardKey) {
+        AiCardInstance inst = new AiCardInstance();
+        inst.setInstanceId(instanceId);
+        inst.setCardKey(cardKey);
+        inst.setStatus("active");
+        return inst;
+    }
+}

+ 8 - 0
emoon-openplatform/src/test/java/com/emoon/openplatform/service/impl/AgentChatApplicationServiceImplTest.java

@@ -7,8 +7,12 @@ import com.emoon.ai.agent.application.TaskStateService;
 import com.emoon.ai.agent.application.TerminalReplyTemplateService;
 import com.emoon.ai.agent.domain.AiTaskInstanceDO;
 import com.emoon.ai.agent.domain.RouteDecision;
+import com.emoon.ai.card.application.CardInstanceService;
 import com.emoon.ai.device.api.DeviceRegistryFacade;
 import com.emoon.ai.device.api.SceneProfileResult;
+import com.emoon.ai.device.application.DeviceCommandService;
+import com.emoon.ai.mcp.application.McpToolService;
+import com.emoon.ai.meter.application.MeterEventProducer;
 import com.emoon.mcp.domain.AiConversation;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.ExtendWith;
@@ -31,6 +35,10 @@ class AgentChatApplicationServiceImplTest {
     @Mock private TerminalReplyTemplateService replyTemplates;
     @Mock private TaskStateService taskStateService;
     @Mock private DeviceRegistryFacade deviceRegistryFacade;
+    @Mock private CardInstanceService cardInstanceService;
+    @Mock private DeviceCommandService deviceCommandService;
+    @Mock private McpToolService mcpToolService;
+    @Mock private MeterEventProducer meterProducer;
     @Mock private Environment env;
 
     @InjectMocks