实现MCP任务结果提交稳定性映射

This commit is contained in:
andy
2026-07-12 19:10:03 +08:00
parent 937aa569ff
commit 9d095cfd53
15 changed files with 1468 additions and 17 deletions

View File

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

View File

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