From 5df291046513334aa131ee8fbfd14b929f00fbec Mon Sep 17 00:00:00 2001 From: andy Date: Wed, 22 Jul 2026 23:43:54 +0700 Subject: [PATCH] =?UTF-8?q?=E6=94=B6=E5=8F=A3MCP=E6=8F=90=E4=BA=A4?= =?UTF-8?q?=E4=B8=BAV4=E5=A5=91=E7=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PROJECT_STATE.md | 4 +- docs/project/README.md | 2 +- .../backend-to-frontend-notes.md | 2 +- .../frontend-to-backend-api-requests.md | 2 +- .../integrations/superagent-api-contract.md | 18 +- .../integrations/superagent-mcp/README.md | 2 +- .../superagent-mcp/inbound-diagnostics.md | 15 +- .../superagent-mcp/integration-guide.md | 8 +- .../superagent-mcp/security-policy.md | 3 +- .../superagent-mcp/submit-payload-mapping.md | 449 ++++------ .../integrations/superagent-mcp/test-cases.md | 49 +- .../integrations/superagent-mcp/tools.md | 236 +++-- .../M002-order-task-workflow-v3.md | 10 +- .../M002-v4-agent-callback-field-contract.md | 2 + ...002-v4-order-task-card-domain-model-cp2.md | 2 + .../security-access-control-boundary.md | 4 +- .../SuperAgentMcpSubmitPayloadAdapter.java | 4 +- .../impl/SuperAgentMcpServiceImpl.java | 308 ++++--- ...SuperAgentMcpSubmitPayloadAdapterImpl.java | 538 +++--------- .../control/SuperAgentMcpControllerTest.java | 39 +- ...erAgentMcpSubmitEnabledControllerTest.java | 809 ++++++------------ 21 files changed, 897 insertions(+), 1609 deletions(-) diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index be25d5b..9e79001 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -36,13 +36,13 @@ - 后续每完成一个 Feature 或 Checkpoint,需要更新本文件,避免项目状态继续沉淀在聊天记录里。 - M010 Rooming List Excel 生成后端 CP1 和前端 V1 已实现:前端 `/reservation/rooming-lists/new` 上传来源名单并下载后端同步生成的 `.xlsx`,第一版不落库、不上传 OSS。CP2 已实现:来源 Excel `旅游日期` 派生 Arrival / Departure,Adults 由系统按分房结果计算,目标默认值区域只保留 Payment Type / Nationality,Payment Type 默认 `BTQR` 且当前允许 `BTQR` / `CA`,Nationality 只允许 `KR` / `CHN`。 - M011 Booking Excel 附件预处理 CP1/CP2/CP3 已实现:后端可排除人员名单类 Excel,按最近 6 个月候选窗口选择实际存在的最新 3 个业务月,抽取 Booking Update / 附加费表高亮行业务 JSON;Debug EML 和 AgentBus dispatch 在各自 include 开关与总开关同时启用时,会在调用 SuperAgent 前追加 `attachment_extractions[]`。测试机 AgentBus 增强已开启;生产链路仍默认关闭,生产开启需单独确认。 -- M002 V4 CP1 当前已完成入站解析和现有任务链路过渡适配;M002 V4 CP2 已完成订单任务与多卡领域模型设计;M002 V4 CP3 已完成 V4 订单任务、多卡和 S10/S99 来源通知表结构与 Repository 基线;M002 V4 CP4 已完成入站写入新模型;M002 V4 CP5 已完成前端查询接口并补齐订单详情 `v4_order_tasks[]` 时间线;M002 V4 CP6 已完成普通卡片确认和 S10/S99 来源通知 ack;M002 V4 CP7 已完成 `REVIEW_REQUIRED` 卡复核解阻和复核场景订单归属确认;M002 V4 CP8 已完成目录校验、V4 卡片 `fields[]` 字段白名单、确认写入白名单收口和嵌套业务字段目录校验;M002 V4 CP11 已完成数据库目录、初始化种子、启动补种子、Account / Room Type / Rate Code lookup API,并把 V4 入站、确认、复核目录校验切换到当前酒店数据库目录;M002 V4 CP12 已完成前端 lookup 接入第一版和 V4 订单任务时间线消费;M002 V4 CP13 目录管理后台 CP1 已完成前后端列表、新增、启用 / 停用闭环;M002 V4 CP14 已完成订单列表 V4 继续处理入口字段和前端入口消费,`GET /api/reservation/orders` 返回 V4 下一步订单任务、卡片、动作类型、动作状态、V4 open 数和统一展示计数 `open_work_item_count`,前端按 V4 优先跳转,并按 `open_work_item_count` 展示待处理数量;V4 业务审计查询已补齐订单任务审计和来源通知 ack 审计两个只读接口;订单详情已补齐并完成前端接入 V4 `order_overview`、`next_v4_action`、`related_source_messages[]` 和 `v4_order_tasks[].cards[]`;V4 普通业务入站已停止双写旧 `workflow_reservation_task`;Room Information 后端展示模型和前端业务化展示第一版已完成;Rooming List 确认触发 Group Booking Status 自动置 `DEF` 已完成,前端轻量事项确认卡也已完成;Payment 附件安全摘要后端和前端预览接入均已完成。OWNER RATE Room Type / Rate Code 目录口径已落文档;真实 PMS 同步和 SuperAgent 目录机器接口仍未完成。 +- M002 V4 CP1 当前已完成入站解析和现有任务链路过渡适配;M002 V4 CP2 已完成订单任务与多卡领域模型设计;M002 V4 CP3 已完成 V4 订单任务、多卡和 S10/S99 来源通知表结构与 Repository 基线;M002 V4 CP4 已完成入站写入新模型;M002 V4 CP5 已完成前端查询接口并补齐订单详情 `v4_order_tasks[]` 时间线;M002 V4 CP6 已完成普通卡片确认和 S10/S99 来源通知 ack;M002 V4 CP7 已完成 `REVIEW_REQUIRED` 卡复核解阻和复核场景订单归属确认;M002 V4 CP8 已完成目录校验、V4 卡片 `fields[]` 字段白名单、确认写入白名单收口和嵌套业务字段目录校验;M002 V4 CP11 已完成数据库目录、初始化种子、启动补种子、Account / Room Type / Rate Code lookup API,并把 V4 入站、确认、复核目录校验切换到当前酒店数据库目录;M002 V4 CP12 已完成前端 lookup 接入第一版和 V4 订单任务时间线消费;M002 V4 CP13 目录管理后台 CP1 已完成前后端列表、新增、启用 / 停用闭环;M002 V4 CP14 已完成订单列表 V4 继续处理入口字段和前端入口消费,`GET /api/reservation/orders` 返回 V4 下一步订单任务、卡片、动作类型、动作状态、V4 open 数和统一展示计数 `open_work_item_count`,前端按 V4 优先跳转,并按 `open_work_item_count` 展示待处理数量;V4 业务审计查询已补齐订单任务审计和来源通知 ack 审计两个只读接口;订单详情已补齐并完成前端接入 V4 `order_overview`、`next_v4_action`、`related_source_messages[]` 和 `v4_order_tasks[].cards[]`;V4 普通业务入站已停止双写旧 `workflow_reservation_task`;MCP `th_hotel_submit_task_results` 已收口为 M002 V4-only,旧 V2/V3 submit payload 返回 `MCP_SUBMIT_V4_REQUIRED`,不再影响 SuperAgent 输出契约;Room Information 后端展示模型和前端业务化展示第一版已完成;Rooming List 确认触发 Group Booking Status 自动置 `DEF` 已完成,前端轻量事项确认卡也已完成;Payment 附件安全摘要后端和前端预览接入均已完成。OWNER RATE Room Type / Rate Code 目录口径已落文档;真实 PMS 同步和 SuperAgent 目录机器接口仍未完成。 - M002 V4 CP2 已确认:V4 工作台统一列表草案为 `/api/reservation/workbench-items`,业务订单任务接口新开 `/api/reservation/order-tasks/**`,S10/S99 来源通知详情草案为 `/api/reservation/source-notifications/{notificationId}`;S10/S99 使用来源通知模型,不再挂隐藏技术订单;`FIT + BOOKING_CODE` 不建 ACTIVE 唯一约束,匹配多条进人工复核;Basic Information 必须先确认;Rooming List 卡第一版只做事项确认;Account 通过数据库目录选择,Market / Source 可默认来自目录并允许前端人工覆盖提交;旧 V2/V3 任务详情和草稿确认接口后续可逐步废弃。 ## 5. Next Steps - 后续如继续做 M002 V4,可优先进行测试机联调,或推进真实 PMS / OPERA / OHIP 目录同步、`workflow_reservation_catalog_sync_run` checkpoint 和 SuperAgent 目录供给方案。 -- SuperAgent 通过 MCP 提交时,排障优先查询 `platform_superagent_mcp_call_diagnostic`,对比 `arguments_json`、`adapted_payload_json`、`mapping_diagnostics_json` 和业务 batch / transition,判断问题来自 SuperAgent 原始参数、MCP adapter 还是业务入站层;该诊断表不作为业务事实来源,不进入普通前端接口。 +- SuperAgent 通过 MCP 提交时,排障优先查询 `platform_superagent_mcp_call_diagnostic`,对比 `arguments_json`、`adapted_payload_json`、`mapping_diagnostics_json` 和业务 batch / transition,判断问题来自 SuperAgent 原始参数、MCP adapter 还是业务入站层;V4-only 模式下 `mapping_diagnostics_json` 通常为空对象,若错误码为 `MCP_SUBMIT_V4_REQUIRED`,说明 SuperAgent 仍按旧 V2/V3 schema 输出;旧 V2/V3 被拒也会入本诊断表但不会进入业务写入 Service;V4 `source_message.conversation_id` 可缺省;该诊断表不作为业务事实来源,不进入普通前端接口。 - 后续新增重要功能时,优先在 `docs/project/requirements/` 或未来 `docs/specs/` 中形成 Spec,再实现代码。 - M010 CP2 字段收口已完成;预览、历史记录、OSS 下载、订单 / 任务预填或客户字段目录化仍后置,需单独开前后端 checkpoint。 - M011 CP4 暂不推进;当前停留在 CP3 边界,只增强 SuperAgent 输入,不直接落订单、任务或长期解析历史。后续如确实需要运营查询或长期追踪,再单独设计 Excel 解析批次 / 行级持久化表。 diff --git a/docs/project/README.md b/docs/project/README.md index 9cd0390..da9ecd3 100644 --- a/docs/project/README.md +++ b/docs/project/README.md @@ -68,7 +68,7 @@ | 文档 | 状态 | 中文说明 | | --- | --- | --- | | `integrations/superagent-api-contract.md` | 权威契约 | 给 SuperAgent 对接方使用的 HTTP 接口总契约,记录查询上下文、对象详情、邮件会话任务、邮件会话正文、任务结果通知和统一 HMAC 规则。 | -| `integrations/superagent-mcp/README.md` | 当前有效 | SuperAgent MCP 资料包入口;MCP tools 是 HTTP 总契约的 MCP 映射说明,不单独替代总契约。 | +| `integrations/superagent-mcp/README.md` | 当前有效 | SuperAgent MCP 资料包入口;MCP tools 是 HTTP 总契约的 MCP 映射说明,不单独替代总契约;`th_hotel_submit_task_results` 当前为 M002 V4-only。 | | `integrations/superagent-mcp/inbound-diagnostics.md` | 当前有效 | SuperAgent MCP 入站诊断链路说明,记录受控保存原始工具参数、adapter 后 payload 和安全错误摘要的排障口径。 | | `integrations/superagent-agentbus-project-integration-guide.md` | 当前有效 | 当前项目 SuperAgent 与 AgentBus 验证记录和项目级接入细节。 | | `../../mcp-server/README.md` | 当前有效 | SuperAgent MCP 方案入口指针;当前不单独部署 MCP 服务,运行时代码内嵌在 `server/`。 | diff --git a/docs/project/frontend-backend/backend-to-frontend-notes.md b/docs/project/frontend-backend/backend-to-frontend-notes.md index ee204b2..7f061ed 100644 --- a/docs/project/frontend-backend/backend-to-frontend-notes.md +++ b/docs/project/frontend-backend/backend-to-frontend-notes.md @@ -27,7 +27,7 @@ - `docs/import/20260711/开发交付_P0冻结基线_2026-07-11/01_Adapter_Frontend/任务卡前端字段变更说明_3.0_to_当前版_2026-07-10.xlsx` 是当前前端展示 / 编辑白名单和三元组路由表。 - `docs/import/20260708/任务卡前端展示字段表 3.0.xlsx` 是历史前端展示 / 编辑白名单,已被 0711 P0 冻结基线承接。 - `docs/import/20260706/任务卡展示编辑矩阵.xlsx` 是后端校验、最终确认写入、OPERA 映射和展示条件的完整规则来源。 -- 前端不要直接把整个旧 `ai_task_results[]` 或 V3 `message_events[]` 渲染成表单,只展示白名单允许的字段。 +- 前端不要直接把整个旧 `ai_task_results[]` 或历史 V3 `message_events[]` 渲染成表单,只展示白名单允许的字段。当前 MCP `th_hotel_submit_task_results` 已收口为 M002 V4-only;若联调看到 `MCP_SUBMIT_V4_REQUIRED`,说明 SuperAgent 仍按旧 V2/V3 payload 提交,不是前端任务详情渲染问题。 - 如果 3.0 白名单与旧矩阵冲突,应记录为前后端待确认问题,不由前端单方面放宽必填、枚举或校验规则。 ## 4. 业务规则注意事项 diff --git a/docs/project/frontend-backend/frontend-to-backend-api-requests.md b/docs/project/frontend-backend/frontend-to-backend-api-requests.md index f288904..44eb551 100644 --- a/docs/project/frontend-backend/frontend-to-backend-api-requests.md +++ b/docs/project/frontend-backend/frontend-to-backend-api-requests.md @@ -48,7 +48,7 @@ | `GET /api/reservation/orders` | 已完成第一版 | 可以 | 默认查询全部订单状态;订单列表待处理展示使用 `open_work_item_count`;V4 普通业务已停止双写旧任务,旧 `open_task_count` 仅作为历史诊断计数。 | | `GET /api/source-messages/{sourceMessageId}/conversation` | 已完成第一版,已补 `html_body_sanitized` 和 `html_render_mode` | 可以 | 必须带 Bearer token,需要同时拥有 `SOURCE_MESSAGE_READ` 和 `SOURCE_MESSAGE_ORIGINAL_READ`;返回完整 text/html、后端清洗后的 HTML、媒体外链和关联订单 / 任务摘要;前端不传原文读取 key,页面展示优先使用 `html_body_sanitized`。V4 Payment 卡图片大图预览和非图片下载也复用该权限链路,只能使用当前触发 SourceMessage 且被 `attachment_ids[]` 引用的附件。 | | `POST /api/system/reservation/demo-data` | 已完成 | 仅本地 / test 联调可用 | 默认关闭,必须后端配置访问口令;不能作为生产页面接口。 | -| `POST /api/system/debug/eml-superagent-runs` | 已完成第一版 | 仅 dev/test Debug 页面可用 | 默认关闭,必须后端配置访问口令、阿里云 OSS 和 SuperAgent Open API;Debug 服务自身只展示 SuperAgent 结果,不直接创建订单和任务;如 SuperAgent 通过正式回调 / MCP 写入业务结果,V4 smoke 必须创建 V4 order task / cards,不再创建旧 `workflow_reservation_task`;已能识别旧 S000/S999 和新结构化 S10/S99。 | +| `POST /api/system/debug/eml-superagent-runs` | 已完成第一版 | 仅 dev/test Debug 页面可用 | 默认关闭,必须后端配置访问口令、阿里云 OSS 和 SuperAgent Open API;Debug 服务自身只展示 SuperAgent 结果,不直接创建订单和任务;如 SuperAgent 通过正式回调 / MCP 写入业务结果,V4 smoke 必须创建 V4 order task / cards,不再创建旧 `workflow_reservation_task`;MCP submit 已收口为 M002 V4-only,旧 V2/V3 payload 会返回 `MCP_SUBMIT_V4_REQUIRED`;已能识别旧 S000/S999 和新结构化 S10/S99。 | | `GET /api/source-message-conversations/{externalConversationId}` | 未发现后端实现 | 不可以 | 历史讨论过的候选路径,当前不提供;前端统一使用 `GET /api/source-messages/{sourceMessageId}/conversation`。 | | `GET /api/reservation/message-notifications` | 未发现后端实现 | 不可以 | 历史候选路径,当前不提供。旧 S000/S999 和 V3 S10/S99 兼容数据通过 `SOURCE_MESSAGE_ONLY` 任务展示;V4 S10/S99 新数据走 V4 工作台和 `/api/reservation/source-notifications/{notificationId}`,不要再请求本候选路径。 | | `GET /api/reservation/task-card-field-whitelist` | 未发现后端实现 | 不可以 | 若任务详情 `fields[]` 已补齐 3.0 元数据,可后置。 | diff --git a/docs/project/integrations/superagent-api-contract.md b/docs/project/integrations/superagent-api-contract.md index c3cee5d..f52c04a 100644 --- a/docs/project/integrations/superagent-api-contract.md +++ b/docs/project/integrations/superagent-api-contract.md @@ -4,9 +4,9 @@ | 项目 | 内容 | | --- | --- | -| 文档版本 | 0.10 | -| 日期 | 2026-07-20 | -| 状态 | 当前代码契约已支持 V4 订单任务 + 多卡入站、V4 S10/S99 来源通知、V2 `ai_task_results[]` 兼容、V3 业务根兼容、旧 S000/S999 兼容、M011 Booking Excel 调 SuperAgent 前预处理增强和单酒店 hotel_id 后端解析 | +| 文档版本 | 0.11 | +| 日期 | 2026-07-22 | +| 状态 | 当前代码契约已支持 V4 订单任务 + 多卡入站、V4 S10/S99 来源通知、REST 历史 V2 `ai_task_results[]` / V3 业务根兼容、旧 S000/S999 兼容、M011 Booking Excel 调 SuperAgent 前预处理增强和单酒店 hotel_id 后端解析;MCP `th_hotel_submit_task_results` 已收口为 M002 V4-only,不再接受 V2/V3 submit payload | | 适用范围 | SuperAgent 调用本系统查询上下文、查询邮件会话、提交 AI 任务结果 | | 主要读者 | SuperAgent 对接方、后端、测试、运维 | @@ -714,9 +714,9 @@ V3 字段说明: 当前已支持的 V3 行为: -- 如果通过 MCP `th_hotel_submit_task_results` 调用,MCP adapter 会在进入业务入站服务前按 `message_events[]` 顺序把 Agent 内部事件 ID 映射为本系统一基 `source_event_index`,并校验 `related_source_event_index`、`parent_source_event_index` 和 `related_source_event_indices[]` 是否悬空或重复;成功响应会额外返回 `mapping_diagnostics`,该诊断不写入业务 JSON。 -- MCP adapter 只拦 transport 层问题,例如未知字段、缺失或重复 `source_event_index`、关系引用悬空或重复、V2 item schema 不完整;V3 event 的业务契约问题,例如 unsupported `event_type`、不完整 `case_keys`、不完整 `manual_review`,继续由业务入站层保存为 `adapter_contract_error` transition。 -- MCP 路径缺失 `source_message.source_message_id` 或整个 `source_message` 时,保留业务入站层 `MISSING_SOURCE_MESSAGE_ID` 错误语义。 +- 仅 REST 历史兼容路径仍可接收 V3 业务根;MCP `th_hotel_submit_task_results` 已收口为 V4-only,旧 V3 payload 会返回 `MCP_SUBMIT_V4_REQUIRED`。 +- REST 历史兼容路径中,V3 event 的业务契约问题,例如 unsupported `event_type`、不完整 `case_keys`、不完整 `manual_review`,继续由业务入站层保存为 `adapter_contract_error` transition。 +- REST 历史兼容路径缺失 `source_message.source_message_id` 或整个 `source_message` 时,保留业务入站层 `MISSING_SOURCE_MESSAGE_ID` 错误语义。 - 40 条 P0.1 路由进入后端枚举 / 稳定配置。 - `route_code` 是稳定代码,不因路由总数从 42 调整为 40 而重编号;联调方不要按数字连续性判断合法性。 - V3 结构化 `S10/S99` 兼容路径创建只读 `SOURCE_MESSAGE_ONLY` 任务,任务列表可见,订单列表不可见;V4 新入站不走该模型,改用来源通知。 @@ -788,13 +788,13 @@ V3 字段说明: | `ai_task_results[].source_event_index` | 是 | AI current 事件序号 | | `ai_task_results[].catalog_code` | 是 | Skill 目录代码 | | `ai_task_results[].skill_id` | 是 | Skill 标识 | -| `ai_task_results[].result_type` | 是 | 当前代码契约接受 `normal_task`、`manual_review`;`informational_message` 仅历史兼容;MCP 路径会在 adapter 层校验 | +| `ai_task_results[].result_type` | 是 | 当前 REST 历史兼容契约接受 `normal_task`、`manual_review`;`informational_message` 仅历史兼容;MCP submit 已不接受 V2 `ai_task_results[]` | | `ai_task_results[].task_type` | 是 | AI 原始任务类型 | | `ai_task_results[].task_subtype` | 否 | 业务动作 subtype | | `ai_task_results[].case_keys` | 否 | 订单关联候选键 | | `ai_task_results[].extracted_fields` | 否 | 业务字段主体 | -正式联调时,SuperAgent 不需要传 `hotel_id`。后端通过系统酒店和外部 `source_message_id` 查找唯一 `platform_source_message_inbox.external_message_id`,真实 provider/channel 以 Inbox 入库值为准。如果没有找到,返回 `SOURCE_MESSAGE_NOT_FOUND`;如果同一系统酒店下匹配到多条,返回 `SOURCE_MESSAGE_AMBIGUOUS`。本地旧夹具允许在缺少 `hotel_id` 时使用内部数字 SourceMessage ID,但该兼容路径不作为 SuperAgent 正式契约。 +正式联调时,SuperAgent 不需要传 `hotel_id`。后端通过系统酒店和外部 `source_message_id` 查找唯一 `platform_source_message_inbox.external_message_id`,真实 provider/channel 以 Inbox 入库值为准。如果没有找到,返回 `SOURCE_MESSAGE_NOT_FOUND`;如果同一系统酒店下匹配到多条,返回 `SOURCE_MESSAGE_AMBIGUOUS`。本节为 REST 历史兼容说明;MCP submit 已不接受顶层 `source_message_id + ai_task_results[]`。 `informational_message` 结构化任务仅用于历史兼容。新数据如果是纯信息类邮件或无法形成业务素材包,应优先使用 V4 `S10/S99` 来源通知;V3 结构化 `S10/S99` 和下面的 `S000/S999` 文本请求体仅作为旧联调或兼容路径。 @@ -958,6 +958,8 @@ V3 `adapter_contract_error` 响应中的 `items[]` 不会包含 `order_id` / `ta | `SOURCE_MESSAGE_NOT_FOUND` | 404 | 任务结果通知或会话锚点引用的外部来源消息尚未写入 SourceMessage Inbox | | `SOURCE_MESSAGE_AMBIGUOUS` | 409 | 任务结果通知的外部来源消息在同一系统酒店下匹配到多条 Inbox 记录,后端拒绝随机选择 | | `missing_source_message_id` | 400 | V3 请求缺少 `source_message.source_message_id`,响应体为 typed `infrastructure_input_error` | +| `MCP_SUBMIT_V4_REQUIRED` | 200 tool error | MCP `th_hotel_submit_task_results` 收到的不是 M002 V4 根结构,例如旧 V2/V3 payload | +| `MCP_SUBMIT_PAYLOAD_INVALID` | 200 tool error | MCP submit 已具备 V4 根字段,但 transport 字段类型、未知字段或 V4 `source_message` / `attachments` 结构无效 | ## 10. HMAC 上线配置 diff --git a/docs/project/integrations/superagent-mcp/README.md b/docs/project/integrations/superagent-mcp/README.md index ecef95d..41225c8 100644 --- a/docs/project/integrations/superagent-mcp/README.md +++ b/docs/project/integrations/superagent-mcp/README.md @@ -23,7 +23,7 @@ server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/ | --- | --- | | `integration-guide.md` | MCP 总体接入说明和调用顺序 | | `tools.md` | 5 个 MCP tools 的工具契约 | -| `submit-payload-mapping.md` | `th_hotel_submit_task_results` 的 V3/P0.1 payload adapter、事件索引映射和提交前校验 | +| `submit-payload-mapping.md` | `th_hotel_submit_task_results` 的 M002 V4-only payload adapter、schema validator 和提交前校验 | | `inbound-diagnostics.md` | MCP 入站诊断链路,说明如何受控保存原始工具参数、adapter 后 payload 和安全错误摘要 | | `security-policy.md` | 鉴权、权限、正文、附件和日志边界 | | `deployment-guide.md` | 部署参数、环境变量和上线顺序 | diff --git a/docs/project/integrations/superagent-mcp/inbound-diagnostics.md b/docs/project/integrations/superagent-mcp/inbound-diagnostics.md index d4e30b5..44efd86 100644 --- a/docs/project/integrations/superagent-mcp/inbound-diagnostics.md +++ b/docs/project/integrations/superagent-mcp/inbound-diagnostics.md @@ -11,8 +11,9 @@ ## 2. 背景 -SuperAgent 通过 MCP `th_hotel_submit_task_results` 提交业务结果时,当前后端会把 MCP -`params.arguments` 转换为本系统 `ReservationAiTaskIntakeService` 可接收的业务 payload。 +SuperAgent 通过 MCP `th_hotel_submit_task_results` 提交业务结果时,当前后端只接受 M002 V4 +`params.arguments`,并把通过 V4 transport 校验的 payload 交给本系统 +`ReservationAiTaskIntakeService`。 当业务入站失败时,如果只看到最终错误,例如 `SOURCE_MESSAGE_NOT_FOUND` 或 `MCP_SUBMIT_PAYLOAD_INVALID`,很难判断问题来自: @@ -54,7 +55,7 @@ platform_superagent_mcp_call_diagnostic | `raw_body_json` | 原始 MCP JSON-RPC 请求体,受控诊断字段 | | `arguments_json` | `params.arguments` 原始 JSON,受控诊断字段 | | `adapted_payload_json` | submit adapter 转换后送入业务入站层的 JSON;非 submit 或转换失败为空 | -| `mapping_diagnostics_json` | submit adapter 事件索引映射诊断 | +| `mapping_diagnostics_json` | submit adapter 诊断;V4-only 模式下通常为空对象,旧 V3 事件索引映射已废弃 | | `response_summary_json` | MCP 响应安全摘要,不保存完整查询结果或正文 | | `call_status` | `RECEIVED`、`SUCCEEDED`、`FAILED` | | `safe_error_code` | 安全错误码,例如 `SOURCE_MESSAGE_NOT_FOUND` | @@ -71,11 +72,15 @@ platform_superagent_mcp_call_diagnostic | body 超限 | 不写诊断表,直接返回原有 body too large 错误 | | JSON 解析失败 | 鉴权通过后写入失败诊断,保存 body hash 和 raw body | | tools/list / query tool | 写入原始请求和参数,只保存响应安全摘要 | -| submit adapter 成功 | 补写 `adapted_payload_json` 和 `mapping_diagnostics_json` | +| submit adapter 成功 | 补写 `adapted_payload_json` 和 `mapping_diagnostics_json`;V4-only 模式下 `adapted_payload_json` 通常与 `arguments_json` 相同 | | submit adapter 失败 | 保留 `arguments_json`,记录 adapter 安全错误码 | | 业务入站失败 | 保留 `arguments_json` 和可用的 `adapted_payload_json`,记录业务安全错误码 | | 调用成功 | 标记 `SUCCEEDED`,保存响应安全摘要 | +因此,鉴权通过且 body 未超限的旧 V2 / V3 submit payload 会写入本表,`call_status=FAILED`, +`safe_error_code=MCP_SUBMIT_V4_REQUIRED`,`adapted_payload_json` 为空;这表示请求已被 MCP +adapter 拒绝,没有进入业务入站 Service。 + ## 6. 排障 SQL 按外部 SourceMessage ID 查询 SuperAgent 传入的原始 MCP 参数: @@ -113,7 +118,7 @@ WHERE id = ; ## 7. 与现有链路关系 - REST `POST /api/integrations/superagent/task-results` 仍使用现有 batch / transition 追踪。 -- MCP `th_hotel_submit_task_results` 会同时拥有 MCP 入站诊断和业务 batch / transition 追踪。 +- MCP `th_hotel_submit_task_results` 会同时拥有 MCP 入站诊断和业务 batch / transition 追踪;如果返回 `MCP_SUBMIT_V4_REQUIRED`,说明 SuperAgent 仍提交旧 V2/V3 schema,不会进入业务写入 Service。 - Debug EML、AgentBus dispatch run 不改原逻辑;如果 SuperAgent 最终通过 MCP 回写,才进入本诊断表。 - 第一版不新增前端页面和查询接口;DB 权限和运维访问由部署环境控制。 diff --git a/docs/project/integrations/superagent-mcp/integration-guide.md b/docs/project/integrations/superagent-mcp/integration-guide.md index 90e2e71..432a8e0 100644 --- a/docs/project/integrations/superagent-mcp/integration-guide.md +++ b/docs/project/integrations/superagent-mcp/integration-guide.md @@ -130,11 +130,9 @@ REST request 5. 必要时调用 `th_hotel_list_message_conversation_tasks` 查询同一邮件会话下已有任务。 6. 最终只在明确产出任务结果时调用 `th_hotel_submit_task_results`。 -写入工具里的 `source_message_id` 必须来自 AgentBus payload 的 `source.external_message_id`。SuperAgent 不需要传数据库层 provider/channel;TH Hotel 后端按系统酒店和外部消息 ID 匹配唯一 SourceMessage Inbox,真实 channel 可能是 `OUTLOOK`。 +写入工具里的 `source_message.source_message_id` 必须来自 AgentBus payload 的 `source.external_message_id`。SuperAgent 不需要传数据库层 `hotel_id` / provider / channel;TH Hotel 后端按系统酒店和外部消息 ID 匹配唯一 SourceMessage Inbox,真实 channel 可能是 `OUTLOOK`。 -M002 V3 后,写入工具优先接收结构化 `S10/S99` 或 `source_message + message_events[]` 业务根。`message_events[].source_event_index` 可以是 SuperAgent 内部事件 ID,例如 `E_CHILD_1`;MCP adapter 会在提交前按数组顺序映射为本系统一基数字索引,并校验跨事件关系是否悬空或重复。详细映射规则见 `submit-payload-mapping.md`。 - -V3 业务根写入成功时,MCP tool result 会返回 `mapping_diagnostics.source_event_index_mapping[]`,用于联调排查原始事件 ID 到本系统索引的映射;该字段不是业务任务字段,不会写入 TH Hotel 业务 payload。 +M002 V4 后,写入工具只接收 V4 包级结构:`route_code + source_message + order_contexts[] + message_events[]`。旧 V2 `ai_task_results[]`、旧 V3 `case_candidates[]` / `unhandled_current_intents[]`、旧 V3 S10/S99 入口通知都会返回 `MCP_SUBMIT_V4_REQUIRED`,不会进入业务写入 Service。详细规则见 `submit-payload-mapping.md`。 ## 9. 当前 checkpoint @@ -150,6 +148,6 @@ checkpoint-superagent-mcp-embedded-endpoint - 5 个 MCP tools 均可通过 `tools/list` 发现。 - 只读 tool 直接复用现有查询 Service。 - 写入 tool 受 `MCP_ENABLE_SUBMIT_TASK_RESULTS` 开关控制。 -- 写入 tool 已提供 V3/P0.1 submit payload adapter 和提交前 schema validator。 +- 写入 tool 已提供 M002 V4-only submit payload adapter 和提交前 schema validator。 - MCP endpoint 使用 Bearer Token 鉴权。 - 文档不包含生产 Secret、真实客户数据、附件 URL 或原始邮件正文。 diff --git a/docs/project/integrations/superagent-mcp/security-policy.md b/docs/project/integrations/superagent-mcp/security-policy.md index 59e5894..6a16e29 100644 --- a/docs/project/integrations/superagent-mcp/security-policy.md +++ b/docs/project/integrations/superagent-mcp/security-policy.md @@ -56,13 +56,14 @@ MCP endpoint 与后端业务 Service 同进程,不再通过 HMAC REST 调用 | `th_hotel_query_object_detail` | 只读 | 不写订单、不写任务、不写 OPERA | | `th_hotel_list_message_conversation_tasks` | 只读 | 不创建任务、不修改任务 | | `th_hotel_list_message_conversation_messages` | 受控读取 | 读取正文会写后端访问审计 | -| `th_hotel_submit_task_results` | 写入 | 写 AI 过渡层、订单、任务和任务卡 | +| `th_hotel_submit_task_results` | 写入 | 只接受 M002 V4 payload;写 AI 过渡层、V4 订单任务、V4 任务卡或 V4 来源通知,不写旧 V2/V3 任务 | 写入工具要求: - dev/test 可以用于联调。 - prod 必须经过上线审批后启用。 - 生产建议保留独立开关,例如 `MCP_ENABLE_SUBMIT_TASK_RESULTS=true`。 +- 旧 V2/V3 submit payload 必须返回 `MCP_SUBMIT_V4_REQUIRED`,不能进入业务写入 Service。 - 日志必须能定位调用方、request id、trace id 和 source message,但不能输出正文和 Secret。 ## 5. 邮件正文和附件边界 diff --git a/docs/project/integrations/superagent-mcp/submit-payload-mapping.md b/docs/project/integrations/superagent-mcp/submit-payload-mapping.md index 8e885c8..4f888e8 100644 --- a/docs/project/integrations/superagent-mcp/submit-payload-mapping.md +++ b/docs/project/integrations/superagent-mcp/submit-payload-mapping.md @@ -4,22 +4,24 @@ | 项目 | 内容 | | --- | --- | -| 文档版本 | 0.2 | -| 日期 | 2026-07-12 | -| 状态 | 已落地第一版 | -| 适用范围 | `th_hotel_submit_task_results` 的 V3/P0.1 payload adapter、schema validator 和一次提交规则 | +| 文档版本 | 0.3 | +| 日期 | 2026-07-22 | +| 状态 | 当前有效:M002 V4-only | +| 适用范围 | `th_hotel_submit_task_results` 的 M002 V4 payload adapter、schema validator 和一次提交规则 | ## 2. 核心原则 -`th_hotel_submit_task_results` 接收的是 SuperAgent 已经冻结的业务结果。MCP 层只负责把业务结果转换为本系统可接收的提交 payload,不重新解释业务含义,不调用 Skill,不根据字段名猜测任务类型。 +`th_hotel_submit_task_results` 只接收 M002 V4 任务结果包。MCP 层只做 transport 形态校验和 V4 根结构防错,不重新解释业务含义、不调用 Skill、不根据字段名猜测任务类型。 + +当前开发阶段已确认不维护 V2/V3 submit 兼容。旧 `source_message + message_events[] + case_candidates[]`、旧结构化 `S10/S99`、`source_message_id + ai_task_results[]` 都不会再通过 MCP submit 写入业务层。 目标链路: ```text -SuperAgent 业务结果 +SuperAgent M002 V4 业务结果 -> MCP Submit Payload Adapter - -> MCP Submit Schema Validator - -> ReservationAiTaskIntakeService + -> ReservationAiTaskIntakeService V4 入站 + -> V4 order task / V4 task card / V4 source notification -> MCP tool result ``` @@ -34,321 +36,204 @@ server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/service/impl/ | 形态 | 判断方式 | 处理方式 | | --- | --- | --- | -| V3 业务根 | 包含 `message_events` | 校验根结构、source message、event transport 字段和关系索引,再按 `message_events[]` 顺序映射 `source_event_index`;event 业务合法性仍交给业务入站层落 `adapter_contract_error` | -| V3 S10/S99 入口通知 | 包含 `route_code` 或入口通知 `result_type` | 校验 S10/S99 根字段和 source message 后透传给业务入站服务 | -| V2 兼容任务结果 | 包含 `ai_task_results` | 保留旧 `source_message_id + ai_task_results[]` 兼容路径,并在 MCP 层校验完整 item schema | +| V4 普通业务包 | 根字段包含 `route_code=null`、`source_message`、`order_contexts[]`、`message_events[]` | MCP adapter 校验根结构和 V4 `source_message` 后原样透传;业务入站层创建 V4 订单任务和任务卡 | +| V4 来源通知包 | 根字段包含 `route_code=S10/S99`、`source_message`、空 `order_contexts[]`、空 `message_events[]` | MCP adapter 校验根结构和 V4 `source_message` 后原样透传;业务入站层创建 V4 来源通知 | + +不再支持的输入形态: + +| 旧形态 | 示例特征 | 当前处理 | +| --- | --- | --- | +| V3 业务根 | `case_candidates`、`extraction_warnings`、`unhandled_current_intents`、旧 `source_message.from` | 返回 `MCP_SUBMIT_V4_REQUIRED`,不调用业务写入 Service | +| V3 S10/S99 | `handler_type`、`result_type=source_message_review_notification`、旧 `source_message.received_at` | 返回 `MCP_SUBMIT_V4_REQUIRED`,不调用业务写入 Service | +| V2 任务结果 | 顶层 `source_message_id + ai_task_results[]` | 返回 `MCP_SUBMIT_V4_REQUIRED`,不调用业务写入 Service | 未知根字段会在 MCP 层被拒绝,错误码为 `MCP_SUBMIT_PAYLOAD_INVALID`。 -`source_message_id` 缺失或整个 `source_message` 缺失属于既有基础设施输入错误,MCP adapter 不改写该错误通道;业务入站服务会返回 `MISSING_SOURCE_MESSAGE_ID`。 +## 4. V4 根结构 -V3 业务根和 S10/S99 如果携带 MCP schema 中的兼容字段 `hotel_id`、`source_provider`、`source_channel`,adapter 会接受并在转交业务入站层前移除。业务定位仍以系统酒店和 `source_message.source_message_id` 为准。 +```json +{ + "route_code": null, + "source_message": {}, + "order_contexts": [], + "message_events": [] +} +``` -## 4. source_message_id 定义 +字段规则: | 字段 | 规则 | | --- | --- | -| `source_message.source_message_id` | V3 必填;必须来自 AgentBus 原始 payload 的外部消息 ID,对应 `platform_source_message_inbox.external_message_id` | -| `source_message_id` | V2 兼容必填;同样对应 `platform_source_message_inbox.external_message_id` | -| `hotel_id` | SuperAgent 正式提交不需要传;本系统按平台唯一 ACTIVE 酒店解析 | +| `route_code` | 必须存在;普通业务为 `null`,来源通知为 `S10` 或 `S99` | +| `source_message` | 必须存在;结构见本文第 5 节 | +| `order_contexts[]` | 必须存在且为数组;普通业务至少一条,来源通知为空数组 | +| `message_events[]` | 必须存在且为数组;普通业务至少一条,来源通知为空数组 | -`source_message_id` 不是本系统数据库主键,也不是 `platform_source_message_inbox.id`。 +MCP adapter 不再映射 `source_event_index`。V4 `message_events[]` 的数组顺序就是业务处理顺序;业务层使用数组位置派生一基 event index。 -## 5. source_event_index 映射 - -SuperAgent 业务结果中的 `E1`、`E_CHILD_1`、`E_PARENT` 等值只是 Agent 内部事件 ID,不能直接当成本系统的 `source_event_index`。 - -MCP adapter 按 `message_events[]` 数组顺序生成一基数字索引: - -| 业务事件 ID | 数组位置 | 本系统 `source_event_index` | -| --- | --- | --- | -| `E_CHILD_1` | 第 1 个 event | `1` | -| `E_CHILD_2` | 第 2 个 event | `2` | -| `E_PARENT` | 第 3 个 event | `3` | -| `E_TRACE` | 第 4 个 event | `4` | - -映射规则: - -- `message_events[].source_event_index` 输出为数字。 -- `related_source_event_index` 输出为字符串数字,例如 `"1"`。 -- `parent_source_event_index` 输出为数字。 -- `related_source_event_indices[]` 输出为字符串数字数组,保持原顺序。 -- 悬空引用、重复引用、缺失事件 ID 都在 MCP 层拒绝。 -- MCP tool result 会返回 `mapping_diagnostics.source_event_index_mapping[]`,记录原始事件 ID 到本系统索引的映射;该诊断不写入 `workflow_reservation_ai_transition.ai_payload_json`。 - -## 6. ai_task_results[] 兼容 item schema - -V2 兼容路径仍保留,`ai_task_results[]` item 以 REST 总契约为准。当前 MCP `tools/list` 已暴露 item schema,adapter 也会在提交业务层前校验必填字段、字段类型、允许 `result_type`、未知字段和根级 `extraction_warnings`。 - -必填字段: - -- `source_event_index` -- `catalog_code` -- `skill_id` -- `result_type` -- `task_type` - -允许的 `result_type`: - -- `normal_task` -- `manual_review` -- `informational_message`,仅历史兼容 - -建议 item 结构: +## 5. source_message ```json { - "source_event_index": 1, - "catalog_code": "S01", - "skill_id": "S01_new_booking_skill", - "result_type": "normal_task", - "task_type": "New Booking", - "task_subtype": "new_fit_reservation", - "current_or_history": "current", - "case_keys": { - "group_code": null, - "confirmation_number": null, - "reservation_number": null, - "block_code": null - }, - "relevant_message_excerpt": "Please create a new booking.", - "attachments": [], - "file_references": [], - "context_used": {}, - "extracted_fields": {}, - "manual_review": null, - "additional_operations": [], - "idempotency_key": null + "source_message_id": "mail-20260722-0001", + "conversation_id": "thread-001", + "subject": "Update Booking", + "sender": "agent@example.test", + "sent_at": "2026-07-22T08:00:00Z", + "body": "Please update the booking.", + "body_content_type": "text/plain", + "attachments": [] } ``` -新数据优先使用 V3 业务根或 V3 S10/S99,不建议继续新增 V2 `ai_task_results[]`。 +字段规则: -## 7. extraction_warnings 规则 - -| 形态 | 规则 | +| 字段 | 规则 | | --- | --- | -| V3 业务根 | `extraction_warnings` 必须存在且为数组,无警告传 `[]` | -| V2 兼容 | `extraction_warnings` 可存在且为数组;缺省由业务层按空数组处理 | +| `source_message_id` | 必填非空;必须来自 AgentBus 原始 payload 的外部消息 ID,对应 `platform_source_message_inbox.external_message_id` | +| `conversation_id` | 可缺省;出现时必须为字符串或 `null` | +| `subject` | 必须出现,可为 `null` | +| `sender` | 必须出现,可为 `null` | +| `sent_at` | 必须出现,可为 `null`;时间点使用 UTC ISO-8601 | +| `body` | 必须出现,可为 `null`;当前邮件原文,不摘要、不翻译、不重排 | +| `body_content_type` | 必填;只能为 `text/plain` 或 `text/html` | +| `attachments[]` | 必填数组;附件结构见第 6 节 | -MCP 层不解释 warning 语义,不因为 warning 自动创建任务。 +旧 V3 的 `from`、`cc`、`received_at`、`source_channel` 不再属于 MCP submit 契约。`hotel_id`、`source_provider`、`source_channel` 也不需要 SuperAgent 传入。 -## 8. 成功示例:Parent Split - -提交给 MCP tool 的业务结果可以使用 Agent 内部事件 ID: +## 6. attachments ```json { + "id": "att-1", + "name": "payment-slip.jpg", + "content_type": "image/jpeg", + "url": "https://upstream-storage.example/payment-slip.jpg", + "size": 251524 +} +``` + +字段规则: + +| 字段 | 规则 | +| --- | --- | +| `id` | 必填非空;包内稳定附件引用 | +| `name` | 必填非空;原始文件名 | +| `content_type` | 必填非空;MIME 类型 | +| `url` | 必填非空;上游文件 URL,普通 V4 task detail 不直接返回 | +| `size` | 可省略或为 `null`;有值时必须是数字 | + +Payment 事件只能通过 `attachment_ids[]` 引用 `source_message.attachments[].id`,不能复制完整附件对象或按文件名猜测。 + +## 7. 成功示例 + +### 7.1 普通业务 + +```json +{ + "route_code": null, "source_message": { - "source_message_id": "mail-20260712-parent-split-001", - "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": "CHILD-A", - "confirmation_number": null, - "reservation_number": null, - "block_code": null - }, - "relevant_message_excerpt": "Create child group A.", - "attachments": [], - "file_references": [], - "context_used": {}, - "extracted_fields": { - "booking_object_type": "Group Block" - }, - "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": "CHILD-B", - "confirmation_number": null, - "reservation_number": null, - "block_code": null - }, - "relevant_message_excerpt": "Create child group B.", - "attachments": [], - "file_references": [], - "context_used": {}, - "extracted_fields": { - "booking_object_type": "Group Block" - }, - "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": "PARENT", - "confirmation_number": null, - "reservation_number": null, - "block_code": "PARENT" - }, - "relevant_message_excerpt": "Release parent after child split.", - "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": "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": ["CHILD-A", "CHILD-B"] - }, - "manual_review": null - } - ], - "case_candidates": [], - "extraction_warnings": [], - "unhandled_current_intents": [] -} -``` - -MCP adapter 进入业务层前会把父事件关系映射为: - -```json -{ - "source_event_index": 3, - "related_source_event_indices": ["1", "2"] -} -``` - -MCP tool result 同时返回非业务诊断: - -```json -{ - "mapping_diagnostics": { - "mapping_policy": "message_events_array_order_1_based", - "source_event_index_mapping": [ + "source_message_id": "mail-mcp-v4-business-001", + "conversation_id": "thread-mcp-v4-business-001", + "subject": "Group booking and payment", + "sender": "agent@example.test", + "sent_at": "2026-07-18T02:10:00Z", + "body": "Please create group GRP-MCP-V4-001 and note payment attached.", + "body_content_type": "text/plain", + "attachments": [ { - "original_source_event_index": "E_CHILD_1", - "mapped_source_event_index": 1 - }, - { - "original_source_event_index": "E_CHILD_2", - "mapped_source_event_index": 2 - }, - { - "original_source_event_index": "E_PARENT", - "mapped_source_event_index": 3 + "id": "att-pay-1", + "name": "payment-slip.jpg", + "content_type": "image/jpeg", + "url": "https://upstream-storage.example/payment-slip.jpg", + "size": 251524 } ] - } -} -``` - -## 9. 成功示例:跨 Child Trace - -当 Trace 覆盖完整 Parent split 的全部 Child 时,不能只绑定第一个 Child。业务结果应保留全部 Child 引用: - -```json -{ - "event_type": "Trace", - "source_event_index": "E_TRACE", - "related_source_event_indices": ["E_CHILD_1", "E_CHILD_2"], - "extracted_fields": { - "trace_subtype": "general_request" - } -} -``` - -MCP adapter 会把它映射为: - -```json -{ - "source_event_index": 4, - "related_source_event_indices": ["1", "2"] -} -``` - -## 10. 拒绝示例 - -未知字段: - -```json -{ - "source_message": {}, - "message_events": [], - "case_candidates": [], - "extraction_warnings": [], - "unhandled_current_intents": [], - "unexpected_root": true -} -``` - -返回: - -```json -{ - "isError": true, - "structuredContent": { - "success": false, - "error": { - "code": "MCP_SUBMIT_PAYLOAD_INVALID", - "details": { - "field": "unexpected_root" + }, + "order_contexts": [ + { + "order_ref": "order-1", + "basic_information": { + "account_code": "QBD_TRAVEL", + "manual_review": null } } - } + ], + "message_events": [ + { + "order_ref": "order-1", + "event_type": "NEW_BOOKING", + "target_order": { + "booking_type": "GROUP", + "locator_type": "GROUP_CODE", + "locator_value": "GRP-MCP-V4-001" + }, + "arrival_date": "2026-07-26", + "departure_date": "2026-07-29", + "rate_code": "GRPA2-850UP", + "booking_scenario": "STANDARD", + "room_items": [ + { + "room_type_code": "RM2", + "room_count": 2 + } + ], + "manual_review": null + }, + { + "order_ref": "order-1", + "event_type": "PAYMENT", + "target_order": { + "booking_type": "GROUP", + "locator_type": "GROUP_CODE", + "locator_value": "GRP-MCP-V4-001" + }, + "attachment_ids": ["att-pay-1"], + "manual_review": null + } + ] } ``` -悬空关系: +### 7.2 S99 来源通知 ```json { - "related_source_event_indices": ["E_CHILD_1", "E_UNKNOWN_CHILD"] + "route_code": "S99", + "source_message": { + "source_message_id": "mail-mcp-v4-s99-001", + "conversation_id": "thread-mcp-v4-s99-001", + "subject": "Cannot form material package", + "sender": "guest@example.test", + "sent_at": "2026-07-18T02:10:00Z", + "body": "The input does not contain enough business material.", + "body_content_type": "text/plain", + "attachments": [] + }, + "order_contexts": [], + "message_events": [] } ``` -同样返回 `MCP_SUBMIT_PAYLOAD_INVALID`,`details.field=related_source_event_indices`,不会调用业务写入 Service。 +## 8. 错误码 -## 11. 一次提交和失败不重试 +| 错误码 | 中文说明 | +| --- | --- | +| `MCP_SUBMIT_V4_REQUIRED` | MCP submit 收到的不是 M002 V4 根结构,通常是旧 V2/V3 payload | +| `MCP_SUBMIT_PAYLOAD_INVALID` | 已具备 V4 根字段,但字段类型、未知字段或 V4 `source_message` / `attachments` 结构不符合 MCP transport 校验 | +| `SOURCE_MESSAGE_NOT_FOUND` | V4 `source_message.source_message_id` 无法匹配 SourceMessage Inbox | +| `SOURCE_MESSAGE_AMBIGUOUS` | 同一系统酒店下外部 `source_message_id` 匹配多条 Inbox | +| `ADAPTER_CONTRACT_ERROR` | V4 包或 event 通过 MCP transport,但不满足业务入站契约,业务层记录 adapter contract error | -当前 MCP endpoint 与后端业务 Service 同进程: +MCP adapter 校验失败时不会调用业务写入 Service,不会创建 AI batch、V4 order task、V4 card、V4 source notification 或旧任务。 -- adapter 校验失败时,不调用 `ReservationAiTaskIntakeService`。 -- adapter 校验通过后,只调用一次 `ReservationAiTaskIntakeService.accept`。 -- MCP endpoint 不做自动重试。 -- 业务结果不会因为 MCP 响应被回写或污染。 +## 9. 诊断 -当前回归测试: +MCP 入站诊断表仍保存: -```text -./mvnw -Dtest=SuperAgentMcpControllerTest,SuperAgentMcpSubmitEnabledControllerTest test -``` +- `arguments_json`:SuperAgent 调用 MCP tool 的原始 arguments。 +- `adapted_payload_json`:MCP adapter 后交给业务入站层的 payload;V4-only 模式下通常与 arguments 相同。 +- `mapping_diagnostics_json`:当前 V4-only 模式下为空对象;旧 V3 事件 ID 映射已经废弃。 +- `safe_error_code` / `safe_error_summary`:安全错误摘要。 -覆盖内容: - -- `tools/list` 暴露 V3 submit schema。 -- `tools/list` 暴露 V2 `ai_task_results[]` item schema 和 V3 `relationship_type`。 -- 缺失 `source_message_id` 或整个 `source_message` 保持既有 `MISSING_SOURCE_MESSAGE_ID` 错误。 -- 未知根字段在业务层前被拒绝。 -- V2 item 缺必填字段在业务层前被拒绝。 -- 悬空 `related_source_event_indices` 在业务层前被拒绝。 -- 重复 `related_source_event_indices` 在业务层前被拒绝。 -- `E_CHILD_1/E_CHILD_2/E_PARENT` 按数组顺序映射为 `1/2/3`。 -- 跨 Child Trace 关系映射为完整 `["1", "2"]`,不压缩到第一个 Child。 -- S10/S99 可通过 MCP 写入工具创建只读来源消息任务。 -- V3 event 业务契约错误由业务入站层保存为 `adapter_contract_error` transition。 -- 合法 Parent split 只创建一个 AI batch,并返回 `accepted_count=3`。 +排障时先对比 `arguments_json` 和 `adapted_payload_json`。如果二者均为 V4 且业务层返回 `ADAPTER_CONTRACT_ERROR`,说明问题在 SuperAgent V4 业务契约内容或业务校验;如果错误为 `MCP_SUBMIT_V4_REQUIRED`,说明 SuperAgent 仍在按旧 schema 输出。 diff --git a/docs/project/integrations/superagent-mcp/test-cases.md b/docs/project/integrations/superagent-mcp/test-cases.md index 13c8558..6d90695 100644 --- a/docs/project/integrations/superagent-mcp/test-cases.md +++ b/docs/project/integrations/superagent-mcp/test-cases.md @@ -84,37 +84,30 @@ | 用例 ID | 场景 | 输入要点 | 期望结果 | | --- | --- | --- | --- | -| MCP-T05-001 | 提交单个 normal task | `ai_task_results` 1 条 | 返回 `accepted_count=1` | -| MCP-T05-002 | 提交 manual review | `result_type=manual_review` | 写入人工复核任务 | -| MCP-T05-003 | 提交 informational message | `result_type=informational_message` | 写入提示类信息 | -| MCP-T05-004 | source message 不存在 | 不存在的外部 `source_message_id` | 返回 `SOURCE_MESSAGE_NOT_FOUND` | -| MCP-T05-005 | 缺省 hotel id | 不传 `hotel_id`,source message 属于系统酒店 | 后端按系统唯一 ACTIVE 酒店写入成功 | -| MCP-T05-006 | 重复提交同一幂等任务 | 使用相同幂等信息 | 不重复创建业务任务 | -| MCP-T05-007 | hotel id 不一致 | 显式传非系统酒店 `hotel_id` | 返回 `HOTEL_ID_MISMATCH` | -| MCP-T05-008 | source message 入库渠道为 OUTLOOK | 不传 `source_channel`,只传外部 `source_message_id` | 后端按真实 Inbox 渠道写入成功 | -| MCP-T05-009 | source message 多渠道重复 | 同一酒店存在相同外部 `source_message_id` 的多条 Inbox | 返回 `SOURCE_MESSAGE_AMBIGUOUS` | -| MCP-T05-010 | V3 Parent split 使用 Agent 内部事件 ID | `message_events[].source_event_index=E_CHILD_1/E_CHILD_2/E_PARENT` | MCP adapter 按数组顺序映射为 `1/2/3`,返回 `accepted_count=3` | -| MCP-T05-011 | V3 缺失 source message id | `source_message.source_message_id` 缺失 | 返回既有 `MISSING_SOURCE_MESSAGE_ID` typed infrastructure error | -| MCP-T05-012 | V3 多事件关系悬空 | `related_source_event_indices` 引用不存在的事件 ID | 返回 `MCP_SUBMIT_PAYLOAD_INVALID`,不写 AI batch、订单或任务 | -| MCP-T05-013 | V3 多事件关系重复 | `related_source_event_indices=["E_CHILD_1","E_CHILD_1"]` | 返回 `MCP_SUBMIT_PAYLOAD_INVALID`,不写 AI batch、订单或任务 | -| MCP-T05-014 | V3 根节点未知字段 | 根节点存在 `unexpected_root` | 返回 `MCP_SUBMIT_PAYLOAD_INVALID`,不调用业务写入 Service | -| MCP-T05-015 | V3 跨 Child Trace | Trace event 关联全部 Child event | `related_source_event_indices[]` 保留全量关系并映射为真实索引,不压缩到第一个 Child | -| MCP-T05-016 | V3 缺失整个 source_message | 业务根或 S10/S99 没有 `source_message` | 返回既有 `MISSING_SOURCE_MESSAGE_ID` typed infrastructure error | -| MCP-T05-017 | V2 item 缺必填字段 | `ai_task_results[]` item 缺 `task_type` 等必填字段 | 返回 `MCP_SUBMIT_PAYLOAD_INVALID`,不调用业务写入 Service | -| MCP-T05-018 | V3 event 业务契约错误 | event_type unsupported 但 transport 字段完整 | MCP adapter 不整批拒绝,业务入站层保存 `adapter_contract_error` transition | -| MCP-T05-019 | V3 S10 入口通知 | `route_code=S10` | 通过 MCP 写入只读 `SOURCE_MESSAGE_ONLY` 任务 | -| MCP-T05-020 | V3 S99 入口通知 | `route_code=S99` 且 `manual_review` 完整 | 通过 MCP 写入只读 `SOURCE_MESSAGE_ONLY` 任务 | +| MCP-T05-001 | V4 普通业务包 | `route_code=null + source_message + order_contexts[] + message_events[]` | 返回 `accepted_count`;只创建 V4 order task / cards,不创建旧 `workflow_reservation_task` | +| MCP-T05-001A | V4 普通业务包缺省 conversation_id | `source_message` 不传 `conversation_id` | 返回 `accepted_count`;仍只创建 V4 order task / cards | +| MCP-T05-002 | V4 type-known manual review | V4 `message_events[].manual_review=true`,业务字段可由 V4 入站识别 | 创建 V4 `REVIEW_REQUIRED` 卡,后续通过 V4 复核接口解阻 | +| MCP-T05-003 | V4 S10 来源通知 | `route_code=S10`,`order_contexts=[]`,`message_events=[]` | 创建 V4 source notification,不创建订单和旧任务 | +| MCP-T05-004 | V4 S99 来源通知 | `route_code=S99`,`order_contexts=[]`,`message_events=[]` | 创建 V4 source notification,不创建订单和旧任务 | +| MCP-T05-005 | source message 不存在 | V4 `source_message.source_message_id` 无法匹配 Inbox | 返回 `SOURCE_MESSAGE_NOT_FOUND` | +| MCP-T05-006 | 缺省 hotel id | 不传 `hotel_id`,source message 属于系统酒店 | 后端按系统唯一 ACTIVE 酒店写入成功 | +| MCP-T05-007 | source message 入库渠道为 OUTLOOK | 不传 `source_channel`,只传 V4 `source_message.source_message_id` | 后端按真实 Inbox 渠道写入成功 | +| MCP-T05-008 | source message 多渠道重复 | 同一酒店存在相同外部 `source_message_id` 的多条 Inbox | 返回 `SOURCE_MESSAGE_AMBIGUOUS` | +| MCP-T05-009 | V4 根结构缺字段 | 缺少 `order_contexts` 或 `message_events` | 返回 `MCP_SUBMIT_V4_REQUIRED`,不调用业务写入 Service | +| MCP-T05-010 | V4 source_message 字段错误 | 使用旧 `from/received_at/source_channel` 或缺少 `sender/body_content_type` | 返回 `MCP_SUBMIT_PAYLOAD_INVALID`,不调用业务写入 Service | +| MCP-T05-011 | V4 附件字段错误 | 附件缺少 `id/name/content_type/url` 或 `size` 不是数字 | 返回 `MCP_SUBMIT_PAYLOAD_INVALID`,不调用业务写入 Service | +| MCP-T05-012 | V4 event 业务契约错误 | event_type unsupported 或 Payment `attachment_ids[]` 不匹配同包附件 | 由业务入站层保存 `adapter_contract_error`,不创建用户可处理卡 | +| MCP-T05-013 | 旧 V3 业务根 | 含 `case_candidates` / `unhandled_current_intents` | 返回 `MCP_SUBMIT_V4_REQUIRED`,不调用业务写入 Service | +| MCP-T05-014 | 旧 V3 S10/S99 | 含 `handler_type` / `result_type=source_message_review_notification` | 返回 `MCP_SUBMIT_V4_REQUIRED`,不调用业务写入 Service | +| MCP-T05-015 | 旧 V2 task results | 顶层 `source_message_id + ai_task_results[]` | 返回 `MCP_SUBMIT_V4_REQUIRED`,不调用业务写入 Service | 写入验证: -- `ai_task_results[]` 顺序不能被 MCP endpoint 改变。 -- V3 `message_events[]` 顺序是 `source_event_index` 的唯一基准,MCP endpoint 不能重排。 -- `E1/E2/E_PARENT` 等 Agent 内部事件 ID 只能由 MCP adapter 转换,不能作为本系统最终 `source_event_index`。 +- V4 `message_events[]` 顺序不能被 MCP endpoint 改变;业务层按数组顺序派生 event index。 - MCP endpoint 不能在日志输出完整 `extracted_fields` 中的敏感内容。 - 失败响应应保留后端错误码和 message。 - MCP adapter 校验失败时不进入业务写入 Service,且不自动重试。 -- V3 成功响应返回 `mapping_diagnostics.source_event_index_mapping[]`,但 AI transition 业务 payload 不包含该诊断字段。 -- V3 `relationship_type` 可在 event 根节点透传,也可在 `extracted_fields` 中作为业务关系字段;MCP adapter 不据此派生业务含义。 +- V4-only 模式下 `mapping_diagnostics_json` 为空对象;旧 V3 事件 ID 映射已经废弃。 ## 9. MCP 鉴权和开关测试 @@ -143,9 +136,9 @@ - 5 个工具正常成功调用。 - 查询接口错误 envelope 不丢失。 - 任务结果写入成功和幂等重放正常。 -- V3 submit payload adapter 的事件索引映射、mapping 诊断、悬空关系拒绝、重复关系拒绝和未知字段拒绝正常。 -- V2 `ai_task_results[]` item schema 和 adapter 校验正常。 -- S10/S99 结构化入口通知可通过 MCP 写入工具。 +- V4 submit payload adapter 的根结构、source_message、attachments 校验正常。 +- 旧 V2/V3 submit payload 统一返回 `MCP_SUBMIT_V4_REQUIRED`,不会进入业务写入 Service。 +- V4 S10/S99 来源通知可通过 MCP 写入工具。 - provider/channel 隔离正常。 - 受控正文不返回附件 URL。 - MCP auth 失败不进入业务 Service。 diff --git a/docs/project/integrations/superagent-mcp/tools.md b/docs/project/integrations/superagent-mcp/tools.md index f41c966..e7c1aa4 100644 --- a/docs/project/integrations/superagent-mcp/tools.md +++ b/docs/project/integrations/superagent-mcp/tools.md @@ -330,37 +330,33 @@ POST /api/ai-query/v1/message-conversation/messages ### 7.1 用途 -提交 SuperAgent 对单封外部来源消息的 AI 任务识别结果。该工具会写入 AI 过渡层、订单、任务和任务卡。 +提交 SuperAgent 对单封外部来源消息的 M002 V4 任务识别结果。该工具会写入 AI 过渡层、V4 订单任务、V4 任务卡或 V4 来源通知。 ### 7.2 何时使用 - SuperAgent 已完成当前邮件的最终任务拆分。 -- 已确认外部 `source_message_id` 来自 AgentBus payload;`hotel_id` 由 TH Hotel 后端解析。 -- 不需要为写入工具传数据库层 `source_provider` / `source_channel`;后端会按系统酒店和外部消息 ID 匹配真实 Inbox 记录。 +- 已确认 `source_message.source_message_id` 来自 AgentBus payload 的外部消息 ID;`hotel_id` 由 TH Hotel 后端解析。 +- 不需要为写入工具传数据库层 `hotel_id` / `source_provider` / `source_channel`;后端会按系统酒店和外部消息 ID 匹配真实 Inbox 记录。 - 需要把 AI 任务结果交给 TH Hotel 后端进入人工确认流程。 ### 7.3 不应使用 - 不应在试探、草稿、未完成推理阶段调用。 - 不应把历史邮件中的外部来源消息 ID 当作当前邮件 ID 提交。 -- 不应在缺少 `source_message_id` 时调用;`hotel_id` 不需要 SuperAgent 提供。 +- 不应在缺少 V4 `source_message.source_message_id` 时调用。 +- 不应继续提交 V2 `ai_task_results[]` 或 V3 `case_candidates` / `unhandled_current_intents` 结构。 ### 7.4 输入 Schema -当前 MCP tool 已支持三种输入形态,真实机器可读 schema 以 `/mcp` 的 `tools/list` 返回为准: - -1. V3 业务根:`source_message + message_events[]`。 -2. V3 S10/S99 入口通知:`source_message + route_code`。 -3. V2 兼容任务结果:`source_message_id + ai_task_results[]`。 - -MCP 层会先调用 `SuperAgentMcpSubmitPayloadAdapter` 做提交前校验和事件索引映射。`E1`、`E_CHILD_1`、`E_PARENT` 等 Agent 内部事件 ID 不会直接进入业务层;adapter 会按 `message_events[]` 顺序生成本系统一基数字 `source_event_index`,并同步映射 `related_source_event_index`、`parent_source_event_index` 和 `related_source_event_indices[]`。 +当前 MCP tool 只支持 M002 V4 包级结构,真实机器可读 schema 以 `/mcp` 的 `tools/list` 返回为准。 补充规则: -- V3 业务根或 S10/S99 如果携带 `hotel_id`、`source_provider`、`source_channel`,MCP adapter 会接受这些兼容字段,但转交业务入站层前会移除;业务定位仍以系统酒店和外部 `source_message_id` 为准。 -- 缺失 `source_message.source_message_id` 或整个 `source_message` 时,MCP adapter 不改写错误,业务入站层返回 `MISSING_SOURCE_MESSAGE_ID`。 -- V3 event 业务合法性问题,例如 unsupported `event_type`、不完整 `case_keys`、不完整 `manual_review`,由业务入站层保存为 `adapter_contract_error` transition;MCP adapter 只拦 transport 必需问题,包括未知字段、`source_event_index` 缺失/重复、关系悬空/重复。 -- V2 `ai_task_results[]` item 已在 MCP schema 和 adapter 中完整校验,必填字段为 `source_event_index`、`catalog_code`、`skill_id`、`result_type`、`task_type`。 +- V4 普通业务包:`route_code=null + source_message + order_contexts[] + message_events[]`。 +- V4 来源通知包:`route_code=S10/S99 + source_message + order_contexts=[] + message_events=[]`。 +- MCP adapter 只校验 V4 根结构和 V4 `source_message` / `attachments` transport 形态;event 业务契约问题由业务入站层记录 `adapter_contract_error`。 +- 旧 V2/V3 payload 返回 `MCP_SUBMIT_V4_REQUIRED`,不会调用业务写入 Service。 +- V4 根字段外的 `hotel_id`、`source_provider`、`source_channel`、`handler_type`、`result_type`、`ai_task_results`、`case_candidates` 等字段都会被拒绝。 下面 JSON 是关键结构节选;不要手工复制为最终 schema,联调时应以 `tools/list` 实时返回为准。 @@ -369,158 +365,122 @@ MCP 层会先调用 `SuperAgentMcpSubmitPayloadAdapter` 做提交前校验和事 "type": "object", "additionalProperties": false, "properties": { - "hotel_id": { + "route_code": { "type": ["string", "null"], - "description": "可选酒店上下文 ID;默认由 TH Hotel 后端解析" - }, - "source_provider": { - "type": ["string", "null"], - "description": "兼容字段;写入工具通常不需要传,后端写入定位不使用该字段" - }, - "source_channel": { - "type": ["string", "null"], - "description": "兼容字段;写入工具通常不需要传,后端写入定位不使用该字段,AgentBus 实际入库渠道可能是 OUTLOOK" + "description": "M002 V4 包级路由;普通业务必须为 null,来源通知为 S10 或 S99" }, "source_message": { "type": "object", - "description": "V3 来源邮件元数据;source_message_id 对应 AgentBus source.external_message_id", "properties": { - "source_message_id": { - "type": "string" + "source_message_id": { "type": "string" }, + "conversation_id": { "type": ["string", "null"] }, + "subject": { "type": ["string", "null"] }, + "sender": { "type": ["string", "null"] }, + "sent_at": { "type": ["string", "null"] }, + "body": { "type": ["string", "null"] }, + "body_content_type": { + "type": "string", + "enum": ["text/plain", "text/html"] }, - "subject": { - "type": ["string", "null"] - }, - "from": { - "type": ["string", "null"] - }, - "cc": { + "attachments": { "type": "array", "items": { - "type": "string" + "type": "object", + "additionalProperties": false, + "required": ["id", "name", "content_type", "url"], + "properties": { + "id": { "type": "string" }, + "name": { "type": "string" }, + "content_type": { "type": "string" }, + "url": { "type": "string" }, + "size": { "type": ["number", "null"] } + } } - }, - "received_at": { - "type": ["string", "null"] - }, - "source_channel": { - "type": "string", - "enum": ["Email"] } }, - "required": ["source_message_id", "subject", "from", "cc", "received_at", "source_channel"] + "required": ["source_message_id", "subject", "sender", "sent_at", "body", "body_content_type", "attachments"] }, - "route_code": { - "type": ["string", "null"], - "description": "V3 S10/S99 入口通知路由码" - }, - "handler_type": { - "type": ["string", "null"], - "description": "V3 Main Agent 输出处理器类型" - }, - "result_type": { - "type": ["string", "null"], - "description": "V3 入口通知或 V2 任务结果类型" - }, - "current_or_history": { - "type": ["string", "null"], - "description": "V3 current/history 标记" - }, - "agent_assessment": { - "type": "object", - "description": "V3 S10/S99 入口判断摘要" - }, - "notification": { - "type": "object", - "description": "V3 S10/S99 通知展示信息" - }, - "manual_review": { - "type": ["object", "null"], - "description": "V3 人工复核对象;S10 可为空,S99 必须完整" - }, - "message_events": { + "order_contexts": { "type": "array", - "description": "V3 业务事件数组;MCP Adapter 会按数组顺序生成一基 source_event_index", - "items": { - "type": "object" - } - }, - "case_candidates": { - "type": "array", - "description": "V3 订单候选数组,无候选传空数组", - "items": { - "type": "object" - } - }, - "unhandled_current_intents": { - "type": "array", - "description": "V3 未覆盖当前意图数组,无意图传空数组", - "items": { - "type": "object" - } - }, - "source_message_id": { - "type": "string", - "description": "V2 兼容字段:外部来源消息 ID,对应 AgentBus source.external_message_id" - }, - "ai_task_results": { - "type": "array", - "description": "V2 兼容字段:AI 拆分出的任务结果,必须保留数组顺序", "items": { "type": "object", "additionalProperties": false, - "required": ["source_event_index", "catalog_code", "skill_id", "result_type", "task_type"], + "required": ["order_ref", "basic_information"], "properties": { - "source_event_index": { - "type": ["integer", "string"] - }, - "catalog_code": { - "type": "string" - }, - "skill_id": { - "type": "string" - }, - "result_type": { - "type": "string", - "enum": ["normal_task", "manual_review", "informational_message"] - }, - "task_type": { - "type": "string" + "order_ref": { "type": "string" }, + "basic_information": { + "type": "object", + "additionalProperties": false, + "required": ["account_code", "manual_review"], + "properties": { + "account_code": { "type": ["string", "null"] }, + "manual_review": { "type": ["boolean", "null"] } + } } } } }, - "extraction_warnings": { + "message_events": { "type": "array", - "description": "AI 抽取警告;V3/V2 都允许,缺省按空数组处理", "items": { - "type": "object" + "type": "object", + "additionalProperties": true, + "required": ["order_ref", "event_type", "target_order", "manual_review"], + "properties": { + "order_ref": { "type": "string" }, + "event_type": { + "type": "string", + "enum": ["NEW_BOOKING", "UPDATE_BOOKING", "CANCEL_BOOKING", "TRACE_RESERVATION_NOTES", "ROOMING_LIST", "PAYMENT"] + }, + "target_order": { + "type": "object", + "additionalProperties": false, + "required": ["booking_type", "locator_type", "locator_value"], + "properties": { + "booking_type": { "type": ["string", "null"] }, + "locator_type": { "type": ["string", "null"] }, + "locator_value": { "type": ["string", "null"] } + } + }, + "manual_review": { + "type": ["boolean", "null"] + }, + "arrival_date": { "type": ["string", "null"] }, + "departure_date": { "type": ["string", "null"] }, + "rate_code": { "type": ["string", "null"] }, + "booking_scenario": { "type": ["string", "null"] }, + "guest_name": { "type": ["string", "null"] }, + "room_items": { + "type": "array", + "items": { + "type": "object", + "required": ["room_type_code", "room_count"], + "properties": { + "room_type_code": { "type": ["string", "null"] }, + "room_count": { "type": ["integer", "null"] } + } + } + }, + "after": { "type": "object" }, + "trace_items": { "type": "array" }, + "attachment_ids": { + "type": "array", + "items": { "type": "string" } + } + } } } }, - "required": [], - "oneOf": [ - { - "required": ["source_message", "message_events", "case_candidates", "extraction_warnings", "unhandled_current_intents"] - }, - { - "required": ["source_message", "route_code", "handler_type", "result_type", "current_or_history", "agent_assessment", "notification", "manual_review"] - }, - { - "required": ["source_message_id", "ai_task_results"] - } - ] + "required": ["route_code", "source_message", "order_contexts", "message_events"] } ``` 说明: -- V3 业务根字段、S10/S99 字段和 V2 `ai_task_results[]` item 完整语义以 `superagent-api-contract.md` 第 8 节为准。 -- BusinessResult 到 MCP payload 的稳定映射、`source_event_index` 正式定义、跨 Child Trace 示例和拒绝示例见 `submit-payload-mapping.md`。 -- MCP endpoint 不应重排 V2 `ai_task_results[]`,也不应重排 V3 `message_events[]`。 -- `source_message_id` 必须是 AgentBus payload 的 `source.external_message_id`,不是内部 `platform_source_message_inbox.id`;写入工具不要求 SuperAgent 知道 Inbox 的真实 channel。 +- V4 字段完整语义以 `superagent-api-contract.md` 第 8 节和 `M002-v4-agent-callback-field-contract.md` 为准。 +- `source_message.source_message_id` 必须是 AgentBus payload 的 `source.external_message_id`,不是内部 `platform_source_message_inbox.id`;写入工具不要求 SuperAgent 知道 Inbox 的真实 channel。 - 同一系统酒店下如果外部 `source_message_id` 匹配多条 Inbox,业务 Service 返回 `SOURCE_MESSAGE_AMBIGUOUS`,MCP tool result 应原样保留该错误码和 message。 -- MCP adapter 校验失败时返回 `MCP_SUBMIT_PAYLOAD_INVALID`,不会调用业务写入 Service,也不会自动重试。 +- 旧 V2/V3 payload 会返回 `MCP_SUBMIT_V4_REQUIRED`;V4 transport 校验失败返回 `MCP_SUBMIT_PAYLOAD_INVALID`。两者都不会调用业务写入 Service,也不会自动重试。 ### 7.5 输出 @@ -537,9 +497,8 @@ POST /api/integrations/superagent/task-results - `accepted_count`:接收数量。 - `items[].ai_transition_id`:AI 过渡层 ID。 - `items[].order_id`:订单 ID。 -- `items[].task_id`:任务 ID。 -- `items[].task_status`:任务状态。 -- `mapping_diagnostics`:仅 MCP 写入工具在 V3 业务根映射时返回,记录 Agent 原始事件 ID 到本系统一基索引的映射;不写入业务 JSON。 +- `items[].task_id`:V4 主线不再返回旧任务 ID,通常为 `null`。 +- `items[].task_status`:V4 主线不再返回旧任务状态,通常为 `null`。 完整字段以 `superagent-api-contract.md` 第 8 节为准。 @@ -574,5 +533,6 @@ MCP 层新增错误建议: | `MCP_REQUEST_BODY_TOO_LARGE` | MCP 请求体超过 10MB 默认限制或环境配置限制 | | `MCP_METHOD_NOT_FOUND` | MCP 方法不存在 | | `MCP_TOOL_NOT_FOUND` | MCP 工具不存在 | +| `MCP_SUBMIT_V4_REQUIRED` | 写入工具收到的不是 M002 V4 根结构,例如旧 V2/V3 payload | | `MCP_SUBMIT_PAYLOAD_INVALID` | 写入工具 payload 未通过 MCP adapter 提交前校验 | | `MCP_INTERNAL_ERROR` | MCP endpoint 内部异常 | diff --git a/docs/project/requirements/M002-order-task-workflow-v3.md b/docs/project/requirements/M002-order-task-workflow-v3.md index f3c6232..a2c8d3a 100644 --- a/docs/project/requirements/M002-order-task-workflow-v3.md +++ b/docs/project/requirements/M002-order-task-workflow-v3.md @@ -416,7 +416,7 @@ V3 建议拆成以下 checkpoint,避免一次性重构过大: | M002-V3-CP5 | 同卡复核解阻 | 已完成第一版:支持 review_status、review_resolution.field_overrides[]、复核场景订单归属确认、JSON Pointer 校验和 READY 流转 | | M002-V3-CP6 | P0 fixtures 回归 | 已完成第一版:引入 0711 P0 fixtures / validator 作为后端适配测试参考,覆盖 main_outcomes、candidate_gate、manual_review_resolution、source_identity_errors、parent_split_two_children、row_multiple_derived、allotment_scope;其中 candidate_gate 是 Main Agent 调 Skill 前契约,后端以 validator 和 fixture reference 固化,不作为任务结果回调直接建任务 | | M002-V3-CP7 | P0.1 Parent Group 路由修订 | 已完成:将 Parent split 父事件从旧 Cancel Booking 迁移为 Cancel Allotment,路由总数 42 → 40,并保留旧 payload 只读兼容 | -| M002-V3-CP8 | MCP submit 稳定性 | 已完成 review 修正版:MCP `th_hotel_submit_task_results` 支持 V3 业务根、结构化 S10/S99 和 V2 兼容;V2 item 已暴露完整 schema 并在提交前校验;V3 按 `message_events[]` 顺序把 Agent 内部事件 ID 映射为本系统一基 `source_event_index`,返回 `mapping_diagnostics` 但不污染业务 JSON;MCP 层只拦 transport 问题,event 业务契约问题继续由业务入站层落 `adapter_contract_error` | +| M002-V3-CP8 | MCP submit 稳定性 | 历史已完成;M002 V4 后已被 V4-only MCP submit 替代。当前 `th_hotel_submit_task_results` 只接受 `route_code + source_message + order_contexts[] + message_events[]`,旧 V2/V3 payload 返回 `MCP_SUBMIT_V4_REQUIRED`,不再做 V3 事件索引映射或 V2 `ai_task_results[]` 校验 | | M002-V3-CP9 | 字段控件契约 V1 | 已完成后端第一版:`fields[]` 返回 `control_type/edit_scope/write_target/options_source/raw_readonly/control_hint`,覆盖 room_items、只读证据、S10/S99 只读卡和 type-known manual review 缺失字段控件;前端后续按契约接入 | ## 13. 明确不做 @@ -468,10 +468,10 @@ V3 P0.1 不做以下事项: - `field_contract_version` 历史迁移已收紧:V18 只把没有 `draft_payload_json` 且没有 `confirmed_payload_json` 的 `code-v1` 任务卡标记为 `20260711-p0`;已经存在用户草稿或确认 payload 的历史任务卡保留旧版本,等待重新保存、确认或后续专项 backfill。 - 订单 / 任务列表、任务详情、草稿保存、最终确认、OPERA 模拟骨架和审计列表。 - SuperAgent 查询上下文接口 1、2,以及邮件会话相关查询。 -- MCP `th_hotel_submit_task_results` 的 V3/P0.1 payload adapter 和提交前校验;`E1/E_CHILD_1/E_PARENT` 等 Agent 内部事件 ID 不直接进入业务层,由 MCP adapter 映射为 `1/2/3` 等本系统一基索引,跨 Child Trace / Parent split 多事件关系保留顺序并拒绝悬空或重复引用。 -- MCP submit V3 成功响应返回 `mapping_diagnostics.source_event_index_mapping[]`,用于排查 Agent 内部事件 ID 到本系统索引的映射;该诊断不写入 AI transition 业务 payload。 -- MCP submit V2 兼容路径已在 `tools/list` 暴露 `ai_task_results[]` item schema,并在 adapter 层校验必填字段、字段类型、允许 `result_type` 和未知字段。 -- MCP submit 对缺失 `source_message.source_message_id` 或整个 `source_message` 保留业务入站层 `MISSING_SOURCE_MESSAGE_ID` 错误语义;对 V3 event 业务契约问题不提前整批拒绝,由业务入站层保存 `adapter_contract_error` transition。 +- MCP `th_hotel_submit_task_results` 的 V3/P0.1 payload adapter 和 V2 `ai_task_results[]` 兼容已作为历史参考废弃;当前 MCP submit 已收口为 M002 V4-only。 +- 当前 MCP submit 只接受 `route_code + source_message + order_contexts[] + message_events[]`,旧 V2/V3 payload 返回 `MCP_SUBMIT_V4_REQUIRED`,不调用业务写入 Service。 +- V4-only 模式下 `mapping_diagnostics_json` 通常为空对象;旧 V3 事件 ID 映射不再作为当前联调能力。 +- V4 event 业务契约问题继续由业务入站层保存为 `adapter_contract_error` transition;MCP adapter 只拦 V4 根结构、`source_message` 和附件 transport 形态问题。 M002 V4 CP2 设计文档已落地: diff --git a/docs/project/requirements/M002-v4-agent-callback-field-contract.md b/docs/project/requirements/M002-v4-agent-callback-field-contract.md index f0ce2a9..ac9c858 100644 --- a/docs/project/requirements/M002-v4-agent-callback-field-contract.md +++ b/docs/project/requirements/M002-v4-agent-callback-field-contract.md @@ -18,6 +18,8 @@ V4 订单任务与多卡领域模型的 CP2 设计已经单独落到 `M002-v4-order-task-card-domain-model-cp2.md`。截至 CP14 和停止旧任务双写 checkpoint,表结构、Entity、Mapper、Repository、SuperAgent V4 入站写入、V4 查询、普通卡片确认、S10/S99 ack、V4 复核解阻、数据库目录、Account / Room Type / Rate Code Lookup API、目录管理后台 CP1 后端接口、订单列表 V4 继续处理入口字段,以及 V4 普通业务不再创建旧 `workflow_reservation_task` 已实现。Room Information 卡 New / Update / Cancel 展示模型、Nights / Breakfast / Group Booking Status 派生,以及 Rooming List 确认后 Group 自动置 `DEF` 的后端联动已实现;这些均不扩大 SuperAgent 输入字段。2026-07-21 OWNER RATE `RATECODE (2)` 只读整理已确认:Room Type 第一阶段只维护 `RM2`、`RM3`、`RM4`、`SU1`、`SU2`、`SU3` 六个稳定 code,不建 Account -> Room Type 关系;Rate Code 第一阶段暂不建立 Account 适用关系,Q.B.D / LIAN TAI 的 40 个规范化 Rate Code 作为酒店级目录候选;真实 PMS 同步仍后置,方案见 `M002-v4-real-catalog-lookup-api-design.md`。 +2026-07-22 后,MCP `th_hotel_submit_task_results` 已与本文 V4 字段契约对齐并收口为 V4-only:只接受 `route_code + source_message + order_contexts[] + message_events[]`,旧 V2/V3 submit payload 返回 `MCP_SUBMIT_V4_REQUIRED`。 + 当前已确认开发阶段数据可以清空,因此 M002 V4 后续可以按新模型重建,不要求兼容旧任务数据、旧草稿、旧 OPERA 模拟、旧 `S000/S999`、旧 Fallback 或旧 `case_keys`。 ## 2. 输入资料与优先级 diff --git a/docs/project/requirements/M002-v4-order-task-card-domain-model-cp2.md b/docs/project/requirements/M002-v4-order-task-card-domain-model-cp2.md index 68b1c98..d72b530 100644 --- a/docs/project/requirements/M002-v4-order-task-card-domain-model-cp2.md +++ b/docs/project/requirements/M002-v4-order-task-card-domain-model-cp2.md @@ -18,6 +18,8 @@ M002 V4 CP1 已完成 SuperAgent V4 回调包入站解析、基础校验、路 截至 CP14、V4 业务审计查询、停止旧任务双写、Room Information 后端展示模型、Rooming List 确认自动 DEF 后端联动和 Payment 附件安全摘要后端第一版,后端已实现本文第 10、11、12 节中的持久化和查询基线,并已把 SuperAgent V4 入站结果写入新表:普通业务包只创建 V4 订单任务、来源邮件展示卡、Basic Information 卡和业务卡,不再创建旧 `workflow_reservation_task`;V4 S10/S99 创建来源通知。当前已开放 V4 工作台、订单任务列表 / 详情、来源通知详情查询接口、订单详情 V4 订单任务时间线、V4 卡片确认接口、S10/S99 来源通知 ack 接口、V4 `REVIEW_REQUIRED` 卡复核解阻接口、V4 订单任务 / 来源通知审计查询接口、当前酒店数据库目录校验、卡片 `fields[]` 白名单、Account / Room Type / Rate Code lookup API、目录管理后台 CP1、订单列表 V4 继续处理入口字段、Room Information New / Update / Cancel 第一版业务展示模型、Rooming List 确认触发 Group Booking Status 自动置 `DEF`,以及 Payment 卡 `payment_attachments[]` 安全摘要。2026-07-21 OWNER RATE `RATECODE (2)` 只读整理已确认:Room Type 第一阶段只维护 `RM2`、`RM3`、`RM4`、`SU1`、`SU2`、`SU3` 六个稳定 code,不建 Account -> Room Type 关系;Rate Code 第一阶段暂不建立 Account 适用关系,Q.B.D / LIAN TAI 的 40 个规范化 Rate Code 作为酒店级目录候选。真实 PMS 同步继续后置,设计见 `M002-v4-real-catalog-lookup-api-design.md`。 +2026-07-22 后,MCP `th_hotel_submit_task_results` 也已与本文模型对齐:MCP submit 只接受 M002 V4 包级结构,旧 V2/V3 submit payload 返回 `MCP_SUBMIT_V4_REQUIRED`,不会绕回旧 `workflow_reservation_task` 模型。 + 后续如本文与 `M002-v4-agent-callback-field-contract.md` 的字段契约冲突,以字段契约为准;如与安全边界冲突,以 `security-access-control-boundary.md` 为准。 ## 2. CP1 已完成和 CP2 差距 diff --git a/docs/project/security-access-control-boundary.md b/docs/project/security-access-control-boundary.md index 3d3f49e..6a33eac 100644 --- a/docs/project/security-access-control-boundary.md +++ b/docs/project/security-access-control-boundary.md @@ -122,7 +122,7 @@ | `POST /api/ai-query/v1/conversation-tasks` | `THIRD_PARTY_SUPERAGENT` | HMAC 鉴权 | 保持 HMAC;不返回邮件原文 | 记录请求 ID、client_id 和安全错误 | | `POST /api/ai-query/v1/conversation-source` | `THIRD_PARTY_SUPERAGENT` | HMAC 鉴权 | 保持 HMAC;只按契约返回需要字段 | 记录请求 ID、client_id 和安全错误 | | `POST /api/integrations/superagent/task-results` | `THIRD_PARTY_SUPERAGENT` | HMAC + nonce + timestamp + body hash | 保持;V4 / V3 / V2 共存期均必须用外部 `source_message_id` 匹配 Inbox,技术契约错误只落 adapter error,不创建用户可处理任务;V4 普通业务包只写 V4 订单任务 / 多卡模型,不再创建旧 `workflow_reservation_task`;V4 S10/S99 写入来源通知且不创建旧任务 | 记录 batch、transition、adapter error、幂等结果和安全错误 | -| `/mcp` | `THIRD_PARTY_MCP` | Bearer Token;提交工具可独立开关 | 保持;工具级能力限制,不暴露无关接口 | 记录工具调用结果、业务入站结果和受控 MCP 入站诊断;诊断原文不进入普通前端接口或普通日志 | +| `/mcp` | `THIRD_PARTY_MCP` | Bearer Token;提交工具可独立开关;`th_hotel_submit_task_results` 已收口为 M002 V4-only,旧 V2/V3 submit payload 返回 `MCP_SUBMIT_V4_REQUIRED` | 保持;工具级能力限制,不暴露无关接口;MCP 写入工具不走用户权限码、不接受前端调用、不作为 REST 历史兼容入口 | 记录工具调用结果、业务入站结果和受控 MCP 入站诊断;诊断原文不进入普通前端接口或普通日志 | | AgentBus WebSocket | `THIRD_PARTY_AGENTBUS` | AgentBus Token + capture 开关 | 保持;只入 SourceMessage,不直接建业务任务 | 记录 SourceMessage、payload hash 和 dispatch run | ### 3.7 后端内部能力 @@ -207,7 +207,7 @@ | 业务审计 | `workflow_reservation_audit_log` | 任务确认、人工复核、订单归属确认、V4 来源通知 ack、Rooming List 触发 Group Booking Status 自动 DEF、OPERA 执行 / 重试 | | 邮件原文读取审计 | `platform_source_message_original_access_audit` | 读取邮件正文、HTML、附件外链、Payment 图片预览和非图片下载 | | SuperAgent 入站追踪 | `workflow_reservation_ai_batch`、`workflow_reservation_ai_transition` | task-results / MCP 提交、路由、adapter error | -| SuperAgent MCP 入站诊断 | `platform_superagent_mcp_call_diagnostic` | MCP 原始工具参数、submit adapter 后 payload、事件索引映射和安全错误摘要;不保存完整查询 tool 响应 | +| SuperAgent MCP 入站诊断 | `platform_superagent_mcp_call_diagnostic` | MCP 原始工具参数、submit adapter 后 payload、空 mapping diagnostics 和安全错误摘要;不保存完整查询 tool 响应;V4-only 后旧 V3 事件索引映射已废弃 | | AgentBus 分发追踪 | `platform_superagent_dispatch_run` | SourceMessage 自动分发 SuperAgent、重试、失败摘要 | | 安全审计 | 后续可新增平台安全审计表 | 登录失败、签名失败、nonce 重放、越权访问 | diff --git a/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/service/SuperAgentMcpSubmitPayloadAdapter.java b/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/service/SuperAgentMcpSubmitPayloadAdapter.java index 9a4c819..34d6203 100644 --- a/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/service/SuperAgentMcpSubmitPayloadAdapter.java +++ b/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/service/SuperAgentMcpSubmitPayloadAdapter.java @@ -4,12 +4,12 @@ import cn.nianxx.thhotel.integrations.mcp.superagent.common.dto.SuperAgentMcpSub import com.fasterxml.jackson.databind.JsonNode; /** - * SuperAgent MCP 写入工具 payload 适配服务。负责把 Agent 业务结果转换为本系统任务结果入站 payload。 + * SuperAgent MCP 写入工具 payload 适配服务。M002 V4 起只接受 V4 包级业务结果。 */ public interface SuperAgentMcpSubmitPayloadAdapter { /** - * 校验并转换 th_hotel_submit_task_results 的 arguments,返回可提交给业务入站服务的稳定 payload 和 MCP 诊断。 + * 校验 th_hotel_submit_task_results 的 V4 arguments,返回可提交给业务入站服务的稳定 payload 和 MCP 诊断。 */ SuperAgentMcpSubmitPayloadAdaptation adapt(JsonNode arguments); } diff --git a/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/service/impl/SuperAgentMcpServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/service/impl/SuperAgentMcpServiceImpl.java index 8a70bf6..c650ad6 100644 --- a/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/service/impl/SuperAgentMcpServiceImpl.java +++ b/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/service/impl/SuperAgentMcpServiceImpl.java @@ -18,7 +18,6 @@ import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationAiObjec import cn.nianxx.thhotel.workflows.reservation.common.request.ReservationMessageConversationQueryRequest; import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationAiQueryErrorResult; import cn.nianxx.thhotel.workflows.reservation.common.result.ReservationAiQueryResponse; -import cn.nianxx.thhotel.workflows.reservation.common.enums.ReservationAiV3EventType; import cn.nianxx.thhotel.workflows.reservation.common.result.SuperAgentTaskResultResponse; import cn.nianxx.thhotel.workflows.reservation.service.ReservationAiQueryService; import cn.nianxx.thhotel.workflows.reservation.service.ReservationAiTaskIntakeService; @@ -28,7 +27,6 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; -import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -364,7 +362,7 @@ public class SuperAgentMcpServiceImpl implements SuperAgentMcpService { readOnlyAnnotations()), new SuperAgentMcpToolDefinition( TOOL_SUBMIT_TASK_RESULTS, - "提交 SuperAgent AI 任务结果,会写入 AI 过渡层、订单、任务和任务卡。", + "提交 M002 V4 SuperAgent 任务结果,会写入 AI 过渡层、V4 订单任务、V4 任务卡或 V4 来源通知。", submitTaskResultsSchema(), writeAnnotations())); } @@ -401,176 +399,204 @@ public class SuperAgentMcpServiceImpl implements SuperAgentMcpService { private Map submitTaskResultsSchema() { Map propertiesMap = new LinkedHashMap<>(); - propertiesMap.put("hotel_id", stringField("可选酒店上下文 ID;缺省由 TH Hotel 后端解析系统酒店")); - propertiesMap.put("source_provider", nullableStringField("兼容字段;写入工具通常不需要传,后端写入定位不使用该字段")); - propertiesMap.put("source_channel", nullableStringField("兼容字段;写入工具通常不需要传,后端写入定位不使用该字段,AgentBus 实际入库渠道可能是 OUTLOOK")); + propertiesMap.put("route_code", nullableStringField("M002 V4 包级路由;普通业务必须为 null,来源通知为 S10 或 S99")); propertiesMap.put("source_message", sourceMessageSchema()); - propertiesMap.put("route_code", nullableStringField("V3 S10/S99 入口通知路由码")); - propertiesMap.put("handler_type", nullableStringField("V3 Main Agent 输出处理器类型")); - propertiesMap.put("result_type", nullableStringField("V3 入口通知或 V2 任务结果类型")); - propertiesMap.put("current_or_history", nullableStringField("V3 current/history 标记")); - propertiesMap.put("agent_assessment", Map.of("type", "object", "description", "V3 S10/S99 入口判断摘要")); - propertiesMap.put("notification", Map.of("type", "object", "description", "V3 S10/S99 通知展示信息")); - propertiesMap.put("manual_review", Map.of( - "type", List.of("object", "null"), - "description", "V3 人工复核对象,S10 可为空,S99 必须完整")); + propertiesMap.put("order_contexts", Map.of( + "type", "array", + "description", "M002 V4 订单上下文数组;S10/S99 必须为空数组", + "items", orderContextSchema())); propertiesMap.put("message_events", Map.of( "type", "array", - "description", "V3 业务事件数组;MCP Adapter 会按数组顺序生成一基 source_event_index", + "description", "M002 V4 业务事件数组;普通业务至少一条,S10/S99 必须为空数组,数组顺序即业务处理顺序", "items", messageEventSchema())); - propertiesMap.put("case_candidates", Map.of( - "type", "array", - "description", "V3 订单候选数组,无候选传空数组", - "items", Map.of("type", "object"))); - propertiesMap.put("unhandled_current_intents", Map.of( - "type", "array", - "description", "V3 未覆盖当前意图数组,无意图传空数组", - "items", Map.of("type", "object"))); - propertiesMap.put("source_message_id", stringField("V2 兼容字段:外部来源消息 ID,对应 AgentBus source.external_message_id")); - propertiesMap.put("ai_task_results", Map.of( - "type", "array", - "description", "V2 兼容字段:AI 拆分出的任务结果,必须保留数组顺序", - "items", legacyV2TaskResultItemSchema())); - propertiesMap.put("extraction_warnings", Map.of( - "type", "array", - "description", "AI 抽取警告;V3/V2 都允许,缺省按空数组处理", - "items", Map.of("type", "object"))); - Map schema = new LinkedHashMap<>(objectSchema(propertiesMap, List.of())); - schema.put("oneOf", List.of( - Map.of( - "required", List.of( - "source_message", - "message_events", - "case_candidates", - "extraction_warnings", - "unhandled_current_intents")), - Map.of( - "required", List.of( - "source_message", - "route_code", - "handler_type", - "result_type", - "current_or_history", - "agent_assessment", - "notification", - "manual_review")), - Map.of("required", List.of("source_message_id", "ai_task_results")))); - return schema; + return objectSchema(propertiesMap, List.of( + "route_code", + "source_message", + "order_contexts", + "message_events")); } /** - * 构造 V3 source_message 的 MCP schema,保持和提交前 adapter 校验规则一致。 + * 构造 V4 source_message 的 MCP schema,保持和提交前 adapter 校验规则一致。 */ private Map sourceMessageSchema() { Map propertiesMap = new LinkedHashMap<>(); propertiesMap.put("source_message_id", stringField("外部来源消息 ID,对应 AgentBus source.external_message_id")); - propertiesMap.put("subject", nullableStringField("邮件主题")); - propertiesMap.put("from", nullableStringField("发件人摘要")); - propertiesMap.put("cc", Map.of("type", "array", "description", "抄送人列表", "items", Map.of("type", "string"))); - propertiesMap.put("received_at", nullableStringField("来源消息接收时间")); - propertiesMap.put("source_channel", Map.of("type", "string", "enum", List.of("Email"), "description", "来源渠道语义,固定 Email")); + propertiesMap.put("conversation_id", nullableStringField("外部邮件会话 ID,可为空")); + propertiesMap.put("subject", nullableStringField("邮件主题,可为空")); + propertiesMap.put("sender", nullableStringField("单一发件人值,可为空")); + propertiesMap.put("sent_at", nullableStringField("邮件发送 UTC 时间,可为空")); + propertiesMap.put("body", nullableStringField("当前邮件单一原文,可为空;不得由 Agent 摘要或改写")); + propertiesMap.put("body_content_type", Map.of( + "type", "string", + "enum", List.of("text/plain", "text/html"), + "description", "正文格式")); + propertiesMap.put("attachments", Map.of( + "type", "array", + "description", "包级附件数组;Payment 只通过 attachment_ids[] 引用这里的 id", + "items", attachmentSchema())); return objectSchema(propertiesMap, List.of( "source_message_id", "subject", - "from", - "cc", - "received_at", - "source_channel")); + "sender", + "sent_at", + "body", + "body_content_type", + "attachments")); } /** - * 构造 V3 message_events[] item 的 MCP schema,说明事件索引会由 adapter 统一映射。 + * 构造 V4 source_message.attachments[] item 的 MCP schema。 + */ + private Map attachmentSchema() { + Map propertiesMap = new LinkedHashMap<>(); + propertiesMap.put("id", stringField("包内稳定附件 ID")); + propertiesMap.put("name", stringField("原始文件名")); + propertiesMap.put("content_type", stringField("MIME 类型")); + propertiesMap.put("url", stringField("上游文件 URL;后端只用于匹配和原文权限链路,不在普通任务详情泄露")); + propertiesMap.put("size", Map.of("type", List.of("number", "null"), "description", "附件大小,上游无值可为 null")); + return objectSchema(propertiesMap, List.of( + "id", + "name", + "content_type", + "url")); + } + + /** + * 构造 V4 order_contexts[] item 的 MCP schema。 + */ + private Map orderContextSchema() { + Map propertiesMap = new LinkedHashMap<>(); + propertiesMap.put("order_ref", stringField("当前结果包内订单引用,不是 PMS key 或系统 ID")); + propertiesMap.put("basic_information", basicInformationSchema()); + return objectSchema(propertiesMap, List.of( + "order_ref", + "basic_information")); + } + + /** + * 构造 V4 Basic Information 的 MCP schema。 + */ + private Map basicInformationSchema() { + Map propertiesMap = new LinkedHashMap<>(); + propertiesMap.put("account_code", nullableStringField("信息系统 Account 目录 code;未解决时为 null")); + propertiesMap.put("manual_review", Map.of( + "type", List.of("boolean", "null"), + "description", "只能为 null 或 true;为 true 时必须能由 account_code 未解决解释")); + return objectSchema(propertiesMap, List.of( + "account_code", + "manual_review")); + } + + /** + * 构造 V4 message_events[] item 的 MCP schema,事件特有字段以 V4 字段契约为准。 */ private Map messageEventSchema() { Map propertiesMap = new LinkedHashMap<>(); propertiesMap.put("event_type", Map.of( "type", "string", - "enum", v3EventTypeSchemaValues(), - "description", "V3 业务事件类型;Need Manual Review 等业务契约问题由业务入站层落 adapter_contract_error")); - propertiesMap.put("event_role", stringField("V3 事件来源角色")); - propertiesMap.put("source_event_index", Map.of( - "type", List.of("string", "integer"), - "description", "Agent 业务事件 ID 或数字;MCP Adapter 会按 message_events[] 顺序映射为本系统一基数字索引")); - propertiesMap.put("current_or_history", stringField("第一版只接受 current")); - propertiesMap.put("case_keys", Map.of("type", "object", "description", "订单关联候选键")); - propertiesMap.put("relevant_message_excerpt", stringField("当前事件证据摘录")); - propertiesMap.put("attachments", Map.of("type", "array", "description", "当前事件附件引用", "items", Map.of("type", "object"))); - propertiesMap.put("file_references", Map.of("type", "array", "description", "当前事件文件引用", "items", Map.of("type", "object"))); - propertiesMap.put("context_used", Map.of("type", "object", "description", "当前事件使用的上下文")); - propertiesMap.put("extracted_fields", Map.of("type", "object", "description", "业务字段主体")); - propertiesMap.put("manual_review", Map.of("type", List.of("object", "null"), "description", "type-known manual review 对象")); - propertiesMap.put("related_source_event_index", nullableStringField("单事件关系引用,MCP Adapter 会映射为真实索引")); - propertiesMap.put("related_source_event_indices", Map.of( + "enum", List.of( + "NEW_BOOKING", + "UPDATE_BOOKING", + "CANCEL_BOOKING", + "TRACE_RESERVATION_NOTES", + "ROOMING_LIST", + "PAYMENT"), + "description", "M002 V4 业务事件类型")); + propertiesMap.put("order_ref", stringField("引用 order_contexts[].order_ref")); + propertiesMap.put("target_order", targetOrderSchema()); + propertiesMap.put("manual_review", Map.of( + "type", List.of("boolean", "null"), + "description", "只能为 null 或 true;字段或订单归属未解决时为 true")); + propertiesMap.put("arrival_date", nullableStringField("NEW_BOOKING 入住日期,酒店本地日期 yyyy-MM-dd")); + propertiesMap.put("departure_date", nullableStringField("NEW_BOOKING 离店日期,酒店本地日期 yyyy-MM-dd")); + propertiesMap.put("rate_code", nullableStringField("NEW_BOOKING 订单级 Rate Code;UPDATE_BOOKING 不允许输出")); + propertiesMap.put("booking_scenario", nullableStringField("NEW_BOOKING Group 场景:STANDARD 或 PROPOSAL")); + propertiesMap.put("guest_name", nullableStringField("NEW_BOOKING / UPDATE_BOOKING Fit 客人姓名")); + propertiesMap.put("room_items", Map.of( "type", "array", - "description", "多事件关系引用,MCP Adapter 会保持顺序、去重校验并映射为真实索引", - "items", Map.of("type", List.of("string", "integer")))); - propertiesMap.put("parent_source_event_index", Map.of( - "type", List.of("string", "integer", "null"), - "description", "父事件引用,MCP Adapter 会映射为真实索引")); - propertiesMap.put("linked_task_group_id", nullableStringField("联动任务组 ID")); - propertiesMap.put("blocked_until_parent_completed", Map.of("type", List.of("boolean", "null"), "description", "是否被父任务阻塞")); - propertiesMap.put("related_event_type", nullableStringField("关系目标事件类型")); - propertiesMap.put("relationship_type", nullableStringField("Parent split 等联动事件关系类型,MCP 只透传不派生业务语义")); - propertiesMap.put("requires_downstream_hard_validation", Map.of("type", List.of("boolean", "null"), "description", "是否要求下游硬校验")); - propertiesMap.put("contract_errors", Map.of("type", "array", "description", "Agent 暴露的契约错误", "items", Map.of("type", "object"))); - propertiesMap.put("missing_fields", Map.of("type", "array", "description", "根级缺失字段", "items", Map.of("type", "string"))); - return objectSchema(propertiesMap, List.of( + "description", "NEW_BOOKING 完整房型清单;UPDATE_BOOKING 使用 after.room_items", + "items", roomItemSchema())); + propertiesMap.put("after", updateAfterSchema()); + propertiesMap.put("trace_items", Map.of( + "type", "array", + "description", "TRACE_RESERVATION_NOTES 事项数组", + "items", traceItemSchema())); + propertiesMap.put("attachment_ids", Map.of( + "type", "array", + "description", "PAYMENT 附件 ID 数组,只能引用 source_message.attachments[].id", + "items", Map.of("type", "string"))); + return flexibleObjectSchema(propertiesMap, List.of( + "order_ref", "event_type", - "event_role", - "source_event_index", - "current_or_history", - "case_keys", - "relevant_message_excerpt", - "attachments", - "file_references", - "context_used", - "extracted_fields", + "target_order", "manual_review")); } /** - * 返回 V3 event_type schema 允许值;Need Manual Review 是人工复核兜底事件,不属于 activeCodes 但业务层支持。 + * 构造 V4 room_items[] item 的 MCP schema。 */ - private List v3EventTypeSchemaValues() { - List values = new ArrayList<>(ReservationAiV3EventType.activeCodes()); - values.add("Need Manual Review"); - return values.stream().sorted().toList(); + private Map roomItemSchema() { + Map propertiesMap = new LinkedHashMap<>(); + propertiesMap.put("room_type_code", nullableStringField("当前酒店 Room Type 目录 code,未解决时为 null")); + propertiesMap.put("room_count", Map.of( + "type", List.of("integer", "null"), + "description", "房量,正常为正整数,未解决时为 null")); + return objectSchema(propertiesMap, List.of( + "room_type_code", + "room_count")); } /** - * 构造 V2 兼容 ai_task_results[] item 的 MCP schema。 + * 构造 V4 UPDATE_BOOKING after 稀疏对象 schema。 */ - private Map legacyV2TaskResultItemSchema() { + private Map updateAfterSchema() { Map propertiesMap = new LinkedHashMap<>(); - propertiesMap.put("source_event_index", Map.of( - "type", List.of("integer", "string"), - "description", "V2 兼容事件序号,必须非空")); - propertiesMap.put("catalog_code", stringField("Skill 目录代码")); - propertiesMap.put("skill_id", stringField("Skill 标识")); - propertiesMap.put("result_type", Map.of( + propertiesMap.put("guest_name", nullableStringField("Fit Name 更新值")); + propertiesMap.put("arrival_date", nullableStringField("修改后入住日期")); + propertiesMap.put("departure_date", nullableStringField("修改后离店日期")); + propertiesMap.put("room_items", Map.of( + "type", List.of("array", "null"), + "description", "修改后的完整房型清单;无法形成完整清单时为 null 且 manual_review=true", + "items", roomItemSchema())); + return flexibleObjectSchema(propertiesMap, List.of()); + } + + /** + * 构造 V4 trace_items[] item 的 MCP schema。 + */ + private Map traceItemSchema() { + Map propertiesMap = new LinkedHashMap<>(); + propertiesMap.put("item_type", Map.of( "type", "string", - "enum", List.of("normal_task", "manual_review", "informational_message"), - "description", "V2 兼容结果类型,informational_message 仅历史兼容")); - propertiesMap.put("task_type", stringField("AI 原始任务类型")); - propertiesMap.put("task_subtype", nullableStringField("业务动作 subtype")); - propertiesMap.put("current_or_history", nullableStringField("current/history 标记")); - propertiesMap.put("case_keys", Map.of("type", "object", "description", "订单关联候选键")); - propertiesMap.put("visible_reason", nullableStringField("前端可见原因")); - propertiesMap.put("relevant_message_excerpt", nullableStringField("当前任务证据摘录")); - propertiesMap.put("attachments", Map.of("type", "array", "description", "附件引用", "items", Map.of("type", "object"))); - propertiesMap.put("file_references", Map.of("type", "array", "description", "文件引用", "items", Map.of("type", "object"))); - propertiesMap.put("context_used", Map.of("type", "object", "description", "使用的上下文")); - propertiesMap.put("extracted_fields", Map.of("type", "object", "description", "业务字段主体")); - propertiesMap.put("manual_review", Map.of("type", List.of("object", "null"), "description", "人工复核信息")); - propertiesMap.put("informational_message", Map.of("type", List.of("object", "null"), "description", "历史信息类消息兼容字段")); - propertiesMap.put("additional_operations", Map.of("type", "array", "description", "补充操作", "items", Map.of("type", "object"))); - propertiesMap.put("idempotency_key", nullableStringField("调用方幂等键,通常为空")); + "enum", List.of("GENERAL", "EXTRA_BED"), + "description", "Trace item 类型")); + propertiesMap.put("text", nullableStringField("GENERAL 普通备注内容;不使用 content")); + propertiesMap.put("department_code", Map.of( + "type", "string", + "enum", List.of("FO", "HSK", "FO+HSK"), + "description", "第一版固定部门 code")); + propertiesMap.put("target_room_type_code", nullableStringField("EXTRA_BED 加床目标房型 code")); + propertiesMap.put("extra_bed_room_count", Map.of( + "type", List.of("integer", "null"), + "description", "EXTRA_BED 加床房间数量,正常为正整数")); + return flexibleObjectSchema(propertiesMap, List.of( + "item_type", + "department_code")); + } + + /** + * 构造 V4 target_order 的 MCP schema。 + */ + private Map targetOrderSchema() { + Map propertiesMap = new LinkedHashMap<>(); + propertiesMap.put("booking_type", nullableStringField("GROUP 或 FIT,未解决时为 null")); + propertiesMap.put("locator_type", nullableStringField("GROUP_CODE、BOOKING_CODE 或 CONFIRMATION_NUMBER,未解决时为 null")); + propertiesMap.put("locator_value", nullableStringField("定位值,未解决时为 null")); return objectSchema(propertiesMap, List.of( - "source_event_index", - "catalog_code", - "skill_id", - "result_type", - "task_type")); + "booking_type", + "locator_type", + "locator_value")); } private Map objectSchema(Map propertiesMap, List required) { @@ -581,6 +607,14 @@ public class SuperAgentMcpServiceImpl implements SuperAgentMcpService { "required", required); } + private Map flexibleObjectSchema(Map propertiesMap, List required) { + return Map.of( + "type", "object", + "additionalProperties", true, + "properties", propertiesMap, + "required", required); + } + private Map stringField(String description) { return Map.of("type", "string", "description", description); } diff --git a/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/service/impl/SuperAgentMcpSubmitPayloadAdapterImpl.java b/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/service/impl/SuperAgentMcpSubmitPayloadAdapterImpl.java index 50c0b31..8188455 100644 --- a/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/service/impl/SuperAgentMcpSubmitPayloadAdapterImpl.java +++ b/server/src/main/java/cn/nianxx/thhotel/integrations/mcp/superagent/service/impl/SuperAgentMcpSubmitPayloadAdapterImpl.java @@ -3,350 +3,145 @@ package cn.nianxx.thhotel.integrations.mcp.superagent.service.impl; import cn.nianxx.thhotel.integrations.mcp.superagent.common.dto.SuperAgentMcpSubmitPayloadAdaptation; import cn.nianxx.thhotel.integrations.mcp.superagent.service.SuperAgentMcpSubmitPayloadAdapter; 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.util.ArrayList; import java.util.Iterator; -import java.util.LinkedHashMap; -import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Set; import org.springframework.stereotype.Service; /** - * SuperAgent MCP 写入工具 payload 适配实现。该类只处理 transport 映射,不重新解释业务语义。 + * SuperAgent MCP 写入工具 payload 适配实现。M002 V4 起 MCP submit 只接受 V4 包级契约,不再兼容 V2/V3。 */ @Service public class SuperAgentMcpSubmitPayloadAdapterImpl implements SuperAgentMcpSubmitPayloadAdapter { private static final String ERROR_CODE = "MCP_SUBMIT_PAYLOAD_INVALID"; - private static final Set V3_BUSINESS_ROOT_FIELDS = Set.of( + private static final String V4_REQUIRED_ERROR_CODE = "MCP_SUBMIT_V4_REQUIRED"; + private static final Set V4_ROOT_FIELDS = Set.of( + "route_code", "source_message", - "message_events", - "case_candidates", - "extraction_warnings", - "unhandled_current_intents", - "hotel_id", - "source_provider", - "source_channel"); - private static final Set V3_TRANSPORT_COMPATIBILITY_ROOT_FIELDS = Set.of( - "hotel_id", - "source_provider", - "source_channel"); - private static final Set V3_SOURCE_MESSAGE_FIELDS = Set.of( + "order_contexts", + "message_events"); + private static final List V4_REQUIRED_ROOT_FIELDS = List.of( + "route_code", + "source_message", + "order_contexts", + "message_events"); + private static final Set V4_SOURCE_MESSAGE_FIELDS = Set.of( + "source_message_id", + "conversation_id", + "subject", + "sender", + "sent_at", + "body", + "body_content_type", + "attachments"); + private static final List V4_SOURCE_MESSAGE_REQUIRED_FIELDS = List.of( "source_message_id", "subject", - "from", - "cc", - "received_at", - "source_channel"); - private static final List V3_SOURCE_MESSAGE_REQUIRED_FIELDS = List.of( - "source_message_id", - "subject", - "from", - "cc", - "received_at", - "source_channel"); - private static final Set V3_EVENT_FIELDS = Set.of( - "event_type", - "event_role", - "source_event_index", - "current_or_history", - "case_keys", - "relevant_message_excerpt", - "attachments", - "file_references", - "context_used", - "extracted_fields", - "manual_review", - "related_event_type", - "requires_downstream_hard_validation", - "related_source_event_index", - "related_source_event_indices", - "parent_source_event_index", - "linked_task_group_id", - "blocked_until_parent_completed", - "contract_errors", - "missing_fields", - "relationship_type"); - private static final List V3_EVENT_TRANSPORT_REQUIRED_FIELDS = List.of( - "source_event_index"); - private static final Set V3_NOTIFICATION_ROOT_FIELDS = Set.of( - "source_message", - "route_code", - "handler_type", - "result_type", - "current_or_history", - "agent_assessment", - "notification", - "manual_review", - "hotel_id", - "source_provider", - "source_channel"); - private static final List V3_NOTIFICATION_REQUIRED_FIELDS = List.of( - "source_message", - "route_code", - "handler_type", - "result_type", - "current_or_history", - "agent_assessment", - "notification", - "manual_review"); - private static final Set LEGACY_V2_ROOT_FIELDS = Set.of( - "hotel_id", - "source_provider", - "source_channel", - "source_message_id", - "ai_task_results", - "extraction_warnings"); - private static final Set LEGACY_V2_ITEM_FIELDS = Set.of( - "source_event_index", - "catalog_code", - "skill_id", - "result_type", - "task_type", - "task_subtype", - "current_or_history", - "case_keys", - "visible_reason", - "relevant_message_excerpt", - "attachments", - "file_references", - "context_used", - "extracted_fields", - "manual_review", - "informational_message", - "additional_operations", - "idempotency_key"); - private static final List LEGACY_V2_ITEM_REQUIRED_FIELDS = List.of( - "source_event_index", - "catalog_code", - "skill_id", - "result_type", - "task_type"); - private static final Set LEGACY_V2_RESULT_TYPES = Set.of( - "normal_task", - "manual_review", - "informational_message"); - private static final List REQUIRED_V3_ROOT_ARRAY_FIELDS = List.of( - "message_events", - "case_candidates", - "extraction_warnings", - "unhandled_current_intents"); - - private final ObjectMapper objectMapper; + "sender", + "sent_at", + "body", + "body_content_type", + "attachments"); + private static final Set V4_ATTACHMENT_FIELDS = Set.of( + "id", + "name", + "content_type", + "url", + "size"); + private static final List V4_ATTACHMENT_REQUIRED_FIELDS = List.of( + "id", + "name", + "content_type", + "url"); /** - * 注入 JSON 工具,用于深拷贝和构造归一化 payload。 - */ - public SuperAgentMcpSubmitPayloadAdapterImpl(ObjectMapper objectMapper) { - this.objectMapper = objectMapper; - } - - /** - * 根据 submit arguments 的根结构选择 V3 业务根、S10/S99 通知或 V2 兼容映射。 + * 校验 MCP submit arguments 是否为 V4 包级结构;通过后原样交给 V4 入站服务。 */ @Override public SuperAgentMcpSubmitPayloadAdaptation adapt(JsonNode arguments) { if (arguments == null || !arguments.isObject()) { - throw invalid("MCP submit arguments 必须是 JSON object。", "arguments"); + throw v4Required("MCP submit arguments 必须是 M002 V4 JSON object。"); } - if (isV3BusinessRoot(arguments)) { - if (isV3SourceMessageIdentityMissing(arguments)) { - return new SuperAgentMcpSubmitPayloadAdaptation(arguments, Map.of()); - } - return adaptV3BusinessRoot(arguments); + if (!isV4Root(arguments)) { + throw v4Required("MCP submit 只接受 M002 V4 任务结果结构,请使用 route_code + source_message + order_contexts[] + message_events[]。"); } - if (isV3SourceMessageNotification(arguments)) { - if (isV3SourceMessageIdentityMissing(arguments)) { - return new SuperAgentMcpSubmitPayloadAdaptation(arguments, Map.of()); - } - validateRootFields(arguments, V3_NOTIFICATION_ROOT_FIELDS); - validateRequiredFields(arguments, V3_NOTIFICATION_REQUIRED_FIELDS); - validateSourceMessage(arguments.path("source_message")); - return new SuperAgentMcpSubmitPayloadAdaptation(removeCompatibilityFields(arguments), Map.of()); + validateRootFields(arguments, V4_ROOT_FIELDS); + validateSourceMessage(arguments.path("source_message")); + if (!isStringOrNull(arguments.path("route_code"))) { + throw invalid("route_code 必须是字符串或 null。", "route_code"); } - if (arguments.has("ai_task_results")) { - validateRootFields(arguments, LEGACY_V2_ROOT_FIELDS); - if (!hasText(arguments.path("source_message_id"))) { - throw invalid("V2 兼容 payload 缺少 source_message_id。", "source_message_id"); - } - if (!arguments.path("ai_task_results").isArray()) { - throw invalid("ai_task_results 必须是数组。", "ai_task_results"); - } - validateLegacyV2Items(arguments.path("ai_task_results")); - validateLegacyExtractionWarnings(arguments); - return new SuperAgentMcpSubmitPayloadAdaptation(arguments, Map.of()); + if (!arguments.path("order_contexts").isArray()) { + throw invalid("order_contexts 必须是数组。", "order_contexts"); } - throw invalid("MCP submit arguments 不是支持的 V3 或 V2 任务结果结构。", "arguments"); + if (!arguments.path("message_events").isArray()) { + throw invalid("message_events 必须是数组。", "message_events"); + } + return new SuperAgentMcpSubmitPayloadAdaptation(arguments, Map.of()); } /** - * 校验并转换 V3 业务根,将业务事件 ID 映射为本系统一基 source_event_index。 + * 判断 arguments 是否具备 V4 包级必填字段;旧 V2/V3 结构会在这里被拒绝。 */ - private SuperAgentMcpSubmitPayloadAdaptation adaptV3BusinessRoot(JsonNode root) { - validateRootFields(root, V3_BUSINESS_ROOT_FIELDS); - validateSourceMessage(root.path("source_message")); - for (String fieldName : REQUIRED_V3_ROOT_ARRAY_FIELDS) { - if (!root.path(fieldName).isArray()) { - throw invalid(fieldName + " 必须是数组。", fieldName); + private boolean isV4Root(JsonNode arguments) { + for (String requiredField : V4_REQUIRED_ROOT_FIELDS) { + if (!arguments.has(requiredField)) { + return false; } } - ArrayNode events = (ArrayNode) root.path("message_events"); - if (events.isEmpty()) { - return new SuperAgentMcpSubmitPayloadAdaptation(removeCompatibilityFields(root), Map.of()); - } - Map eventIndexMap = eventIndexMap(events); - ObjectNode mappedRoot = removeCompatibilityFields(root); - ArrayNode mappedEvents = (ArrayNode) mappedRoot.path("message_events"); - for (int index = 0; index < mappedEvents.size(); index++) { - JsonNode eventNode = mappedEvents.get(index); - if (!eventNode.isObject()) { - throw invalid("message_events[] item 必须是 object。", "message_events"); - } - ObjectNode event = (ObjectNode) eventNode; - validateRootFields(event, V3_EVENT_FIELDS); - validateRequiredFields(event, V3_EVENT_TRANSPORT_REQUIRED_FIELDS); - if (!hasText(event.path("source_event_index"))) { - throw invalid("message_events[].source_event_index 不能为空。", "source_event_index"); - } - event.put("source_event_index", index + 1); - mapSingleEventReference(event, eventIndexMap, "related_source_event_index", false); - mapSingleEventReference(event, eventIndexMap, "parent_source_event_index", true); - mapMultipleEventReferences(event, eventIndexMap); - } - return new SuperAgentMcpSubmitPayloadAdaptation( - mappedRoot, - Map.of( - "mapping_policy", "message_events_array_order_1_based", - "source_event_index_mapping", sourceEventIndexMapping(eventIndexMap))); + return true; } /** - * 构建业务事件 ID 到 MCP 一基索引的映射,重复或缺失立即拒绝。 - */ - private Map eventIndexMap(ArrayNode events) { - Map result = new LinkedHashMap<>(); - for (int index = 0; index < events.size(); index++) { - JsonNode event = events.get(index); - if (!event.isObject()) { - throw invalid("message_events[] item 必须是 object。", "message_events"); - } - validateRootFields(event, V3_EVENT_FIELDS); - validateRequiredFields(event, V3_EVENT_TRANSPORT_REQUIRED_FIELDS); - String rawIndex = referenceText(event.path("source_event_index")); - if (rawIndex == null) { - throw invalid("message_events[].source_event_index 不能为空。", "source_event_index"); - } - if (result.putIfAbsent(rawIndex, index + 1) != null) { - throw invalid("message_events[].source_event_index 不能重复。", "source_event_index"); - } - } - return result; - } - - /** - * 构造原始业务事件 ID 到本系统一基索引的映射诊断。 - */ - private List> sourceEventIndexMapping(Map eventIndexMap) { - List> result = new ArrayList<>(); - for (Map.Entry entry : eventIndexMap.entrySet()) { - Map mapping = new LinkedHashMap<>(); - mapping.put("original_source_event_index", entry.getKey()); - mapping.put("mapped_source_event_index", entry.getValue()); - result.add(mapping); - } - return result; - } - - /** - * 映射单事件关系字段;parent_source_event_index 按业务入站服务约定输出数字。 - */ - private void mapSingleEventReference( - ObjectNode event, - Map eventIndexMap, - String fieldName, - boolean numericOutput) { - JsonNode value = event.get(fieldName); - if (value == null || value.isNull()) { - return; - } - Integer mappedIndex = eventIndexMap.get(referenceText(value)); - if (mappedIndex == null) { - throw invalid(fieldName + " 引用了不存在的 source_event_index。", fieldName); - } - if (numericOutput) { - event.put(fieldName, mappedIndex); - } else { - event.put(fieldName, String.valueOf(mappedIndex)); - } - } - - /** - * 映射多事件关系字段,保持顺序并拒绝悬空或重复引用。 - */ - private void mapMultipleEventReferences(ObjectNode event, Map eventIndexMap) { - JsonNode indices = event.get("related_source_event_indices"); - if (indices == null || indices.isNull()) { - return; - } - if (!indices.isArray()) { - throw invalid("related_source_event_indices 必须是数组。", "related_source_event_indices"); - } - ArrayNode mapped = objectMapper.createArrayNode(); - Set seen = new LinkedHashSet<>(); - for (JsonNode item : indices) { - Integer mappedIndex = eventIndexMap.get(referenceText(item)); - if (mappedIndex == null) { - throw invalid("related_source_event_indices 引用了不存在的 source_event_index。", "related_source_event_indices"); - } - if (!seen.add(mappedIndex)) { - throw invalid("related_source_event_indices 不能包含重复引用。", "related_source_event_indices"); - } - mapped.add(String.valueOf(mappedIndex)); - } - event.set("related_source_event_indices", mapped); - } - - /** - * 校验 source_message 的最小形态,source_message_id 必须存在。 + * 校验 V4 source_message 最小结构,保证 MCP 层不继续接受 V3 from/received_at/source_channel 形态。 */ private void validateSourceMessage(JsonNode sourceMessage) { if (sourceMessage == null || !sourceMessage.isObject()) { throw invalid("source_message 必须是对象。", "source_message"); } - validateRootFields(sourceMessage, V3_SOURCE_MESSAGE_FIELDS); - validateRequiredFields(sourceMessage, V3_SOURCE_MESSAGE_REQUIRED_FIELDS); + validateRootFields(sourceMessage, V4_SOURCE_MESSAGE_FIELDS); + validateRequiredFields(sourceMessage, V4_SOURCE_MESSAGE_REQUIRED_FIELDS); if (!hasText(sourceMessage.path("source_message_id"))) { throw invalid("source_message.source_message_id 不能为空。", "source_message.source_message_id"); } - if (!isStringOrNull(sourceMessage.path("subject")) - || !isStringOrNull(sourceMessage.path("from")) - || !isStringArray(sourceMessage.path("cc")) - || !isStringOrNull(sourceMessage.path("received_at")) - || !"Email".equals(referenceText(sourceMessage.path("source_channel")))) { - throw invalid("source_message 结构不符合 0711 P0 契约。", "source_message"); + if (!isMissingStringOrNull(sourceMessage.path("conversation_id")) + || !isStringOrNull(sourceMessage.path("subject")) + || !isStringOrNull(sourceMessage.path("sender")) + || !isStringOrNull(sourceMessage.path("sent_at")) + || !isStringOrNull(sourceMessage.path("body")) + || !("text/plain".equals(referenceText(sourceMessage.path("body_content_type"))) + || "text/html".equals(referenceText(sourceMessage.path("body_content_type")))) + || !sourceMessage.path("attachments").isArray()) { + throw invalid("source_message 结构不符合 M002 V4 契约。", "source_message"); } + validateAttachments(sourceMessage.path("attachments")); } /** - * 校验 V2 兼容 item 完整结构,避免 MCP 调用方发送半结构化对象后才落到业务层失败。 + * 校验 V4 包级附件引用结构;URL 只校验存在,不在错误中回显。 */ - private void validateLegacyV2Items(JsonNode taskResults) { - for (JsonNode item : taskResults) { - if (item == null || !item.isObject()) { - throw invalid("ai_task_results[] item 必须是 object。", "ai_task_results"); + private void validateAttachments(JsonNode attachments) { + for (JsonNode attachment : attachments) { + if (attachment == null || !attachment.isObject()) { + throw invalid("source_message.attachments[] 必须是对象。", "source_message.attachments"); + } + validateRootFields(attachment, V4_ATTACHMENT_FIELDS); + validateRequiredFields(attachment, V4_ATTACHMENT_REQUIRED_FIELDS); + if (!hasText(attachment.path("id")) + || !hasText(attachment.path("name")) + || !hasText(attachment.path("content_type")) + || !hasText(attachment.path("url")) + || !(attachment.path("size").isMissingNode() + || attachment.path("size").isNull() + || attachment.path("size").isNumber())) { + throw invalid("source_message.attachments[] 结构不符合 M002 V4 契约。", "source_message.attachments"); } - validateRootFields(item, LEGACY_V2_ITEM_FIELDS); - validateLegacyV2RequiredFields(item); - validateLegacyV2Item((ObjectNode) item); } } /** - * 校验对象不存在未知字段,避免 Agent 猜测 transport 字段。 + * 校验对象不存在未知字段,避免 MCP 工具继续接受旧 V2/V3 transport 字段。 */ private void validateRootFields(JsonNode node, Set allowedFields) { if (node == null || !node.isObject()) { @@ -373,143 +168,7 @@ public class SuperAgentMcpSubmitPayloadAdapterImpl implements SuperAgentMcpSubmi } /** - * 校验 V2 兼容 item 的字段类型、允许值和常见子对象形态。 - */ - private void validateLegacyV2Item(ObjectNode item) { - if (!hasText(item.path("source_event_index"))) { - throw invalid("ai_task_results[].source_event_index 不能为空。", "ai_task_results[].source_event_index"); - } - validateText(item, "catalog_code"); - validateText(item, "skill_id"); - validateText(item, "result_type"); - validateText(item, "task_type"); - if (!LEGACY_V2_RESULT_TYPES.contains(referenceText(item.path("result_type")))) { - throw invalid("ai_task_results[].result_type 不在允许范围。", "ai_task_results[].result_type"); - } - validateOptionalText(item, "task_subtype"); - validateOptionalText(item, "current_or_history"); - validateOptionalText(item, "visible_reason"); - validateOptionalText(item, "relevant_message_excerpt"); - validateOptionalText(item, "idempotency_key"); - validateOptionalObject(item, "case_keys"); - validateOptionalObject(item, "context_used"); - validateOptionalObject(item, "extracted_fields"); - validateOptionalObjectOrNull(item, "manual_review"); - validateOptionalObjectOrNull(item, "informational_message"); - validateOptionalArray(item, "attachments"); - validateOptionalArray(item, "file_references"); - validateOptionalArray(item, "additional_operations"); - } - - /** - * 校验 V2 item 必填字段,并返回包含数组路径的错误字段名。 - */ - private void validateLegacyV2RequiredFields(JsonNode item) { - for (String requiredField : LEGACY_V2_ITEM_REQUIRED_FIELDS) { - if (!item.has(requiredField)) { - throw invalid( - "MCP submit payload 缺少必填字段。", - "ai_task_results[]." + requiredField); - } - } - } - - /** - * 校验 V2 根级 extraction_warnings,存在时必须是数组。 - */ - private void validateLegacyExtractionWarnings(JsonNode arguments) { - JsonNode extractionWarnings = arguments.get("extraction_warnings"); - if (extractionWarnings != null && !extractionWarnings.isArray()) { - throw invalid("extraction_warnings 必须是数组。", "extraction_warnings"); - } - } - - /** - * 校验 V2 必填文本字段。 - */ - private void validateText(ObjectNode item, String fieldName) { - if (!hasText(item.path(fieldName))) { - throw invalid("ai_task_results[]." + fieldName + " 不能为空。", "ai_task_results[]." + fieldName); - } - } - - /** - * 校验 V2 可选文本字段。 - */ - private void validateOptionalText(ObjectNode item, String fieldName) { - JsonNode value = item.get(fieldName); - if (value != null && !isStringOrNull(value)) { - throw invalid("ai_task_results[]." + fieldName + " 必须是字符串或 null。", "ai_task_results[]." + fieldName); - } - } - - /** - * 校验 V2 可选对象字段。 - */ - private void validateOptionalObject(ObjectNode item, String fieldName) { - JsonNode value = item.get(fieldName); - if (value != null && !value.isObject()) { - throw invalid("ai_task_results[]." + fieldName + " 必须是 object。", "ai_task_results[]." + fieldName); - } - } - - /** - * 校验 V2 可选对象或 null 字段。 - */ - private void validateOptionalObjectOrNull(ObjectNode item, String fieldName) { - JsonNode value = item.get(fieldName); - if (value != null && !value.isObject() && !value.isNull()) { - throw invalid("ai_task_results[]." + fieldName + " 必须是 object 或 null。", "ai_task_results[]." + fieldName); - } - } - - /** - * 校验 V2 可选数组字段。 - */ - private void validateOptionalArray(ObjectNode item, String fieldName) { - JsonNode value = item.get(fieldName); - if (value != null && !value.isArray()) { - throw invalid("ai_task_results[]." + fieldName + " 必须是数组。", "ai_task_results[]." + fieldName); - } - } - - /** - * 判断是否为 V3 业务根。 - */ - private boolean isV3BusinessRoot(JsonNode arguments) { - return arguments.has("message_events"); - } - - /** - * 判断是否为 V3 S10/S99 入口通知。 - */ - private boolean isV3SourceMessageNotification(JsonNode arguments) { - return arguments.has("route_code") || arguments.has("result_type"); - } - - /** - * source_message_id 缺失由业务入站服务返回既有 typed infrastructure error,MCP adapter 不改写错误通道。 - */ - private boolean isV3SourceMessageIdentityMissing(JsonNode arguments) { - JsonNode sourceMessage = arguments.get("source_message"); - return sourceMessage == null - || !sourceMessage.isObject() - || !hasText(sourceMessage.path("source_message_id")); - } - - /** - * 移除 MCP transport 兼容字段,避免进入业务入站 exact root 校验。 - */ - private ObjectNode removeCompatibilityFields(JsonNode root) { - ObjectNode copy = root.deepCopy(); - for (String fieldName : V3_TRANSPORT_COMPATIBILITY_ROOT_FIELDS) { - copy.remove(fieldName); - } - return copy; - } - - /** - * 提取事件引用文本,数字和字符串都统一成字符串键。 + * 提取 JSON 值的稳定文本,空白字符串按 null 处理。 */ private String referenceText(JsonNode node) { if (node == null || node.isNull()) { @@ -534,18 +193,10 @@ public class SuperAgentMcpSubmitPayloadAdapterImpl implements SuperAgentMcpSubmi } /** - * 判断数组是否只包含字符串。 + * 判断可选字段是否为缺省、字符串或 null。 */ - private boolean isStringArray(JsonNode node) { - if (node == null || !node.isArray()) { - return false; - } - for (JsonNode item : node) { - if (!item.isTextual()) { - return false; - } - } - return true; + private boolean isMissingStringOrNull(JsonNode node) { + return node != null && (node.isMissingNode() || node.isTextual() || node.isNull()); } /** @@ -554,4 +205,11 @@ public class SuperAgentMcpSubmitPayloadAdapterImpl implements SuperAgentMcpSubmi private SuperAgentMcpSubmitPayloadException invalid(String message, String field) { return new SuperAgentMcpSubmitPayloadException(ERROR_CODE, message, field); } + + /** + * 构造 V4-only 契约拒绝异常,避免旧 V2/V3 payload 继续进入业务写入层。 + */ + private SuperAgentMcpSubmitPayloadException v4Required(String message) { + return new SuperAgentMcpSubmitPayloadException(V4_REQUIRED_ERROR_CODE, message, "arguments"); + } } diff --git a/server/src/test/java/cn/nianxx/thhotel/integrations/mcp/superagent/control/SuperAgentMcpControllerTest.java b/server/src/test/java/cn/nianxx/thhotel/integrations/mcp/superagent/control/SuperAgentMcpControllerTest.java index 4c0dc74..d21c3c3 100644 --- a/server/src/test/java/cn/nianxx/thhotel/integrations/mcp/superagent/control/SuperAgentMcpControllerTest.java +++ b/server/src/test/java/cn/nianxx/thhotel/integrations/mcp/superagent/control/SuperAgentMcpControllerTest.java @@ -130,23 +130,32 @@ 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("route_code")) + .andExpect(jsonPath("$.result.tools[4].inputSchema.required[1]").value("source_message")) + .andExpect(jsonPath("$.result.tools[4].inputSchema.required[2]").value("order_contexts")) + .andExpect(jsonPath("$.result.tools[4].inputSchema.required[3]").value("message_events")) .andExpect(jsonPath("$.result.tools[4].inputSchema.properties.source_message").exists()) + .andExpect(jsonPath("$.result.tools[4].inputSchema.properties.source_message.required.length()").value(7)) + .andExpect(jsonPath("$.result.tools[4].inputSchema.properties.source_message.required[0]").value("source_message_id")) + .andExpect(jsonPath("$.result.tools[4].inputSchema.properties.source_message.required[1]").value("subject")) + .andExpect(jsonPath("$.result.tools[4].inputSchema.properties.source_message.required[2]").value("sender")) + .andExpect(jsonPath("$.result.tools[4].inputSchema.properties.source_message.required[3]").value("sent_at")) + .andExpect(jsonPath("$.result.tools[4].inputSchema.properties.source_message.required[4]").value("body")) + .andExpect(jsonPath("$.result.tools[4].inputSchema.properties.source_message.required[5]").value("body_content_type")) + .andExpect(jsonPath("$.result.tools[4].inputSchema.properties.source_message.required[6]").value("attachments")) + .andExpect(jsonPath("$.result.tools[4].inputSchema.properties.source_message.properties.sender").exists()) + .andExpect(jsonPath("$.result.tools[4].inputSchema.properties.source_message.properties.sent_at").exists()) + .andExpect(jsonPath("$.result.tools[4].inputSchema.properties.source_message.properties.body").exists()) + .andExpect(jsonPath("$.result.tools[4].inputSchema.properties.source_message.properties.body_content_type").exists()) + .andExpect(jsonPath("$.result.tools[4].inputSchema.properties.source_message.properties.attachments").exists()) + .andExpect(jsonPath("$.result.tools[4].inputSchema.properties.source_message.properties.from").doesNotExist()) + .andExpect(jsonPath("$.result.tools[4].inputSchema.properties.source_message.properties.received_at").doesNotExist()) + .andExpect(jsonPath("$.result.tools[4].inputSchema.properties.order_contexts").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") - .value(containsString("OUTLOOK"))) + .andExpect(jsonPath("$.result.tools[4].inputSchema.properties.ai_task_results").doesNotExist()) + .andExpect(jsonPath("$.result.tools[4].inputSchema.properties.source_provider").doesNotExist()) + .andExpect(jsonPath("$.result.tools[4].inputSchema.properties.source_channel").doesNotExist()) + .andExpect(jsonPath("$.result.tools[4].inputSchema.oneOf").doesNotExist()) .andExpect(jsonPath("$.result.tools[4].annotations.readOnlyHint").value(false)) .andExpect(jsonPath("$.result.tools[4].annotations.destructiveHint").value(true)); } diff --git a/server/src/test/java/cn/nianxx/thhotel/integrations/mcp/superagent/control/SuperAgentMcpSubmitEnabledControllerTest.java b/server/src/test/java/cn/nianxx/thhotel/integrations/mcp/superagent/control/SuperAgentMcpSubmitEnabledControllerTest.java index 3a29977..6c70705 100644 --- a/server/src/test/java/cn/nianxx/thhotel/integrations/mcp/superagent/control/SuperAgentMcpSubmitEnabledControllerTest.java +++ b/server/src/test/java/cn/nianxx/thhotel/integrations/mcp/superagent/control/SuperAgentMcpSubmitEnabledControllerTest.java @@ -1,10 +1,7 @@ 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.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; @@ -14,8 +11,6 @@ import cn.nianxx.thhotel.platform.message.common.result.SourceMessageCaptureResu 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 java.util.Map; @@ -27,7 +22,6 @@ 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, @@ -57,393 +51,199 @@ class SuperAgentMcpSubmitEnabledControllerTest { private ObjectMapper objectMapper; @Test - void shouldDelegateSubmitTaskResultsToolWhenWriteToolEnabled() throws Exception { - String body = """ - { - "jsonrpc": "2.0", - "id": "mcp-submit-enabled-001", - "method": "tools/call", - "params": { - "name": "th_hotel_submit_task_results", - "arguments": { - "source_message_id": "mail-mcp-enabled-missing-001", - "ai_task_results": [ - { - "source_event_index": 1, - "catalog_code": "S01", - "skill_id": "S01_new_booking_skill", - "result_type": "normal_task", - "task_type": "NEW_BOOKING" - } - ] - } - } - } - """; + void shouldSubmitV4BusinessRootAndCreateV4ModelOnly() throws Exception { + String externalId = "mail-mcp-v4-business-001"; + SourceMessageCaptureResult source = captureSourceMessage(externalId); mockMvc.perform(post(ENDPOINT) .contentType(MediaType.APPLICATION_JSON) .header("Authorization", AUTHORIZATION) - .content(body)) + .content(toolCall("mcp-submit-v4-business-001", v4BusinessRoot(externalId)))) .andExpect(status().isOk()) - .andExpect(jsonPath("$.jsonrpc").value("2.0")) - .andExpect(jsonPath("$.id").value("mcp-submit-enabled-001")) - .andExpect(jsonPath("$.result.isError").value(true)) - .andExpect(jsonPath("$.result.structuredContent.error.code").value("SOURCE_MESSAGE_NOT_FOUND")) - .andExpect(content().string(not(containsString("MCP_TOOL_DISABLED")))); + .andExpect(jsonPath("$.result.isError").value(false)) + .andExpect(jsonPath("$.result.structuredContent.accepted_count").value(2)) + .andExpect(jsonPath("$.result.structuredContent.items[0].route_code").value("R02_NEW_GROUP_BLOCK_NORMAL")) + .andExpect(jsonPath("$.result.structuredContent.items[0].task_id").doesNotExist()) + .andExpect(jsonPath("$.result.structuredContent.items[1].route_code").value("R10_PAYMENT_EVIDENCE_NORMAL")) + .andExpect(jsonPath("$.result.structuredContent.items[1].task_id").doesNotExist()); + + assertNoLegacyTaskRows(source.inboxId()); + Long orderTaskCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM workflow_reservation_v4_order_task + WHERE source_message_id = ? + AND order_ref = 'order-1' + """, Long.class, source.inboxId()); + assertThat(orderTaskCount).isEqualTo(1L); + + List cardTypes = jdbcTemplate.queryForList(""" + SELECT card_type + FROM workflow_reservation_v4_task_card + WHERE source_message_id = ? + ORDER BY card_sort_order, source_event_index + """, String.class, source.inboxId()); + assertThat(cardTypes).containsExactly( + "SOURCE_MESSAGE_DISPLAY", + "BASIC_INFORMATION", + "ROOM_INFORMATION", + "PAYMENT"); Map diagnostic = jdbcTemplate.queryForMap(""" SELECT call_status, - tool_name, source_message_external_id, - arguments_json, adapted_payload_json, - safe_error_code, - safe_error_summary, - response_summary_json + mapping_diagnostics_json FROM platform_superagent_mcp_call_diagnostic WHERE jsonrpc_id = ? - """, "mcp-submit-enabled-001"); - assertThat(diagnostic.get("call_status")).isEqualTo("FAILED"); - assertThat(diagnostic.get("tool_name")).isEqualTo("th_hotel_submit_task_results"); - assertThat(diagnostic.get("source_message_external_id")).isEqualTo("mail-mcp-enabled-missing-001"); - assertThat(diagnostic.get("safe_error_code")).isEqualTo("SOURCE_MESSAGE_NOT_FOUND"); - assertThat((String) diagnostic.get("safe_error_summary")).contains("SourceMessage 不存在"); - - JsonNode arguments = objectMapper.readTree((String) diagnostic.get("arguments_json")); - assertThat(arguments.path("source_message_id").asText()).isEqualTo("mail-mcp-enabled-missing-001"); + """, "mcp-submit-v4-business-001"); + assertThat(diagnostic.get("call_status")).isEqualTo("SUCCEEDED"); + assertThat(diagnostic.get("source_message_external_id")).isEqualTo(externalId); JsonNode adaptedPayload = objectMapper.readTree((String) diagnostic.get("adapted_payload_json")); - assertThat(adaptedPayload.path("source_message_id").asText()).isEqualTo("mail-mcp-enabled-missing-001"); - JsonNode responseSummary = objectMapper.readTree((String) diagnostic.get("response_summary_json")); - assertThat(responseSummary.path("tool_is_error").asBoolean()).isTrue(); - assertThat(responseSummary.path("error_code").asText()).isEqualTo("SOURCE_MESSAGE_NOT_FOUND"); + assertThat(adaptedPayload.path("order_contexts").isArray()).isTrue(); + assertThat(adaptedPayload.has("ai_task_results")).isFalse(); + assertThat(objectMapper.readTree((String) diagnostic.get("mapping_diagnostics_json")).isEmpty()).isTrue(); } @Test - void shouldRejectUnknownV3RootFieldBeforeDelegatingSubmit() throws Exception { - String externalId = "mail-mcp-v3-unknown-root-001"; + void shouldSubmitV4BusinessRootWithoutConversationId() throws Exception { + String externalId = "mail-mcp-v4-no-conversation-001"; SourceMessageCaptureResult source = captureSourceMessage(externalId); - String businessRoot = parentSplitBusinessRoot(externalId) - .replace("\"unhandled_current_intents\": []", "\"unhandled_current_intents\": [], \"unexpected_root\": true"); + String businessRoot = v4BusinessRoot(externalId) + .replace(" \"conversation_id\": \"thread-mcp-v4-business-001\",\\n", ""); mockMvc.perform(post(ENDPOINT) .contentType(MediaType.APPLICATION_JSON) .header("Authorization", AUTHORIZATION) - .content(toolCall("mcp-submit-v3-unknown-root-001", businessRoot))) + .content(toolCall("mcp-submit-v4-no-conversation-001", businessRoot))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.result.isError").value(false)) + .andExpect(jsonPath("$.result.structuredContent.accepted_count").value(2)); + + assertNoLegacyTaskRows(source.inboxId()); + Long orderTaskCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM workflow_reservation_v4_order_task + WHERE source_message_id = ? + AND order_ref = 'order-1' + """, Long.class, source.inboxId()); + assertThat(orderTaskCount).isEqualTo(1L); + } + + @Test + void shouldSubmitV4S99NotificationAndCreateSourceNotificationOnly() throws Exception { + String externalId = "mail-mcp-v4-s99-001"; + SourceMessageCaptureResult source = captureSourceMessage(externalId); + + mockMvc.perform(post(ENDPOINT) + .contentType(MediaType.APPLICATION_JSON) + .header("Authorization", AUTHORIZATION) + .content(toolCall("mcp-submit-v4-s99-001", v4S99Root(externalId)))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.result.isError").value(false)) + .andExpect(jsonPath("$.result.structuredContent.accepted_count").value(1)) + .andExpect(jsonPath("$.result.structuredContent.items[0].route_code").value("S99")) + .andExpect(jsonPath("$.result.structuredContent.items[0].system_process_category") + .value("SOURCE_MESSAGE_NOTIFICATION")) + .andExpect(jsonPath("$.result.structuredContent.items[0].task_id").doesNotExist()); + + assertNoLegacyTaskRows(source.inboxId()); + Long notificationCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM workflow_reservation_v4_source_notification + WHERE source_message_id = ? + AND route_code = 'S99' + AND notification_status = 'ACK_REQUIRED' + """, Long.class, source.inboxId()); + assertThat(notificationCount).isEqualTo(1L); + } + + @Test + void shouldRejectUnknownV4RootFieldBeforeDelegatingSubmit() throws Exception { + String externalId = "mail-mcp-v4-unknown-root-001"; + SourceMessageCaptureResult source = captureSourceMessage(externalId); + String businessRoot = v4BusinessRoot(externalId) + .replace("\"message_events\": [", "\"unexpected_root\": true,\n \"message_events\": ["); + + mockMvc.perform(post(ENDPOINT) + .contentType(MediaType.APPLICATION_JSON) + .header("Authorization", AUTHORIZATION) + .content(toolCall("mcp-submit-v4-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()); + assertNoBusinessRows(source.inboxId()); + assertRejectedDiagnostic("mcp-submit-v4-unknown-root-001", "MCP_SUBMIT_PAYLOAD_INVALID"); } @Test - void shouldKeepInfrastructureErrorWhenV3SourceMessageIdMissing() throws Exception { - String businessRoot = parentSplitBusinessRootWithoutSourceMessageId("mail-mcp-v3-missing-source-id-001"); + void shouldRejectV3BusinessRootBeforeDelegatingSubmit() throws Exception { + String externalId = "mail-mcp-v3-rejected-001"; + SourceMessageCaptureResult source = captureSourceMessage(externalId); mockMvc.perform(post(ENDPOINT) .contentType(MediaType.APPLICATION_JSON) .header("Authorization", AUTHORIZATION) - .content(toolCall("mcp-submit-v3-missing-source-id-001", businessRoot))) + .content(toolCall("mcp-submit-v3-rejected-001", v3BusinessRoot(externalId)))) .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)); + .andExpect(jsonPath("$.result.structuredContent.error.code").value("MCP_SUBMIT_V4_REQUIRED")) + .andExpect(jsonPath("$.result.structuredContent.error.details.field").value("arguments")); + + assertNoBusinessRows(source.inboxId()); + assertRejectedDiagnostic("mcp-submit-v3-rejected-001", "MCP_SUBMIT_V4_REQUIRED"); } @Test - void shouldKeepInfrastructureErrorWhenV3SourceMessageMissing() throws Exception { - String businessRoot = parentSplitBusinessRootWithoutSourceMessage("mail-mcp-v3-missing-source-object-001"); + void shouldRejectV3S99NotificationBeforeDelegatingSubmit() throws Exception { + String externalId = "mail-mcp-v3-s99-rejected-001"; + SourceMessageCaptureResult source = captureSourceMessage(externalId); mockMvc.perform(post(ENDPOINT) .contentType(MediaType.APPLICATION_JSON) .header("Authorization", AUTHORIZATION) - .content(toolCall("mcp-submit-v3-missing-source-object-001", businessRoot))) + .content(toolCall("mcp-submit-v3-s99-rejected-001", v3S99Root(externalId)))) .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)); + .andExpect(jsonPath("$.result.structuredContent.error.code").value("MCP_SUBMIT_V4_REQUIRED")) + .andExpect(jsonPath("$.result.structuredContent.error.details.field").value("arguments")); + + assertNoBusinessRows(source.inboxId()); + assertRejectedDiagnostic("mcp-submit-v3-s99-rejected-001", "MCP_SUBMIT_V4_REQUIRED"); } @Test - void shouldRejectIncompleteV2ItemBeforeDelegatingSubmit() throws Exception { - String externalId = "mail-mcp-v2-incomplete-item-001"; + void shouldRejectLegacyV2TaskResultsBeforeDelegatingSubmit() throws Exception { + String externalId = "mail-mcp-v2-rejected-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" - } - ] + "source_message_id": "%s", + "ai_task_results": [ + { + "source_event_index": 1, + "catalog_code": "S01", + "skill_id": "S01_new_booking_skill", + "result_type": "normal_task", + "task_type": "NEW_BOOKING" } - } + ] } """.formatted(externalId); mockMvc.perform(post(ENDPOINT) .contentType(MediaType.APPLICATION_JSON) .header("Authorization", AUTHORIZATION) - .content(body)) + .content(toolCall("mcp-submit-v2-rejected-001", 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")); + .andExpect(jsonPath("$.result.structuredContent.error.code").value("MCP_SUBMIT_V4_REQUIRED")) + .andExpect(jsonPath("$.result.structuredContent.error.details.field").value("arguments")); - assertNoReservationWorkflowRows(source.inboxId()); - } - - @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)) - .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(""" - 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(parentPayload.toString()).doesNotContain("mapping_diagnostics"); - assertThat(result.getResponse().getContentAsString()).contains("E_CHILD_1"); - } - - @Test - void shouldRecordSubmitArgumentsAndAdaptedPayloadForMcpDiagnostics() throws Exception { - String externalId = "mail-mcp-diagnostic-v3-001"; - captureSourceMessage(externalId); - - mockMvc.perform(post(ENDPOINT) - .contentType(MediaType.APPLICATION_JSON) - .header("Authorization", AUTHORIZATION) - .content(toolCall("mcp-diagnostic-v3-001", parentSplitBusinessRoot(externalId)))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.result.isError").value(false)); - - Map diagnostic = jdbcTemplate.queryForMap(""" - SELECT - call_status, - tool_name, - source_message_external_id, - arguments_json, - adapted_payload_json, - mapping_diagnostics_json, - response_summary_json - FROM platform_superagent_mcp_call_diagnostic - WHERE jsonrpc_id = ? - """, "mcp-diagnostic-v3-001"); - assertThat(diagnostic.get("call_status")).isEqualTo("SUCCEEDED"); - assertThat(diagnostic.get("tool_name")).isEqualTo("th_hotel_submit_task_results"); - assertThat(diagnostic.get("source_message_external_id")).isEqualTo(externalId); - - JsonNode arguments = objectMapper.readTree((String) diagnostic.get("arguments_json")); - assertThat(arguments.path("source_message").path("source_message_id").asText()).isEqualTo(externalId); - assertThat(arguments.path("message_events").get(0).path("source_event_index").asText()).isEqualTo("E_CHILD_1"); - - JsonNode adaptedPayload = objectMapper.readTree((String) diagnostic.get("adapted_payload_json")); - assertThat(adaptedPayload.path("source_message").path("source_message_id").asText()).isEqualTo(externalId); - assertThat(adaptedPayload.path("message_events").get(0).path("source_event_index").asInt()).isEqualTo(1); - - JsonNode mappingDiagnostics = objectMapper.readTree((String) diagnostic.get("mapping_diagnostics_json")); - assertThat(mappingDiagnostics.path("source_event_index_mapping").get(0).path("original_source_event_index").asText()) - .isEqualTo("E_CHILD_1"); - assertThat(mappingDiagnostics.path("source_event_index_mapping").get(0).path("mapped_source_event_index").asInt()) - .isEqualTo(1); - - JsonNode responseSummary = objectMapper.readTree((String) diagnostic.get("response_summary_json")); - assertThat(responseSummary.path("tool_is_error").asBoolean()).isFalse(); - assertThat(responseSummary.toString()).doesNotContain("MCP submit fixture source message"); - } - - @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")); + assertNoBusinessRows(source.inboxId()); + assertRejectedDiagnostic("mcp-submit-v2-rejected-001", "MCP_SUBMIT_V4_REQUIRED"); } private SourceMessageCaptureResult captureSourceMessage(String externalMessageId) { @@ -466,21 +266,64 @@ class SuperAgentMcpSubmitEnabledControllerTest { )); } - private void assertNoReservationWorkflowRows(Long sourceMessageId) { - Long batchCount = jdbcTemplate.queryForObject(""" - SELECT COUNT(*) - FROM workflow_reservation_ai_batch - WHERE source_message_id = ? - """, Long.class, sourceMessageId); + private void assertNoLegacyTaskRows(Long 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 void assertNoBusinessRows(Long sourceMessageId) { + Long batchCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM workflow_reservation_ai_batch + WHERE source_message_id = ? + """, Long.class, sourceMessageId); + Long legacyTaskCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM workflow_reservation_task + WHERE source_message_id = ? + """, Long.class, sourceMessageId); + Long v4OrderTaskCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM workflow_reservation_v4_order_task + WHERE source_message_id = ? + """, Long.class, sourceMessageId); + Long v4CardCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM workflow_reservation_v4_task_card + WHERE source_message_id = ? + """, Long.class, sourceMessageId); + Long notificationCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM workflow_reservation_v4_source_notification + WHERE source_message_id = ? + """, Long.class, sourceMessageId); + assertThat(batchCount).isZero(); + assertThat(legacyTaskCount).isZero(); + assertThat(v4OrderTaskCount).isZero(); + assertThat(v4CardCount).isZero(); + assertThat(notificationCount).isZero(); + } + + private void assertRejectedDiagnostic(String jsonrpcId, String errorCode) { + Map diagnostic = jdbcTemplate.queryForMap(""" + SELECT + call_status, + safe_error_code, + safe_error_summary, + adapted_payload_json + FROM platform_superagent_mcp_call_diagnostic + WHERE jsonrpc_id = ? + """, jsonrpcId); + assertThat(diagnostic.get("call_status")).isEqualTo("FAILED"); + assertThat(diagnostic.get("safe_error_code")).isEqualTo(errorCode); + assertThat((String) diagnostic.get("safe_error_summary")).contains("MCP submit"); + assertThat(diagnostic.get("adapted_payload_json")).isNull(); + } + private String toolCall(String id, String argumentsJson) { return """ { @@ -495,111 +338,114 @@ class SuperAgentMcpSubmitEnabledControllerTest { """.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 v4BusinessRoot(String externalMessageId) { + return """ + { + "route_code": null, + "source_message": { + "source_message_id": "%s", + "conversation_id": "thread-mcp-v4-business-001", + "subject": "Group booking and payment", + "sender": "agent@example.test", + "sent_at": "2026-07-18T02:10:00Z", + "body": "Please create group GRP-MCP-V4-001 and note payment attached.", + "body_content_type": "text/plain", + "attachments": [ + { + "id": "att-pay-1", + "name": "payment-slip.jpg", + "content_type": "image/jpeg", + "url": "https://oss.example.test/payment-slip.jpg", + "size": 251524 + } + ] + }, + "order_contexts": [ + { + "order_ref": "order-1", + "basic_information": { + "account_code": "QBD_TRAVEL", + "manual_review": null + } + } + ], + "message_events": [ + { + "order_ref": "order-1", + "event_type": "NEW_BOOKING", + "target_order": { + "booking_type": "GROUP", + "locator_type": "GROUP_CODE", + "locator_value": "GRP-MCP-V4-001" + }, + "arrival_date": "2026-07-26", + "departure_date": "2026-07-29", + "rate_code": "GRPA2-850UP", + "booking_scenario": "STANDARD", + "room_items": [ + { + "room_type_code": "RM2", + "room_count": 2 + } + ], + "manual_review": null + }, + { + "order_ref": "order-1", + "event_type": "PAYMENT", + "target_order": { + "booking_type": "GROUP", + "locator_type": "GROUP_CODE", + "locator_value": "GRP-MCP-V4-001" + }, + "attachment_ids": ["att-pay-1"], + "manual_review": null + } + ] + } + """.formatted(externalMessageId); } - 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 v4S99Root(String externalMessageId) { + return """ + { + "route_code": "S99", + "source_message": { + "source_message_id": "%s", + "conversation_id": "thread-mcp-v4-s99-001", + "subject": "Cannot form material package", + "sender": "guest@example.test", + "sent_at": "2026-07-18T02:10:00Z", + "body": "The input does not contain enough business material.", + "body_content_type": "text/plain", + "attachments": [] + }, + "order_contexts": [], + "message_events": [] + } + """.formatted(externalMessageId); } - 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) { + private String v3BusinessRoot(String externalMessageId) { return """ { "source_message": { "source_message_id": "%s", - "subject": "Informational message", + "subject": "Legacy V3 booking", "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 + "message_events": [], + "case_candidates": [], + "extraction_warnings": [], + "unhandled_current_intents": [] } """.formatted(externalMessageId); } - private String s99NotificationRoot(String externalMessageId) { + private String v3S99Root(String externalMessageId) { return """ { "source_message": { @@ -627,116 +473,9 @@ class SuperAgentMcpSubmitEnabledControllerTest { "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": {} + "reason_code": "material_package_unavailable" } } """.formatted(externalMessageId); } - - 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); - } }