实现MCP任务结果提交稳定性映射
This commit is contained in:
@@ -130,8 +130,11 @@ class SuperAgentMcpControllerTest {
|
||||
.andExpect(jsonPath("$.result.tools[3].inputSchema.required.length()").value(0))
|
||||
.andExpect(jsonPath("$.result.tools[3].annotations.readOnlyHint").value(true))
|
||||
.andExpect(jsonPath("$.result.tools[4].name").value("th_hotel_submit_task_results"))
|
||||
.andExpect(jsonPath("$.result.tools[4].inputSchema.required[0]").value("source_message_id"))
|
||||
.andExpect(jsonPath("$.result.tools[4].inputSchema.required[1]").value("ai_task_results"))
|
||||
.andExpect(jsonPath("$.result.tools[4].inputSchema.properties.source_message").exists())
|
||||
.andExpect(jsonPath("$.result.tools[4].inputSchema.properties.message_events").exists())
|
||||
.andExpect(jsonPath("$.result.tools[4].inputSchema.properties.ai_task_results").exists())
|
||||
.andExpect(jsonPath("$.result.tools[4].inputSchema.properties.message_events.items.properties.source_event_index.description")
|
||||
.value(containsString("MCP Adapter")))
|
||||
.andExpect(jsonPath("$.result.tools[4].inputSchema.properties.source_provider.description")
|
||||
.value(containsString("兼容字段")))
|
||||
.andExpect(jsonPath("$.result.tools[4].inputSchema.properties.source_channel.description")
|
||||
|
||||
@@ -2,19 +2,31 @@ package cn.nianxx.thhotel.integrations.mcp.superagent.control;
|
||||
|
||||
import static org.hamcrest.Matchers.containsString;
|
||||
import static org.hamcrest.Matchers.not;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import cn.nianxx.thhotel.ThHotelApplication;
|
||||
import cn.nianxx.thhotel.platform.message.common.request.CaptureSourceMessageCommand;
|
||||
import cn.nianxx.thhotel.platform.message.common.result.SourceMessageCaptureResult;
|
||||
import cn.nianxx.thhotel.platform.message.service.SourceMessageCaptureService;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ArrayNode;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.MvcResult;
|
||||
|
||||
@SpringBootTest(
|
||||
classes = ThHotelApplication.class,
|
||||
@@ -34,6 +46,15 @@ class SuperAgentMcpSubmitEnabledControllerTest {
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@Autowired
|
||||
private SourceMessageCaptureService captureService;
|
||||
|
||||
@Autowired
|
||||
private JdbcTemplate jdbcTemplate;
|
||||
|
||||
@Autowired
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
@Test
|
||||
void shouldDelegateSubmitTaskResultsToolWhenWriteToolEnabled() throws Exception {
|
||||
String body = """
|
||||
@@ -70,4 +91,275 @@ class SuperAgentMcpSubmitEnabledControllerTest {
|
||||
.andExpect(jsonPath("$.result.structuredContent.error.code").value("SOURCE_MESSAGE_NOT_FOUND"))
|
||||
.andExpect(content().string(not(containsString("MCP_TOOL_DISABLED"))));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectUnknownV3RootFieldBeforeDelegatingSubmit() throws Exception {
|
||||
String externalId = "mail-mcp-v3-unknown-root-001";
|
||||
SourceMessageCaptureResult source = captureSourceMessage(externalId);
|
||||
String businessRoot = parentSplitBusinessRoot(externalId)
|
||||
.replace("\"unhandled_current_intents\": []", "\"unhandled_current_intents\": [], \"unexpected_root\": true");
|
||||
|
||||
mockMvc.perform(post(ENDPOINT)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.header("Authorization", AUTHORIZATION)
|
||||
.content(toolCall("mcp-submit-v3-unknown-root-001", businessRoot)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.result.isError").value(true))
|
||||
.andExpect(jsonPath("$.result.structuredContent.error.code").value("MCP_SUBMIT_PAYLOAD_INVALID"))
|
||||
.andExpect(jsonPath("$.result.structuredContent.error.details.field").value("unexpected_root"));
|
||||
|
||||
assertNoReservationWorkflowRows(source.inboxId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldKeepInfrastructureErrorWhenV3SourceMessageIdMissing() throws Exception {
|
||||
String businessRoot = parentSplitBusinessRootWithoutSourceMessageId("mail-mcp-v3-missing-source-id-001");
|
||||
|
||||
mockMvc.perform(post(ENDPOINT)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.header("Authorization", AUTHORIZATION)
|
||||
.content(toolCall("mcp-submit-v3-missing-source-id-001", businessRoot)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.result.isError").value(true))
|
||||
.andExpect(jsonPath("$.result.structuredContent.error.code").value("MISSING_SOURCE_MESSAGE_ID"))
|
||||
.andExpect(jsonPath("$.result.structuredContent.error.details.http_status").value(400));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectDanglingRelatedEventIndexBeforeDelegatingSubmit() throws Exception {
|
||||
String externalId = "mail-mcp-v3-dangling-related-001";
|
||||
SourceMessageCaptureResult source = captureSourceMessage(externalId);
|
||||
String businessRoot = parentSplitBusinessRootWithSecondParentRelation(externalId, "E_UNKNOWN_CHILD");
|
||||
|
||||
mockMvc.perform(post(ENDPOINT)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.header("Authorization", AUTHORIZATION)
|
||||
.content(toolCall("mcp-submit-v3-dangling-related-001", businessRoot)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.result.isError").value(true))
|
||||
.andExpect(jsonPath("$.result.structuredContent.error.code").value("MCP_SUBMIT_PAYLOAD_INVALID"))
|
||||
.andExpect(jsonPath("$.result.structuredContent.error.details.field").value("related_source_event_indices"));
|
||||
|
||||
assertNoReservationWorkflowRows(source.inboxId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectDuplicateRelatedEventIndexBeforeDelegatingSubmit() throws Exception {
|
||||
String externalId = "mail-mcp-v3-duplicate-related-001";
|
||||
SourceMessageCaptureResult source = captureSourceMessage(externalId);
|
||||
String businessRoot = parentSplitBusinessRootWithSecondParentRelation(externalId, "E_CHILD_1");
|
||||
|
||||
mockMvc.perform(post(ENDPOINT)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.header("Authorization", AUTHORIZATION)
|
||||
.content(toolCall("mcp-submit-v3-duplicate-related-001", businessRoot)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.result.isError").value(true))
|
||||
.andExpect(jsonPath("$.result.structuredContent.error.code").value("MCP_SUBMIT_PAYLOAD_INVALID"))
|
||||
.andExpect(jsonPath("$.result.structuredContent.error.details.field").value("related_source_event_indices"));
|
||||
|
||||
assertNoReservationWorkflowRows(source.inboxId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldMapBusinessEventIdsToMcpIndicesAndSubmitOnce() throws Exception {
|
||||
String externalId = "mail-mcp-v3-event-map-001";
|
||||
SourceMessageCaptureResult source = captureSourceMessage(externalId);
|
||||
|
||||
MvcResult result = mockMvc.perform(post(ENDPOINT)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.header("Authorization", AUTHORIZATION)
|
||||
.content(toolCall("mcp-submit-v3-event-map-001", parentSplitBusinessRoot(externalId))))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.result.isError").value(false))
|
||||
.andExpect(jsonPath("$.result.structuredContent.accepted_count").value(3))
|
||||
.andExpect(jsonPath("$.result.structuredContent.items[0].source_event_index").value(1))
|
||||
.andExpect(jsonPath("$.result.structuredContent.items[1].source_event_index").value(2))
|
||||
.andExpect(jsonPath("$.result.structuredContent.items[2].source_event_index").value(3))
|
||||
.andReturn();
|
||||
|
||||
Long batchCount = jdbcTemplate.queryForObject("""
|
||||
SELECT COUNT(*)
|
||||
FROM workflow_reservation_ai_batch
|
||||
WHERE source_message_id = ?
|
||||
""", Long.class, source.inboxId());
|
||||
assertThat(batchCount).isEqualTo(1L);
|
||||
|
||||
String parentPayloadJson = jdbcTemplate.queryForObject("""
|
||||
SELECT ai_payload_json
|
||||
FROM workflow_reservation_ai_transition
|
||||
WHERE source_message_id = ?
|
||||
AND source_event_index = 3
|
||||
""", String.class, source.inboxId());
|
||||
JsonNode parentPayload = objectMapper.readTree(parentPayloadJson);
|
||||
assertThat(parentPayload.path("v3_message_event").path("source_event_index").asInt()).isEqualTo(3);
|
||||
assertThat(parentPayload.path("v3_message_event").path("related_source_event_indices").get(0).asText())
|
||||
.isEqualTo("1");
|
||||
assertThat(parentPayload.path("v3_message_event").path("related_source_event_indices").get(1).asText())
|
||||
.isEqualTo("2");
|
||||
assertThat(result.getResponse().getContentAsString()).doesNotContain("E_CHILD_1");
|
||||
}
|
||||
|
||||
private SourceMessageCaptureResult captureSourceMessage(String externalMessageId) {
|
||||
return captureService.capture(new CaptureSourceMessageCommand(
|
||||
"HOTEL-TEST",
|
||||
"AGENTBUS",
|
||||
"EMAIL",
|
||||
externalMessageId,
|
||||
"thread-" + externalMessageId,
|
||||
"frame-" + externalMessageId,
|
||||
"session-mcp-submit",
|
||||
Instant.parse("2026-07-12T04:00:00Z"),
|
||||
"agent@example.test",
|
||||
"MCP submit fixture",
|
||||
"MCP submit fixture source message.",
|
||||
"<html><body>MCP submit fixture source message.</body></html>",
|
||||
"{\"source\":{\"external_message_id\":\"" + externalMessageId + "\"}}",
|
||||
"agentbus-outlook-v1",
|
||||
List.of()
|
||||
));
|
||||
}
|
||||
|
||||
private void assertNoReservationWorkflowRows(Long sourceMessageId) {
|
||||
Long batchCount = jdbcTemplate.queryForObject("""
|
||||
SELECT COUNT(*)
|
||||
FROM workflow_reservation_ai_batch
|
||||
WHERE source_message_id = ?
|
||||
""", Long.class, sourceMessageId);
|
||||
Long taskCount = jdbcTemplate.queryForObject("""
|
||||
SELECT COUNT(*)
|
||||
FROM workflow_reservation_task
|
||||
WHERE source_message_id = ?
|
||||
""", Long.class, sourceMessageId);
|
||||
assertThat(batchCount).isZero();
|
||||
assertThat(taskCount).isZero();
|
||||
}
|
||||
|
||||
private String toolCall(String id, String argumentsJson) {
|
||||
return """
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": "%s",
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "th_hotel_submit_task_results",
|
||||
"arguments": %s
|
||||
}
|
||||
}
|
||||
""".formatted(id, argumentsJson);
|
||||
}
|
||||
|
||||
private String parentSplitBusinessRootWithSecondParentRelation(String externalMessageId, String relatedEventIndex)
|
||||
throws Exception {
|
||||
ObjectNode root = (ObjectNode) objectMapper.readTree(parentSplitBusinessRoot(externalMessageId));
|
||||
ObjectNode parentEvent = (ObjectNode) root.path("message_events").get(2);
|
||||
ArrayNode relatedIndices = (ArrayNode) parentEvent.path("related_source_event_indices");
|
||||
relatedIndices.set(1, objectMapper.getNodeFactory().textNode(relatedEventIndex));
|
||||
return objectMapper.writeValueAsString(root);
|
||||
}
|
||||
|
||||
private String parentSplitBusinessRootWithoutSourceMessageId(String externalMessageId) throws Exception {
|
||||
ObjectNode root = (ObjectNode) objectMapper.readTree(parentSplitBusinessRoot(externalMessageId));
|
||||
((ObjectNode) root.path("source_message")).remove("source_message_id");
|
||||
return objectMapper.writeValueAsString(root);
|
||||
}
|
||||
|
||||
private String parentSplitBusinessRoot(String externalMessageId) {
|
||||
return """
|
||||
{
|
||||
"source_message": {
|
||||
"source_message_id": "%s",
|
||||
"subject": "Parent split booking request",
|
||||
"from": "agent@example.test",
|
||||
"cc": [],
|
||||
"received_at": "2026-07-12T04:00:00Z",
|
||||
"source_channel": "Email"
|
||||
},
|
||||
"message_events": [
|
||||
{
|
||||
"event_type": "New Booking",
|
||||
"event_role": "travel_agent_request",
|
||||
"source_event_index": "E_CHILD_1",
|
||||
"current_or_history": "current",
|
||||
"case_keys": {
|
||||
"group_code": "MCP-CHILD-A",
|
||||
"confirmation_number": null,
|
||||
"reservation_number": null,
|
||||
"block_code": null
|
||||
},
|
||||
"relevant_message_excerpt": "Please create child group A.",
|
||||
"attachments": [],
|
||||
"file_references": [],
|
||||
"context_used": {},
|
||||
"extracted_fields": {
|
||||
"booking_object_type": "Group Block",
|
||||
"arrival_date": "2026-09-01",
|
||||
"departure_date": "2026-09-03"
|
||||
},
|
||||
"manual_review": null
|
||||
},
|
||||
{
|
||||
"event_type": "New Booking",
|
||||
"event_role": "travel_agent_request",
|
||||
"source_event_index": "E_CHILD_2",
|
||||
"current_or_history": "current",
|
||||
"case_keys": {
|
||||
"group_code": "MCP-CHILD-B",
|
||||
"confirmation_number": null,
|
||||
"reservation_number": null,
|
||||
"block_code": null
|
||||
},
|
||||
"relevant_message_excerpt": "Please create child group B.",
|
||||
"attachments": [],
|
||||
"file_references": [],
|
||||
"context_used": {},
|
||||
"extracted_fields": {
|
||||
"booking_object_type": "Group Block",
|
||||
"arrival_date": "2026-09-01",
|
||||
"departure_date": "2026-09-03"
|
||||
},
|
||||
"manual_review": null
|
||||
},
|
||||
{
|
||||
"event_type": "Cancel Allotment",
|
||||
"event_role": "travel_agent_request",
|
||||
"source_event_index": "E_PARENT",
|
||||
"current_or_history": "current",
|
||||
"case_keys": {
|
||||
"group_code": "MCP-PARENT",
|
||||
"confirmation_number": null,
|
||||
"reservation_number": null,
|
||||
"block_code": "MCP-PARENT"
|
||||
},
|
||||
"relevant_message_excerpt": "Release parent group after splitting allocation to child groups.",
|
||||
"attachments": [],
|
||||
"file_references": [],
|
||||
"context_used": {},
|
||||
"related_event_type": "New Booking",
|
||||
"requires_downstream_hard_validation": true,
|
||||
"related_source_event_indices": [
|
||||
"E_CHILD_1",
|
||||
"E_CHILD_2"
|
||||
],
|
||||
"extracted_fields": {
|
||||
"relationship_type": "linked_parent_release_after_child_split",
|
||||
"parent_group_code": "MCP-PARENT",
|
||||
"cancel_scope": "entire_allotment_control_block",
|
||||
"parent_release_or_cancel_candidate": true,
|
||||
"release_reason": "parent_to_child_allocation_split",
|
||||
"allocation_split_from_parent": true,
|
||||
"child_group_codes": [
|
||||
"MCP-CHILD-A",
|
||||
"MCP-CHILD-B"
|
||||
]
|
||||
},
|
||||
"manual_review": null
|
||||
}
|
||||
],
|
||||
"case_candidates": [],
|
||||
"extraction_warnings": [],
|
||||
"unhandled_current_intents": []
|
||||
}
|
||||
""".formatted(externalMessageId);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user