修复MCP任务结果提交契约校验
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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 """
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user