修复MCP任务结果提交契约校验

This commit is contained in:
andy
2026-07-12 19:39:14 +08:00
parent 807d045526
commit eda3e70873
14 changed files with 787 additions and 122 deletions

View File

@@ -133,8 +133,16 @@ class SuperAgentMcpControllerTest {
.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.oneOf.length()").value(3))
.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.message_events.items.properties.relationship_type").exists())
.andExpect(jsonPath("$.result.tools[4].inputSchema.properties.ai_task_results.items.required[0]")
.value("source_event_index"))
.andExpect(jsonPath("$.result.tools[4].inputSchema.properties.ai_task_results.items.required[4]")
.value("task_type"))
.andExpect(jsonPath("$.result.tools[4].inputSchema.properties.ai_task_results.items.properties.result_type.enum.length()")
.value(3))
.andExpect(jsonPath("$.result.tools[4].inputSchema.properties.source_provider.description")
.value(containsString("兼容字段")))
.andExpect(jsonPath("$.result.tools[4].inputSchema.properties.source_channel.description")

View File

@@ -1,8 +1,8 @@
package cn.nianxx.thhotel.integrations.mcp.superagent.control;
import static org.assertj.core.api.Assertions.assertThat;
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;
@@ -125,6 +125,58 @@ class SuperAgentMcpSubmitEnabledControllerTest {
.andExpect(jsonPath("$.result.structuredContent.error.details.http_status").value(400));
}
@Test
void shouldKeepInfrastructureErrorWhenV3SourceMessageMissing() throws Exception {
String businessRoot = parentSplitBusinessRootWithoutSourceMessage("mail-mcp-v3-missing-source-object-001");
mockMvc.perform(post(ENDPOINT)
.contentType(MediaType.APPLICATION_JSON)
.header("Authorization", AUTHORIZATION)
.content(toolCall("mcp-submit-v3-missing-source-object-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 shouldRejectIncompleteV2ItemBeforeDelegatingSubmit() throws Exception {
String externalId = "mail-mcp-v2-incomplete-item-001";
SourceMessageCaptureResult source = captureSourceMessage(externalId);
String body = """
{
"jsonrpc": "2.0",
"id": "mcp-submit-v2-incomplete-item-001",
"method": "tools/call",
"params": {
"name": "th_hotel_submit_task_results",
"arguments": {
"source_message_id": "%s",
"ai_task_results": [
{
"source_event_index": 1,
"catalog_code": "S01",
"skill_id": "S01_new_booking_skill",
"result_type": "normal_task"
}
]
}
}
}
""".formatted(externalId);
mockMvc.perform(post(ENDPOINT)
.contentType(MediaType.APPLICATION_JSON)
.header("Authorization", AUTHORIZATION)
.content(body))
.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("ai_task_results[].task_type"));
assertNoReservationWorkflowRows(source.inboxId());
}
@Test
void shouldRejectDanglingRelatedEventIndexBeforeDelegatingSubmit() throws Exception {
String externalId = "mail-mcp-v3-dangling-related-001";
@@ -176,6 +228,14 @@ class SuperAgentMcpSubmitEnabledControllerTest {
.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))
.andExpect(jsonPath("$.result.structuredContent.mapping_diagnostics.source_event_index_mapping[0].original_source_event_index")
.value("E_CHILD_1"))
.andExpect(jsonPath("$.result.structuredContent.mapping_diagnostics.source_event_index_mapping[0].mapped_source_event_index")
.value(1))
.andExpect(jsonPath("$.result.structuredContent.mapping_diagnostics.source_event_index_mapping[2].original_source_event_index")
.value("E_PARENT"))
.andExpect(jsonPath("$.result.structuredContent.mapping_diagnostics.source_event_index_mapping[2].mapped_source_event_index")
.value(3))
.andReturn();
Long batchCount = jdbcTemplate.queryForObject("""
@@ -197,7 +257,118 @@ class SuperAgentMcpSubmitEnabledControllerTest {
.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");
assertThat(parentPayload.toString()).doesNotContain("mapping_diagnostics");
assertThat(result.getResponse().getContentAsString()).contains("E_CHILD_1");
}
@Test
void shouldMapCrossChildTraceRelationsAndSubmitOnce() throws Exception {
String externalId = "mail-mcp-v3-trace-cross-child-001";
SourceMessageCaptureResult source = captureSourceMessage(externalId);
mockMvc.perform(post(ENDPOINT)
.contentType(MediaType.APPLICATION_JSON)
.header("Authorization", AUTHORIZATION)
.content(toolCall("mcp-submit-v3-trace-cross-child-001", traceCrossChildBusinessRoot(externalId))))
.andExpect(status().isOk())
.andExpect(jsonPath("$.result.isError").value(false))
.andExpect(jsonPath("$.result.structuredContent.accepted_count").value(3))
.andExpect(jsonPath("$.result.structuredContent.items[2].source_event_index").value(3))
.andExpect(jsonPath("$.result.structuredContent.mapping_diagnostics.source_event_index_mapping[2].original_source_event_index")
.value("E_TRACE"))
.andExpect(jsonPath("$.result.structuredContent.mapping_diagnostics.source_event_index_mapping[2].mapped_source_event_index")
.value(3));
String tracePayloadJson = 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 tracePayload = objectMapper.readTree(tracePayloadJson);
assertThat(tracePayload.path("v3_message_event").path("event_type").asText()).isEqualTo("Trace");
assertThat(tracePayload.path("v3_message_event").path("related_source_event_indices").get(0).asText())
.isEqualTo("1");
assertThat(tracePayload.path("v3_message_event").path("related_source_event_indices").get(1).asText())
.isEqualTo("2");
}
@Test
void shouldAllowRelationshipTypeOnV3EventAndLetBusinessLayerHandleIt() throws Exception {
String externalId = "mail-mcp-v3-relationship-root-001";
SourceMessageCaptureResult source = captureSourceMessage(externalId);
String businessRoot = parentSplitBusinessRootWithRootRelationshipType(externalId);
mockMvc.perform(post(ENDPOINT)
.contentType(MediaType.APPLICATION_JSON)
.header("Authorization", AUTHORIZATION)
.content(toolCall("mcp-submit-v3-relationship-root-001", businessRoot)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.result.isError").value(false))
.andExpect(jsonPath("$.result.structuredContent.accepted_count").value(3));
Long transitionCount = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM workflow_reservation_ai_transition
WHERE source_message_id = ?
""", Long.class, source.inboxId());
assertThat(transitionCount).isPositive();
}
@Test
void shouldLetBusinessLayerPersistInvalidV3EventAsContractError() throws Exception {
String externalId = "mail-mcp-v3-business-contract-error-001";
SourceMessageCaptureResult source = captureSourceMessage(externalId);
String businessRoot = businessRootWithInvalidEventShape(externalId);
mockMvc.perform(post(ENDPOINT)
.contentType(MediaType.APPLICATION_JSON)
.header("Authorization", AUTHORIZATION)
.content(toolCall("mcp-submit-v3-business-contract-error-001", businessRoot)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.result.isError").value(false))
.andExpect(jsonPath("$.result.structuredContent.items[0].system_process_category")
.value("ADAPTER_CONTRACT_ERROR"))
.andExpect(jsonPath("$.result.structuredContent.items[0].adapter_error_code")
.value("EVENT_CONTRACT_INCOMPLETE"));
Long transitionCount = jdbcTemplate.queryForObject("""
SELECT COUNT(*)
FROM workflow_reservation_ai_transition
WHERE source_message_id = ?
AND result_type = 'adapter_contract_error'
""", Long.class, source.inboxId());
assertThat(transitionCount).isEqualTo(1L);
}
@Test
void shouldSubmitS10ThroughMcpAdapter() throws Exception {
String externalId = "mail-mcp-s10-001";
captureSourceMessage(externalId);
mockMvc.perform(post(ENDPOINT)
.contentType(MediaType.APPLICATION_JSON)
.header("Authorization", AUTHORIZATION)
.content(toolCall("mcp-submit-s10-001", s10NotificationRoot(externalId))))
.andExpect(status().isOk())
.andExpect(jsonPath("$.result.isError").value(false))
.andExpect(jsonPath("$.result.structuredContent.accepted_count").value(1))
.andExpect(jsonPath("$.result.structuredContent.items[0].system_task_type").value("SOURCE_MESSAGE_ONLY"));
}
@Test
void shouldSubmitS99ThroughMcpAdapter() throws Exception {
String externalId = "mail-mcp-s99-001";
captureSourceMessage(externalId);
mockMvc.perform(post(ENDPOINT)
.contentType(MediaType.APPLICATION_JSON)
.header("Authorization", AUTHORIZATION)
.content(toolCall("mcp-submit-s99-001", s99NotificationRoot(externalId))))
.andExpect(status().isOk())
.andExpect(jsonPath("$.result.isError").value(false))
.andExpect(jsonPath("$.result.structuredContent.accepted_count").value(1))
.andExpect(jsonPath("$.result.structuredContent.items[0].system_task_type").value("SOURCE_MESSAGE_ONLY"));
}
private SourceMessageCaptureResult captureSourceMessage(String externalMessageId) {
@@ -264,6 +435,137 @@ class SuperAgentMcpSubmitEnabledControllerTest {
return objectMapper.writeValueAsString(root);
}
private String parentSplitBusinessRootWithoutSourceMessage(String externalMessageId) throws Exception {
ObjectNode root = (ObjectNode) objectMapper.readTree(parentSplitBusinessRoot(externalMessageId));
root.remove("source_message");
return objectMapper.writeValueAsString(root);
}
private String parentSplitBusinessRootWithRootRelationshipType(String externalMessageId) throws Exception {
ObjectNode root = (ObjectNode) objectMapper.readTree(parentSplitBusinessRoot(externalMessageId));
ObjectNode parentEvent = (ObjectNode) root.path("message_events").get(2);
parentEvent.put("relationship_type", "linked_parent_release_after_child_split");
return objectMapper.writeValueAsString(root);
}
private String businessRootWithInvalidEventShape(String externalMessageId) throws Exception {
ObjectNode root = (ObjectNode) objectMapper.readTree(parentSplitBusinessRoot(externalMessageId));
ArrayNode events = objectMapper.createArrayNode();
ObjectNode invalidEvent = ((ObjectNode) root.path("message_events").get(0)).deepCopy();
invalidEvent.put("event_type", "Unsupported Event Type");
events.add(invalidEvent);
root.set("message_events", events);
return objectMapper.writeValueAsString(root);
}
private String traceCrossChildBusinessRoot(String externalMessageId) throws Exception {
ObjectNode root = (ObjectNode) objectMapper.readTree(parentSplitBusinessRoot(externalMessageId));
ArrayNode events = objectMapper.createArrayNode();
events.add(root.path("message_events").get(0));
events.add(root.path("message_events").get(1));
ObjectNode trace = objectMapper.createObjectNode();
trace.put("event_type", "Trace");
trace.put("event_role", "travel_agent_request");
trace.put("source_event_index", "E_TRACE");
trace.put("current_or_history", "current");
ObjectNode caseKeys = objectMapper.createObjectNode();
caseKeys.putNull("group_code");
caseKeys.putNull("confirmation_number");
caseKeys.putNull("reservation_number");
caseKeys.putNull("block_code");
trace.set("case_keys", caseKeys);
trace.put("relevant_message_excerpt", "Please add a shared trace to both child groups.");
trace.set("attachments", objectMapper.createArrayNode());
trace.set("file_references", objectMapper.createArrayNode());
trace.set("context_used", objectMapper.createObjectNode());
trace.put("related_event_type", "New Booking");
ArrayNode related = objectMapper.createArrayNode();
related.add("E_CHILD_1");
related.add("E_CHILD_2");
trace.set("related_source_event_indices", related);
ObjectNode extractedFields = objectMapper.createObjectNode();
extractedFields.put("trace_subtype", "general_request");
trace.set("extracted_fields", extractedFields);
trace.set("manual_review", objectMapper.nullNode());
events.add(trace);
root.set("message_events", events);
return objectMapper.writeValueAsString(root);
}
private String s10NotificationRoot(String externalMessageId) {
return """
{
"source_message": {
"source_message_id": "%s",
"subject": "Informational message",
"from": "agent@example.test",
"cc": [],
"received_at": "2026-07-12T04:00:00Z",
"source_channel": "Email"
},
"route_code": "S10",
"handler_type": "main_agent_outcome",
"result_type": "source_message_review_notification",
"current_or_history": "current",
"agent_assessment": {
"status": "no_booking_action_detected",
"reason_code": "no_booking_action_detected",
"automation_action": "none"
},
"notification": {
"required": true,
"notification_type": "source_message_review",
"show_source_message": true,
"requires_user_decision": true,
"visible_message": "纯信息类邮件,请人工查看原邮件。"
},
"manual_review": null
}
""".formatted(externalMessageId);
}
private String s99NotificationRoot(String externalMessageId) {
return """
{
"source_message": {
"source_message_id": "%s",
"subject": "Material unavailable",
"from": "agent@example.test",
"cc": [],
"received_at": "2026-07-12T04:00:00Z",
"source_channel": "Email"
},
"route_code": "S99",
"handler_type": "main_agent_outcome",
"result_type": "source_message_review_notification",
"current_or_history": "current",
"agent_assessment": {
"status": "material_package_unavailable",
"reason_code": "material_package_unavailable",
"automation_action": "none"
},
"notification": {
"required": true,
"notification_type": "source_message_review",
"show_source_message": true,
"requires_user_decision": true,
"visible_message": "无法形成业务素材包,请人工查看原邮件。"
},
"manual_review": {
"reason_code": "material_package_unavailable",
"visible_reason": "邮件内容无法形成业务素材包。",
"review_record_type": "main_agent_entry_review",
"missing_fields": [],
"blocking_points": [],
"conflicting_points": [],
"suggested_human_actions": [],
"evidence_to_check": [],
"known_fields": {}
}
}
""".formatted(externalMessageId);
}
private String parentSplitBusinessRoot(String externalMessageId) {
return """
{