From ec3b362aca1f9b27f60903e13b194bae7d36d445 Mon Sep 17 00:00:00 2001 From: huxiaohui <19946728049@163.com> Date: Fri, 28 Aug 2026 15:44:46 +0800 Subject: [PATCH 1/3] =?UTF-8?q?=F0=9F=90=9B=20fix(mcp):=20=E6=9F=A5?= =?UTF-8?q?=E8=AF=A2=E5=B7=A5=E5=85=B7=E8=BF=94=E5=9B=9E=E4=BF=A1=E6=81=AF?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/tools/schedule_mcp_tools.cc | 84 +++---------------- docs/architecture/mcp-tool-contract.md | 10 ++- ...edule_mcp_tools_operation_coverage_test.cc | 8 +- tests/host/schedule_mcp_tools_test.cc | 7 +- 4 files changed, 25 insertions(+), 84 deletions(-) diff --git a/components/voicelife_mcp/src/tools/schedule_mcp_tools.cc b/components/voicelife_mcp/src/tools/schedule_mcp_tools.cc index 284ed64e..817b395c 100644 --- a/components/voicelife_mcp/src/tools/schedule_mcp_tools.cc +++ b/components/voicelife_mcp/src/tools/schedule_mcp_tools.cc @@ -5,7 +5,6 @@ #include #include #include -#include #include #include @@ -50,12 +49,6 @@ using voicelife::mcp::schedule_tool_input::UpdateRuleCommand; ToolResult Output(ToolOutputObject fields) { return ToolResult::Success(ToolOutputValue::Object(std::move(fields))); } -ToolResult SummaryOutput(ToolOutputObject fields, std::string summary) { - ToolResult result = Output(std::move(fields)); - result.text_output = std::move(summary); - return result; -} - ToolResult FailureOutput(std::string message) { return Output({ MakeToolOutput("status", ToolOutputValue::String("failure")), @@ -106,52 +99,6 @@ std::optional OutputJson(const ToolOutputValue& output) { return value; } -const ToolOutputValue* ObjectField(const ToolOutputValue& value, std::string_view key) { - if (!value.IsObject() || value.object == nullptr) return nullptr; - for (const auto& [field, item] : *value.object) { - if (field == key) return item.get(); - } - return nullptr; -} - -std::string StringField(const ToolOutputValue& value, std::string_view key) { - const ToolOutputValue* field = ObjectField(value, key); - return field != nullptr && field->IsString() ? field->string : std::string{}; -} - -std::string VoiceScheduleEntry(const ToolOutputValue& value, std::size_t index) { - std::string text = "第 " + std::to_string(index) + " 条:"; - const std::string event = StringField(value, "event"); - text += event.empty() ? "未命名日程" : event; - const std::string start = StringField(value, "start_time"); - const std::string end = StringField(value, "end_time"); - if (!start.empty()) { - text += ",时间 " + start; - if (!end.empty()) text += " 至 " + end; - } - const std::string location = StringField(value, "location"); - if (!location.empty()) text += ",地点 " + location; - const std::string notes = StringField(value, "notes"); - if (!notes.empty()) text += ",备注 " + notes; - return text; -} - -std::string FullVoiceScheduleText(const ToolOutputArray& schedules, const ToolOutputArray& future_occurrences, - const ToolOutputArray& exceptions) { - const std::size_t count = schedules.size() + future_occurrences.size(); - if (count == 0) return "没有查询到日程。"; - std::string text = "查询到 " + std::to_string(count) + " 条日程。"; - std::size_t index = 1; - for (const auto& item : schedules) { - if (item != nullptr) text += VoiceScheduleEntry(*item, index++) + "。"; - } - for (const auto& item : future_occurrences) { - if (item != nullptr) text += VoiceScheduleEntry(*item, index++) + "。"; - } - if (!exceptions.empty()) text += "另有 " + std::to_string(exceptions.size()) + " 项例外调整。"; - return text; -} - /** @brief 将实体类型字符串转为枚举;非法值返回空。 @param value 输入字符串。 @return 对应枚举。 */ std::optional ParseEntityType(const std::string& value) { if (value == "schedule") return schedule::OperationEntityType::kSchedule; @@ -462,7 +409,6 @@ Status RegisterScheduleMcpTools(McpServer& server, ScheduleService& service, Sch const ToolOutputValue recent = !schedules.empty() ? *schedules.front() : future_occurrences.empty() ? ToolOutputValue::Null() : *future_occurrences.front(); - const std::string voice_result = FullVoiceScheduleText(schedules, future_occurrences, exceptions); auto* reporting_channel = reporting_context.runtime == nullptr ? nullptr : reporting_context.runtime->reporting_channel(); const std::string reporting_device_id = @@ -498,26 +444,20 @@ Status RegisterScheduleMcpTools(McpServer& server, ScheduleService& service, Sch MakeToolOutput("future_occurrences", ToolOutputValue::Array(std::move(future_occurrences))), MakeToolOutput("exceptions", ToolOutputValue::Array(std::move(exceptions))), }); - output.text_output = voice_result + "完整结果已通过 IM 提交。"; - if (report.status != voicelife::im::ReportStatus::kSubmitted) { - output.text_output = voice_result + "IM 结果提交失败,可重试。"; - } return output; } - return SummaryOutput( - { - MakeToolOutput("status", ToolOutputValue::String("success")), - MakeToolOutput("message", ToolOutputValue::String("query success")), - MakeToolOutput("result_count", ToolOutputValue::Integer(result_count)), - MakeToolOutput("recent", recent), - MakeToolOutput("im_delivery", reporting_context.runtime == nullptr - ? ToolOutputValue::Null() - : ToolOutputValue::String("retryable_failed")), - MakeToolOutput("schedules", ToolOutputValue::Array(std::move(schedules))), - MakeToolOutput("future_occurrences", ToolOutputValue::Array(std::move(future_occurrences))), - MakeToolOutput("exceptions", ToolOutputValue::Array(std::move(exceptions))), - }, - reporting_context.runtime == nullptr ? voice_result : voice_result + "IM 暂不可用,可重试。"); + return Output({ + MakeToolOutput("status", ToolOutputValue::String("success")), + MakeToolOutput("message", ToolOutputValue::String("query success")), + MakeToolOutput("result_count", ToolOutputValue::Integer(result_count)), + MakeToolOutput("recent", recent), + MakeToolOutput("im_delivery", reporting_context.runtime == nullptr + ? ToolOutputValue::Null() + : ToolOutputValue::String("retryable_failed")), + MakeToolOutput("schedules", ToolOutputValue::Array(std::move(schedules))), + MakeToolOutput("future_occurrences", ToolOutputValue::Array(std::move(future_occurrences))), + MakeToolOutput("exceptions", ToolOutputValue::Array(std::move(exceptions))), + }); }); if (!status.ok()) return status; diff --git a/docs/architecture/mcp-tool-contract.md b/docs/architecture/mcp-tool-contract.md index 484810ea..2fa8dc14 100644 --- a/docs/architecture/mcp-tool-contract.md +++ b/docs/architecture/mcp-tool-contract.md @@ -218,9 +218,11 @@ MCP Tool 回调只做四件事: - `schedules`:已物化日程。 - `future_occurrences`:未来周期候选日程。 - `exceptions`:`schedule_rule_exception` 实体。 -7. 语音链路使用数量、最近一条和必要提醒构成摘要文本;当 IM Reporting 可用时,使用 - `schedule-query:` 作为幂等业务事件 ID,将完整范围、总数、三类条目和查询时间提交到 - `POST /v1/im/schedule-query-results`。IM 失败只返回可重试状态,不改变语音摘要。 +7. 将完整范围、总数、三类条目和查询时间提交到 `POST /v1/im/schedule-query-results`;使用 + `schedule-query:` 作为幂等业务事件 ID。IM 失败只影响 `im_delivery` 字段, + 不改变返回给模型的结构化结果。 +8. 向模型返回结构化 JSON(`schedules`、`future_occurrences`、`exceptions`、`result_count`、 + `recent`、`im_delivery`),不再返回自然语言文本摘要。 ### `schedule.update` 回调编排 @@ -303,7 +305,7 @@ MCP Tool 回调只做四件事: | `schedule.query` | `future_occurrences` | array | 是 | 未来周期候选日程列表,元素为 `future_occurrence` | | `schedule.query` | `exceptions` | array | 是 | 周期单次例外列表,元素为 `exception` | | `schedule.query` | `result_count` | integer | 是 | `schedules` 与 `future_occurrences` 的总条数 | -| `schedule.query` | `recent` | object/null | 是 | 语音摘要使用的最近一条日程 | +| `schedule.query` | `recent` | object/null | 是 | 结构化结果中的最近一条日程 | | `schedule.query` | `im_delivery` | string | 否 | `submitted`、`retryable_failed` 或 `failed` | ## Tool 3:`schedule.update` diff --git a/tests/host/schedule_mcp_tools_operation_coverage_test.cc b/tests/host/schedule_mcp_tools_operation_coverage_test.cc index b77bce94..0295c024 100644 --- a/tests/host/schedule_mcp_tools_operation_coverage_test.cc +++ b/tests/host/schedule_mcp_tools_operation_coverage_test.cc @@ -213,8 +213,8 @@ void CheckScheduleQueryReportingPaths() { .arguments = {{"keyword", std::string("上报")}}, }); Check(submitted.status.ok() && OutputString(submitted, "im_delivery") == "submitted" && - submitted.text_output.has_value() && submitted.text_output->find("已通过 IM 提交") != std::string::npos, - "IM 上报成功应返回 submitted 状态和用户摘要"); + !submitted.text_output.has_value(), + "IM 上报成功应返回 submitted 状态和结构化 JSON 而非文本摘要"); runtime_fixture.transport->next_post_response = { .status = ImTransportStatus::kNetworkFailure, .status_code = 0, .body = {}, .message = "network down"}; @@ -224,8 +224,8 @@ void CheckScheduleQueryReportingPaths() { .arguments = {}, }); Check(retryable.status.ok() && OutputString(retryable, "im_delivery") == "retryable_failed" && - retryable.text_output.has_value() && retryable.text_output->find("可重试") != std::string::npos, - "IM 上报失败应返回 retryable_failed 和可重试摘要"); + !retryable.text_output.has_value(), + "IM 上报失败应返回 retryable_failed 和结构化 JSON 而非文本摘要"); } } // namespace diff --git a/tests/host/schedule_mcp_tools_test.cc b/tests/host/schedule_mcp_tools_test.cc index 8b6d0729..7401940a 100644 --- a/tests/host/schedule_mcp_tools_test.cc +++ b/tests/host/schedule_mcp_tools_test.cc @@ -291,10 +291,9 @@ int main() { .arguments = {{"status", std::string("active")}}, }); Check(queried.status.ok() && OutputString(queried, "status") == "success", "查询应返回成功结果"); - Check(queried.text_output.has_value() && queried.text_output->find("条日程") != std::string::npos, - "语音查询结果必须提供日程数量"); - Check(queried.text_output.has_value() && queried.text_output->find("第 1 条:") != std::string::npos, - "语音查询结果必须逐条播报完整日程"); + Check(!queried.text_output.has_value(), "查询应返回结构化 JSON 而非文本摘要"); + Check(OutputArraySize(queried, "schedules") >= 1, "查询结果应包含结构化 schedules 数组"); + Check(OutputString(queried, "message") == "query success", "查询结果应包含结构化 message 字段"); // schedule.query:带日期范围与关键字,触发规则未来 occurrence 与例外展开。 const auto queried_range = server.call({ From 4b1c31831c483f3b47e134c4af676b705024647b Mon Sep 17 00:00:00 2001 From: huxiaohui <19946728049@163.com> Date: Fri, 28 Aug 2026 17:49:23 +0800 Subject: [PATCH 2/3] =?UTF-8?q?=F0=9F=90=9B=20fix(mcp):=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E4=B8=80=E4=BA=9B=E5=B7=B2=E7=9F=A5=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/es8311_codec_control.cc | 4 +- .../src/tools/schedule_mcp_tools.cc | 567 ++++++++---------- .../src/tools/schedule_mcp_tools_input.cc | 244 ++++++-- .../src/tools/schedule_mcp_tools_input.h | 23 + components/voicelife_runtime/src/runtime.cc | 7 +- tests/host/schedule_mcp_tools_branch_test.cc | 6 +- ...dule_mcp_tools_delete_confirmation_test.cc | 11 +- ...chedule_mcp_tools_failure_coverage_test.cc | 31 +- tests/host/schedule_mcp_tools_input_test.cc | 18 +- .../host/schedule_mcp_tools_reminder_test.cc | 40 +- tests/host/schedule_mcp_tools_test.cc | 216 +++---- 11 files changed, 656 insertions(+), 511 deletions(-) diff --git a/components/voicelife_audio_esp/src/es8311_codec_control.cc b/components/voicelife_audio_esp/src/es8311_codec_control.cc index b1b7b4bb..9f6b1054 100644 --- a/components/voicelife_audio_esp/src/es8311_codec_control.cc +++ b/components/voicelife_audio_esp/src/es8311_codec_control.cc @@ -179,11 +179,11 @@ voicelife::Result InitializeEs8311(const Es8311ControlConfig& config) { // esp_codec_dev initializes output volume to 0. XiaoZhi explicitly // restores its user volume immediately after open; without this call the // ES8311 remains muted even when PCM reaches the I2S TX channel. - // Match Runtime's serial-validation volume before the first PCM frame. + // Match Runtime's output volume before the first PCM frame. #if CONFIG_VOICELIFE_SERIAL_VOICE_TEST constexpr uint8_t kSparkBotDefaultOutputVolume = 35; #else - constexpr uint8_t kSparkBotDefaultOutputVolume = 70; + constexpr uint8_t kSparkBotDefaultOutputVolume = 100; #endif const esp_err_t volume_err = esp_codec_dev_set_out_vol(dev, kSparkBotDefaultOutputVolume); if (volume_err != ESP_OK) { diff --git a/components/voicelife_mcp/src/tools/schedule_mcp_tools.cc b/components/voicelife_mcp/src/tools/schedule_mcp_tools.cc index 817b395c..b648ec12 100644 --- a/components/voicelife_mcp/src/tools/schedule_mcp_tools.cc +++ b/components/voicelife_mcp/src/tools/schedule_mcp_tools.cc @@ -39,13 +39,19 @@ using voicelife::ToolOutputObject; using voicelife::ToolOutputValue; using voicelife::mcp::schedule_tool_input::CreateProperties; using voicelife::mcp::schedule_tool_input::CreateRuleCommand; +using voicelife::mcp::schedule_tool_input::CreateRuleProperties; using voicelife::mcp::schedule_tool_input::DeleteProperties; +using voicelife::mcp::schedule_tool_input::DeleteRuleProperties; using voicelife::mcp::schedule_tool_input::OperationQueryProperties; +using voicelife::mcp::schedule_tool_input::SkipOccurrenceProperties; using voicelife::mcp::schedule_tool_input::ParsedRepeat; using voicelife::mcp::schedule_tool_input::ParseRepeat; +using voicelife::mcp::schedule_tool_input::ParseRuleProperties; using voicelife::mcp::schedule_tool_input::QueryProperties; +using voicelife::mcp::schedule_tool_input::UpdateOccurrenceProperties; using voicelife::mcp::schedule_tool_input::UpdateProperties; using voicelife::mcp::schedule_tool_input::UpdateRuleCommand; +using voicelife::mcp::schedule_tool_input::UpdateRuleProperties; ToolResult Output(ToolOutputObject fields) { return ToolResult::Success(ToolOutputValue::Object(std::move(fields))); } @@ -245,52 +251,10 @@ Status RegisterScheduleMcpTools(McpServer& server, ScheduleService& service, Sch schedule::ScheduleOperationService* operation_service, schedule::ScheduleReminderService* reminder_service, ScheduleQueryReportingContext reporting_context) { - // schedule.create 根据是否传入 repeat 拆成两条业务路径: - // 一次性日程走 ScheduleService,周期日程走 ScheduleRuleService。 Status status = server.add_tool( - "schedule.create", "创建一次性日程或周期日程。", CreateProperties(), - [&service, rule_service, reminder_service](const PropertyList& properties) { - const auto repeat = properties.value("repeat"); - const ParsedRepeat parsed_repeat = ParseRepeat(repeat, true); - if (!parsed_repeat.ok()) return FailureOutput(parsed_repeat.error); - - if (repeat.has_value()) { - // 有 repeat 时创建周期规则,并把服务端物化的首条实例作为 schedule 一并返回。 - if (rule_service == nullptr) { - return FailureOutput("当前运行时未启用周期日程能力"); - } - const auto result = rule_service->create_schedule_rule(CreateRuleCommand(properties, parsed_repeat)); - if (!result.status.ok()) { - if (result.status.code == ErrorCode::kConflict) { - return ConflictOutput(result.status.message, - schedule_tool_output::ScheduleArrayOutput(result.conflicts)); - } - return FailureOutput(result.status.message); - } - - if (result.rule.has_value()) { - const std::optional reminder_status = - SynchronizeRule(reminder_service, result.rule->id); - if (reminder_status.has_value()) return *reminder_status; - } - - ToolOutputObject fields = { - MakeToolOutput("status", ToolOutputValue::String("success")), - MakeToolOutput("message", ToolOutputValue::String("created success")), - MakeToolOutput("schedule", ToolOutputValue::Null()), - MakeToolOutput("rule", result.rule.has_value() ? schedule_tool_output::RuleOutput(*result.rule) - : ToolOutputValue::Null()), - MakeToolOutput("conflicts", - ToolOutputValue::Array(schedule_tool_output::ScheduleArrayOutput(result.conflicts))), - }; - if (!result.schedules.empty() && result.rule.has_value()) { - fields[2] = MakeToolOutput( - "schedule", schedule_tool_output::ScheduleOutput(result.schedules.front(), &*result.rule)); - } - return Output(std::move(fields)); - } - - // 没有 repeat 时创建一次性日程;时间字符串在这里统一转为领域 DateTime。 + "schedule.create", + "创建一条一次性日程并直接写入 schedule 表。只能创建独立的一次性日程;不要传 repeat 或任何周期规则字段。", + CreateProperties(), [&service, reminder_service](const PropertyList& properties) { schedule::CreateScheduleCommand command; command.event = properties.value("event").value_or(""); command.start_time = properties.value("start_time").has_value() @@ -334,11 +298,48 @@ Status RegisterScheduleMcpTools(McpServer& server, ScheduleService& service, Sch }); if (!status.ok()) return status; + if (rule_service != nullptr) { + status = server.add_tool( + "schedule.create_rule", + "创建周期日程:在 schedule_rule 表创建周期规则,并物化首条 schedule 实例。周期字段必须直接作为顶层参数传入,不使用 repeat 对象。", + CreateRuleProperties(), [rule_service, reminder_service](const PropertyList& properties) { + if (rule_service == nullptr) return FailureOutput("当前运行时未启用周期日程能力"); + const ParsedRepeat parsed_repeat = ParseRuleProperties(properties, true); + if (!parsed_repeat.ok()) return FailureOutput(parsed_repeat.error); + + const auto result = rule_service->create_schedule_rule(CreateRuleCommand(properties, parsed_repeat)); + if (!result.status.ok()) { + if (result.status.code == ErrorCode::kConflict) { + return ConflictOutput(result.status.message, + schedule_tool_output::ScheduleArrayOutput(result.conflicts)); + } + return FailureOutput(result.status.message); + } + if (result.rule.has_value()) { + const std::optional reminder_status = + SynchronizeRule(reminder_service, result.rule->id); + if (reminder_status.has_value()) return *reminder_status; + } + return Output({ + MakeToolOutput("status", ToolOutputValue::String("success")), + MakeToolOutput("message", ToolOutputValue::String("created success")), + MakeToolOutput("rule", result.rule.has_value() ? schedule_tool_output::RuleOutput(*result.rule) + : ToolOutputValue::Null()), + MakeToolOutput("schedule", (!result.schedules.empty() && result.rule.has_value()) + ? schedule_tool_output::ScheduleOutput(result.schedules.front(), &*result.rule) + : ToolOutputValue::Null()), + MakeToolOutput("conflicts", + ToolOutputValue::Array(schedule_tool_output::ScheduleArrayOutput(result.conflicts))), + }); + }); + if (!status.ok()) return status; + } + status = server.add_tool_with_context( - "schedule.query", "按自然语言友好的条件查询当前相关日程。", QueryProperties(), - [&service, rule_service, reporting_context](const ToolCall& call) { + "schedule.query", + "统一查询一次性日程和周期日程。返回结果按 one_time_schedules、recurring_rules、recurring_schedules、future_occurrences、exceptions 分类;schedule_id 与 rule_id 互斥。", + QueryProperties(), [&service, rule_service, reporting_context](const ToolCall& call) { const PropertyList properties = QueryProperties().with_values(call.arguments); - // query 是只读编排:先查已物化日程,再补充未来 occurrence 和周期例外,不写 schedule 表。 const auto start = ParseDateStart(properties); const auto end = ParseDateEnd(properties); if (properties.value("start_date").has_value() && !start.has_value()) { @@ -351,8 +352,15 @@ Status RegisterScheduleMcpTools(McpServer& server, ScheduleService& service, Sch return FailureOutput("start_date 不能晚于 end_date"); } - // 已物化日程仍走 ScheduleService,保证一次性日程和已生成周期实例统一从 schedule 表返回。 + const auto schedule_id = properties.value("schedule_id"); + const auto rule_id = properties.value("rule_id"); + if (schedule_id.has_value() && rule_id.has_value()) { + return FailureOutput("schedule_id 和 rule_id 不能同时传入;查询 schedule 使用前者,查询周期规则使用后者"); + } + schedule::QueryScheduleCommand command; + command.schedule_id = schedule_id; + command.rule_id = rule_id; command.keyword = properties.value("keyword"); command.start_from = start; command.start_to = end; @@ -360,18 +368,27 @@ Status RegisterScheduleMcpTools(McpServer& server, ScheduleService& service, Sch command.limit = 50; command.offset = 0; const auto result = service.query_schedule(command); - if (!result.result.ok()) return FailureOutput(result.result.status.message); + if (!result.result.ok()) { + return FailureOutput(result.result.status.message.empty() ? "查询已物化日程失败" + : result.result.status.message); + } - ToolOutputArray schedules = schedule_tool_output::ScheduleArrayOutput(result.result.value); - if (schedules.size() > contracts::im::kMaxScheduleQueryItems) { - schedules.resize(contracts::im::kMaxScheduleQueryItems); + ToolOutputArray one_time; + ToolOutputArray recurring_schedules; + for (const auto& item : result.result.value) { + if (item.rule_id.has_value()) { + recurring_schedules.emplace_back(MakeToolOutput(schedule_tool_output::ScheduleOutput(item))); + } else { + one_time.emplace_back(MakeToolOutput(schedule_tool_output::ScheduleOutput(item))); + } } + + ToolOutputArray recurring_rules; ToolOutputArray future_occurrences; ToolOutputArray exceptions; - // 周期部分不物化,只把规则、未来 occurrence、exception 转成模型可读的结果。 - std::unordered_map rule_by_id; - if (rule_service != nullptr) { + if (rule_service != nullptr && !schedule_id.has_value()) { schedule::QueryScheduleRulesCommand rule_command; + rule_command.rule_id = rule_id; rule_command.keyword = properties.value("keyword"); rule_command.status = command.status; rule_command.occurrence_start = start; @@ -379,10 +396,11 @@ Status RegisterScheduleMcpTools(McpServer& server, ScheduleService& service, Sch rule_command.limit = 50; rule_command.offset = 0; const auto rules = rule_service->query_schedule_rules(rule_command); - if (!rules.status.ok()) return FailureOutput(rules.status.message); - + if (!rules.status.ok()) { + return FailureOutput(rules.status.message.empty() ? "查询周期规则失败" : rules.status.message); + } for (const auto& view : rules.rules) { - rule_by_id.emplace(view.rule.id, view.rule); + recurring_rules.emplace_back(MakeToolOutput(schedule_tool_output::RuleOutput(view.rule))); for (const auto& exception : view.exceptions) { if (exceptions.size() < contracts::im::kMaxScheduleQueryItems && WithinRange(start, end, exception.original_start_time)) { @@ -399,20 +417,27 @@ Status RegisterScheduleMcpTools(McpServer& server, ScheduleService& service, Sch } } - const auto schedules_json = OutputJson(ToolOutputValue::Array(schedules)); + const int64_t result_count = static_cast(one_time.size() + recurring_schedules.size() + + future_occurrences.size()); + const std::string keyword = properties.value("keyword").value_or(""); + const std::string prefix = keyword.empty() ? "查询到" : "根据“" + keyword + "”关键字查询到"; + const std::string message = prefix + " " + std::to_string(one_time.size()) + " 条一次性日程、" + + std::to_string(recurring_rules.size()) + " 条周期规则、" + + std::to_string(recurring_schedules.size()) + " 条周期实例和 " + + std::to_string(future_occurrences.size()) + " 条未来 occurrence"; + + const auto one_time_json = OutputJson(ToolOutputValue::Array(one_time)); const auto future_json = OutputJson(ToolOutputValue::Array(future_occurrences)); const auto exceptions_json = OutputJson(ToolOutputValue::Array(exceptions)); - if (!schedules_json.has_value() || !future_json.has_value() || !exceptions_json.has_value()) { + if (!one_time_json.has_value() || !future_json.has_value() || !exceptions_json.has_value()) { return FailureOutput("查询结果序列化失败"); } - const int64_t result_count = static_cast(schedules.size() + future_occurrences.size()); - const ToolOutputValue recent = !schedules.empty() ? *schedules.front() - : future_occurrences.empty() ? ToolOutputValue::Null() - : *future_occurrences.front(); + auto* reporting_channel = reporting_context.runtime == nullptr ? nullptr : reporting_context.runtime->reporting_channel(); const std::string reporting_device_id = reporting_context.runtime == nullptr ? std::string{} : reporting_context.runtime->device_id(); + const char* report_state = "failed"; if (reporting_channel != nullptr && !reporting_device_id.empty()) { contracts::im::ScheduleQueryResultIntent intent; intent.schemaVersion = "1"; @@ -425,167 +450,94 @@ Status RegisterScheduleMcpTools(McpServer& server, ScheduleService& service, Sch intent.startDate = properties.value("start_date"); intent.endDate = properties.value("end_date"); intent.resultCount = result_count; - intent.schedules = *schedules_json; + intent.schedules = *one_time_json; intent.futureOccurrences = *future_json; intent.exceptions = *exceptions_json; intent.queriedAt = NowIso(); const voicelife::im::ReportResult report = reporting_channel->SubmitScheduleQueryResult(intent); - const char* report_state = report.status == voicelife::im::ReportStatus::kSubmitted ? "submitted" - : report.status == voicelife::im::ReportStatus::kRetryable - ? "retryable_failed" - : "failed"; - auto output = Output({ - MakeToolOutput("status", ToolOutputValue::String("success")), - MakeToolOutput("message", ToolOutputValue::String("query success")), - MakeToolOutput("result_count", ToolOutputValue::Integer(result_count)), - MakeToolOutput("recent", recent), - MakeToolOutput("im_delivery", ToolOutputValue::String(report_state)), - MakeToolOutput("schedules", ToolOutputValue::Array(std::move(schedules))), - MakeToolOutput("future_occurrences", ToolOutputValue::Array(std::move(future_occurrences))), - MakeToolOutput("exceptions", ToolOutputValue::Array(std::move(exceptions))), - }); - return output; + report_state = report.status == voicelife::im::ReportStatus::kSubmitted + ? "submitted" + : (report.status == voicelife::im::ReportStatus::kRetryable ? "retryable_failed" + : "failed"); } + return Output({ MakeToolOutput("status", ToolOutputValue::String("success")), - MakeToolOutput("message", ToolOutputValue::String("query success")), + MakeToolOutput("message", ToolOutputValue::String(message)), MakeToolOutput("result_count", ToolOutputValue::Integer(result_count)), - MakeToolOutput("recent", recent), - MakeToolOutput("im_delivery", reporting_context.runtime == nullptr - ? ToolOutputValue::Null() - : ToolOutputValue::String("retryable_failed")), - MakeToolOutput("schedules", ToolOutputValue::Array(std::move(schedules))), + MakeToolOutput("one_time_schedules", ToolOutputValue::Array(std::move(one_time))), + MakeToolOutput("recurring_rules", ToolOutputValue::Array(std::move(recurring_rules))), + MakeToolOutput("recurring_schedules", ToolOutputValue::Array(std::move(recurring_schedules))), MakeToolOutput("future_occurrences", ToolOutputValue::Array(std::move(future_occurrences))), MakeToolOutput("exceptions", ToolOutputValue::Array(std::move(exceptions))), + MakeToolOutput("im_delivery", reporting_channel == nullptr + ? ToolOutputValue::Null() + : ToolOutputValue::String(report_state)), }); }); if (!status.ok()) return status; - status = server.add_tool( - "schedule.update", "更新日程、更新周期规则、取消或跳过某次日程。", UpdateProperties(), - [&service, rule_service, reminder_service](const PropertyList& properties) { - // update 根据定位参数识别目标:schedule_id 改实例,rule_id 改规则,rule_id + original_start_time - // 改未来单次。 - const bool has_schedule_id = properties.value("schedule_id").has_value(); - const bool has_rule_id = properties.value("rule_id").has_value(); - const bool has_original_start_time = properties.value("original_start_time").has_value(); - const auto repeat = properties.value("repeat"); - - if (has_schedule_id && has_rule_id) { - return FailureOutput("schedule_id 和 rule_id 不能同时使用"); + "schedule.update", + "修改一次性日程或已经物化到 schedule 表的周期实例。必须只传 schedule_id;不要传 rule_id、original_start_time 或周期规则字段。", + UpdateProperties(), [&service, reminder_service](const PropertyList& properties) { + const auto schedule_id = properties.value("schedule_id"); + if (!schedule_id.has_value()) return FailureOutput("请提供 schedule_id"); + + schedule::UpdateScheduleCommand command; + command.schedule_id = *schedule_id; + if (properties.value("event").has_value()) + command.event = *properties.value("event"); + if (properties.value("start_time").has_value()) { + const auto parsed = schedule_tool_output::ParseDateTime(*properties.value("start_time")); + if (!parsed.has_value()) return FailureOutput("start_time 格式必须是 YYYY-MM-DD HH:mm:ss"); + command.start_time = parsed; } - if (has_original_start_time && !has_rule_id) { - return FailureOutput("original_start_time 必须和 rule_id 一起使用"); + if (properties.value("end_time").has_value()) { + const auto parsed = schedule_tool_output::ParseDateTime(*properties.value("end_time")); + if (!parsed.has_value()) return FailureOutput("end_time 格式必须是 YYYY-MM-DD HH:mm:ss"); + command.end_time = parsed; } + if (properties.value("location").has_value()) + command.location = *properties.value("location"); + if (properties.value("notes").has_value()) + command.notes = *properties.value("notes"); + command.ignore_conflict = properties.value("ignore_conflict").value_or(false); - if (has_schedule_id) { - // schedule_id 命中已物化实例;status=cancelled 走取消,否则走一次性日程更新。 - const auto status_text = properties.value("status"); - if (status_text.has_value() && *status_text == "cancelled") { - schedule::CancelScheduleCommand command; - command.schedule_id = properties.value("schedule_id").value_or(0); - schedule::QueryScheduleCommand query; - query.schedule_id = command.schedule_id; - query.status = schedule::ScheduleStatusFilter::kAll; - query.limit = 1; - query.offset = 0; - const auto loaded = service.query_schedule(query); - if (!loaded.result.ok() || loaded.result.value.empty()) return FailureOutput("日程不存在"); - const std::optional confirmation = - VerifyCancellationTarget(loaded.result.value.front(), properties); - if (confirmation.has_value()) return *confirmation; - const auto result = service.cancel_schedule(command); - if (!result.result.ok()) return FailureOutput(result.result.status.message); - const std::optional reminder_status = - CancelReminder(reminder_service, command.schedule_id); - if (reminder_status.has_value()) return *reminder_status; - return Output({ - MakeToolOutput("status", ToolOutputValue::String("success")), - MakeToolOutput("message", ToolOutputValue::String("deleted success")), - MakeToolOutput("schedule", ToolOutputValue::Null()), - MakeToolOutput("rule", ToolOutputValue::Null()), - MakeToolOutput("exception", ToolOutputValue::Null()), - MakeToolOutput("conflicts", ToolOutputValue::Array(ToolOutputArray{})), - }); - } - - schedule::UpdateScheduleCommand command; - command.schedule_id = *properties.value("schedule_id"); - if (properties.value("event").has_value()) - command.event = *properties.value("event"); - if (properties.value("start_time").has_value()) { - const auto parsed = - schedule_tool_output::ParseDateTime(*properties.value("start_time")); - if (!parsed.has_value()) return FailureOutput("start_time 格式必须是 YYYY-MM-DD HH:mm:ss"); - command.start_time = parsed; - } - if (properties.value("end_time").has_value()) { - const auto parsed = schedule_tool_output::ParseDateTime(*properties.value("end_time")); - if (!parsed.has_value()) return FailureOutput("end_time 格式必须是 YYYY-MM-DD HH:mm:ss"); - command.end_time = parsed; - } - if (properties.value("location").has_value()) - command.location = *properties.value("location"); - if (properties.value("notes").has_value()) - command.notes = *properties.value("notes"); - command.ignore_conflict = properties.value("ignore_conflict").value_or(false); - - const auto result = service.update_schedule(command); - if (!result.result.ok()) { - if (result.result.status.code == ErrorCode::kConflict) { - return ConflictOutput(result.result.status.message, - schedule_tool_output::ScheduleArrayOutput(result.conflicts)); - } - return FailureOutput(result.result.status.message); - } - if (result.result.value.has_value()) { - const std::optional reminder_status = - SynchronizeReminder(reminder_service, result.result.value->id); - if (reminder_status.has_value()) return *reminder_status; + const auto result = service.update_schedule(command); + if (!result.result.ok()) { + if (result.result.status.code == ErrorCode::kConflict) { + return ConflictOutput(result.result.status.message, + schedule_tool_output::ScheduleArrayOutput(result.conflicts)); } - return Output({ - MakeToolOutput("status", ToolOutputValue::String("success")), - MakeToolOutput("message", ToolOutputValue::String("updated success")), - MakeToolOutput("schedule", result.result.value.has_value() - ? schedule_tool_output::ScheduleOutput(*result.result.value) - : ToolOutputValue::Null()), - MakeToolOutput("rule", ToolOutputValue::Null()), - MakeToolOutput("exception", ToolOutputValue::Null()), - MakeToolOutput("conflicts", - ToolOutputValue::Array(schedule_tool_output::ScheduleArrayOutput(result.conflicts))), - }); + return FailureOutput(result.result.status.message); } + if (result.result.value.has_value()) { + const std::optional reminder_status = + SynchronizeReminder(reminder_service, result.result.value->id); + if (reminder_status.has_value()) return *reminder_status; + } + return Output({ + MakeToolOutput("status", ToolOutputValue::String("success")), + MakeToolOutput("message", ToolOutputValue::String("updated success")), + MakeToolOutput("schedule", result.result.value.has_value() + ? schedule_tool_output::ScheduleOutput(*result.result.value) + : ToolOutputValue::Null()), + MakeToolOutput("conflicts", + ToolOutputValue::Array(schedule_tool_output::ScheduleArrayOutput(result.conflicts))), + }); + }); + if (!status.ok()) return status; - if (rule_service == nullptr) return FailureOutput("当前运行时未启用周期日程能力"); - - if (has_original_start_time) { - // 未来周期单次没有 schedule_id,通过 rule_id + original_start_time 定位。 + if (rule_service != nullptr) { + status = server.add_tool( + "schedule.update_occurrence", + "修改未来周期中的某一次尚未物化 occurrence。必须传 rule_id + original_start_time;已物化时请改用 schedule.update。", + UpdateOccurrenceProperties(), [rule_service](const PropertyList& properties) { + if (rule_service == nullptr) return FailureOutput("当前运行时未启用周期日程能力"); const auto original = schedule_tool_output::ParseDateTime( properties.value("original_start_time").value_or("")); - if (!original.has_value()) { - return FailureOutput("original_start_time 格式必须是 YYYY-MM-DD HH:mm:ss"); - } - const auto status_text = properties.value("status"); - if (status_text.has_value() && *status_text == "cancelled") { - // 跳过未来单次:在 schedule_rule_exception 中记录 skip,后续生成时不再物化这次。 - schedule::SkipScheduleOccurrenceCommand command; - command.rule_id = properties.value("rule_id").value_or(0); - command.original_start_time = *original; - const auto result = rule_service->skip_schedule_occurrence(command); - if (!result.status.ok()) return FailureOutput(result.status.message); - return Output({ - MakeToolOutput("status", ToolOutputValue::String("success")), - MakeToolOutput("message", ToolOutputValue::String("updated success")), - MakeToolOutput("schedule", ToolOutputValue::Null()), - MakeToolOutput("rule", ToolOutputValue::Null()), - MakeToolOutput("exception", result.exception.has_value() - ? schedule_tool_output::ExceptionOutput(*result.exception) - : ToolOutputValue::Null()), - MakeToolOutput("conflicts", ToolOutputValue::Array(ToolOutputArray{})), - }); - } + if (!original.has_value()) return FailureOutput("original_start_time 格式必须是 YYYY-MM-DD HH:mm:ss"); - // 修改未来单次:先落到 schedule_rule_exception,后续物化该次时使用覆盖字段。 schedule::UpdateScheduleOccurrenceCommand command; command.rule_id = properties.value("rule_id").value_or(0); command.original_start_time = *original; @@ -607,134 +559,145 @@ Status RegisterScheduleMcpTools(McpServer& server, ScheduleService& service, Sch if (properties.value("notes").has_value()) command.notes = std::optional{*properties.value("notes")}; command.ignore_conflict = properties.value("ignore_conflict").value_or(false); + const auto result = rule_service->update_schedule_occurrence(command); if (!result.status.ok()) return FailureOutput(result.status.message); return Output({ MakeToolOutput("status", ToolOutputValue::String("success")), MakeToolOutput("message", ToolOutputValue::String("updated success")), - MakeToolOutput("schedule", ToolOutputValue::Null()), - MakeToolOutput("rule", ToolOutputValue::Null()), MakeToolOutput("exception", result.exception.has_value() ? schedule_tool_output::ExceptionOutput(*result.exception) : ToolOutputValue::Null()), MakeToolOutput("conflicts", ToolOutputValue::Array(ToolOutputArray{})), }); - } + }); + if (!status.ok()) return status; + } - if (!has_rule_id) return FailureOutput("请提供 schedule_id、rule_id 或 rule_id + original_start_time"); - // 只有 rule_id 时按整条周期规则更新;repeat 提供新规则字段,未传字段由 service 保持原值。 - const ParsedRepeat parsed_repeat = ParseRepeat(repeat, false); - if (!parsed_repeat.ok()) return FailureOutput(parsed_repeat.error); - const schedule::ScheduleRuleId rule_id = properties.value("rule_id").value_or(0); - const std::optional suspended = SuspendRuleReminders(reminder_service, rule_id); - if (suspended.has_value()) return *suspended; - const auto result = rule_service->update_schedule_rule(UpdateRuleCommand(properties, parsed_repeat)); - if (!result.status.ok()) { - (void)SynchronizeRule(reminder_service, rule_id); - if (result.status.code == ErrorCode::kConflict) { - return ConflictOutput(result.status.message, - schedule_tool_output::ScheduleArrayOutput(result.conflicts)); + if (rule_service != nullptr) { + status = server.add_tool( + "schedule.update_rule", + "修改整条周期规则并按新规则重建未来实例。必须只传 rule_id;周期字段直接作为顶层参数传入,不使用 repeat 对象。", + UpdateRuleProperties(), [rule_service, reminder_service](const PropertyList& properties) { + if (rule_service == nullptr) return FailureOutput("当前运行时未启用周期日程能力"); + const ParsedRepeat parsed_repeat = ParseRuleProperties(properties, false); + if (!parsed_repeat.ok()) return FailureOutput(parsed_repeat.error); + + const schedule::ScheduleRuleId rule_id = properties.value("rule_id").value_or(0); + const std::optional suspended = SuspendRuleReminders(reminder_service, rule_id); + if (suspended.has_value()) return *suspended; + const auto result = rule_service->update_schedule_rule(UpdateRuleCommand(properties, parsed_repeat)); + if (!result.status.ok()) { + (void)SynchronizeRule(reminder_service, rule_id); + if (result.status.code == ErrorCode::kConflict) { + return ConflictOutput(result.status.message, + schedule_tool_output::ScheduleArrayOutput(result.conflicts)); + } + return FailureOutput(result.status.message); } - return FailureOutput(result.status.message); + const std::optional reminder_status = SynchronizeRule(reminder_service, rule_id); + if (reminder_status.has_value()) return *reminder_status; + return Output({ + MakeToolOutput("status", ToolOutputValue::String("success")), + MakeToolOutput("message", ToolOutputValue::String("updated success")), + MakeToolOutput("rule", result.rule.has_value() ? schedule_tool_output::RuleOutput(*result.rule) + : ToolOutputValue::Null()), + MakeToolOutput("conflicts", + ToolOutputValue::Array(schedule_tool_output::ScheduleArrayOutput(result.conflicts))), + }); + }); + if (!status.ok()) return status; + } + status = server.add_tool( + "schedule.delete", + "取消一次性日程或已经物化到 schedule 表的周期实例。必须传 schedule_id、expected_event、expected_start_time;这三个字段用于确认具体记录。不要传 rule_id 或 original_start_time。", + DeleteProperties(), [&service, reminder_service](const PropertyList& properties) { + const schedule::ScheduleId id = properties.value("schedule_id").value_or(0); + schedule::QueryScheduleCommand query; + query.schedule_id = id; + query.status = schedule::ScheduleStatusFilter::kAll; + query.limit = 1; + query.offset = 0; + const auto loaded = service.query_schedule(query); + if (!loaded.result.ok() || loaded.result.value.empty()) { + return FailureOutput("找不到 schedule_id=" + std::to_string(id) + " 对应的日程"); + } + if (const auto check = VerifyCancellationTarget(loaded.result.value.front(), properties); + check.has_value()) { + return *check; + } + const auto result = service.cancel_schedule({.schedule_id = id}); + if (!result.result.ok()) { + return FailureOutput(result.result.status.message.empty() ? "日程取消失败" : result.result.status.message); } - const std::optional reminder_status = SynchronizeRule(reminder_service, rule_id); - if (reminder_status.has_value()) return *reminder_status; + if (const auto reminder = CancelReminder(reminder_service, id); reminder.has_value()) return *reminder; + schedule::Schedule cancelled = loaded.result.value.front(); + cancelled.status = schedule::ScheduleStatus::kCancelled; return Output({ MakeToolOutput("status", ToolOutputValue::String("success")), - MakeToolOutput("message", ToolOutputValue::String("updated success")), - MakeToolOutput("schedule", ToolOutputValue::Null()), - MakeToolOutput("rule", result.rule.has_value() ? schedule_tool_output::RuleOutput(*result.rule) - : ToolOutputValue::Null()), - MakeToolOutput("exception", ToolOutputValue::Null()), - MakeToolOutput("conflicts", - ToolOutputValue::Array(schedule_tool_output::ScheduleArrayOutput(result.conflicts))), + MakeToolOutput("message", ToolOutputValue::String("已取消 schedule_id=" + std::to_string(id) + " 的日程")), + MakeToolOutput("schedule", schedule_tool_output::ScheduleOutput(cancelled)), + MakeToolOutput("warnings", ToolOutputValue::Array(ToolOutputArray{})), }); }); if (!status.ok()) return status; - status = server.add_tool( - "schedule.delete", "删除单次日程、未来周期单次或整条周期规则;按 schedule_id 删除前必须先查询并确认目标。", - DeleteProperties(), [&service, rule_service, reminder_service](const PropertyList& properties) { - // delete 根据定位参数拆三条路径:schedule_id 删实例,rule_id 删规则,rule_id + original_start_time - // 跳过未来单次。 - const bool has_schedule_id = properties.value("schedule_id").has_value(); - const bool has_rule_id = properties.value("rule_id").has_value(); - const bool has_original_start_time = properties.value("original_start_time").has_value(); - if (!has_schedule_id && !has_rule_id) return FailureOutput("请提供 schedule_id 或 rule_id"); - if (has_schedule_id && has_rule_id) return FailureOutput("schedule_id 和 rule_id 不能同时使用"); - - if (has_schedule_id) { - // 删除实例前先读取快照,取消成功后把快照状态改为 cancelled 返回给模型。 - const schedule::ScheduleId schedule_id = properties.value("schedule_id").value_or(0); - schedule::QueryScheduleCommand query; - query.schedule_id = schedule_id; - query.status = schedule::ScheduleStatusFilter::kAll; - query.limit = 1; - query.offset = 0; - const auto loaded = service.query_schedule(query); - if (!loaded.result.ok() || loaded.result.value.empty()) return FailureOutput("日程不存在"); - const std::optional confirmation = - VerifyCancellationTarget(loaded.result.value.front(), properties); - if (confirmation.has_value()) return *confirmation; - const auto result = service.cancel_schedule({.schedule_id = schedule_id}); - if (!result.result.ok()) return FailureOutput(result.result.status.message); - const std::optional reminder_status = CancelReminder(reminder_service, schedule_id); - if (reminder_status.has_value()) return *reminder_status; - schedule::Schedule deleted = loaded.result.value.front(); - deleted.status = schedule::ScheduleStatus::kCancelled; + if (rule_service != nullptr) { + status = server.add_tool( + "schedule.delete_rule", + "取消整条周期规则及其已物化实例,并停止后续 occurrence 生成。必须只传 rule_id;不要传 schedule_id 或 original_start_time。", + DeleteRuleProperties(), [rule_service, reminder_service](const PropertyList& properties) { + if (rule_service == nullptr) return FailureOutput("当前运行时未启用周期日程能力,无法取消规则"); + const auto id = properties.value("rule_id").value_or(0); + const auto result = rule_service->cancel_schedule_rule({.rule_id = id}); + if (!result.status.ok()) { + return FailureOutput(result.status.message.empty() ? "周期规则取消失败" : result.status.message); + } + if (const auto reminder = SuspendRuleReminders(reminder_service, id); reminder.has_value()) return *reminder; return Output({ MakeToolOutput("status", ToolOutputValue::String("success")), - MakeToolOutput("message", ToolOutputValue::String("deleted success")), - MakeToolOutput("schedule", schedule_tool_output::ScheduleOutput(deleted)), - MakeToolOutput("rule", ToolOutputValue::Null()), - MakeToolOutput("exception", ToolOutputValue::Null()), + MakeToolOutput("message", ToolOutputValue::String("已取消周期规则 rule_id=" + std::to_string(id) + ",后续 occurrence 将不再生成")), + MakeToolOutput("rule", result.rule.has_value() ? schedule_tool_output::RuleOutput(*result.rule) + : ToolOutputValue::Null()), + MakeToolOutput("cancelled_schedule_count", ToolOutputValue::Integer(result.cancelled_count)), + MakeToolOutput("warnings", ToolOutputValue::Array(ToolOutputArray{})), }); - } + }); + if (!status.ok()) return status; + } - if (rule_service == nullptr) return FailureOutput("当前运行时未启用周期日程能力"); - if (has_original_start_time) { - // 删除未来周期单次等价于创建 skip exception,不落库为 schedule。 + if (rule_service != nullptr) { + status = server.add_tool( + "schedule.skip_occurrence", + "跳过未来周期中的某一次尚未物化 occurrence,实际写入 schedule_rule_exception,而不是删除周期规则。必须传 rule_id + original_start_time + expected_event;不要传 schedule_id。已物化时请改用 schedule.delete。", + SkipOccurrenceProperties(), [rule_service](const PropertyList& properties) { + if (rule_service == nullptr) return FailureOutput("当前运行时未启用周期日程能力,无法跳过 occurrence"); const auto original = schedule_tool_output::ParseDateTime( properties.value("original_start_time").value_or("")); if (!original.has_value()) { - return FailureOutput("original_start_time 格式必须是 YYYY-MM-DD HH:mm:ss"); + return FailureOutput("original_start_time 必须是严格的 YYYY-MM-DD HH:mm:ss 完整本地时间"); } - schedule::SkipScheduleOccurrenceCommand command; - command.rule_id = properties.value("rule_id").value_or(0); - command.original_start_time = *original; + schedule::SkipScheduleOccurrenceCommand command{ + .rule_id = properties.value("rule_id").value_or(0), + .original_start_time = *original, + }; const auto result = rule_service->skip_schedule_occurrence(command); - if (!result.status.ok()) return FailureOutput(result.status.message); - return Output({ - MakeToolOutput("status", ToolOutputValue::String("success")), - MakeToolOutput("message", ToolOutputValue::String("deleted success")), - MakeToolOutput("schedule", ToolOutputValue::Null()), - MakeToolOutput("rule", ToolOutputValue::Null()), - MakeToolOutput("exception", result.exception.has_value() - ? schedule_tool_output::ExceptionOutput(*result.exception) - : ToolOutputValue::Null()), - }); - } - - // 仅 rule_id 时取消整条周期规则及其未来实例。 - schedule::CancelScheduleRuleCommand command; - command.rule_id = properties.value("rule_id").value_or(0); - const std::optional suspended = SuspendRuleReminders(reminder_service, command.rule_id); - if (suspended.has_value()) return *suspended; - const auto result = rule_service->cancel_schedule_rule(command); - if (!result.status.ok()) { - (void)SynchronizeRule(reminder_service, command.rule_id); - return FailureOutput(result.status.message); - } - return Output({ - MakeToolOutput("status", ToolOutputValue::String("success")), - MakeToolOutput("message", ToolOutputValue::String("deleted success")), - MakeToolOutput("schedule", ToolOutputValue::Null()), - MakeToolOutput("rule", result.rule.has_value() ? schedule_tool_output::RuleOutput(*result.rule) - : ToolOutputValue::Null()), - MakeToolOutput("exception", ToolOutputValue::Null()), + if (!result.status.ok()) { + return FailureOutput(result.status.message.empty() ? "跳过未来 occurrence 失败;如果已物化请改用 schedule.delete" + : result.status.message); + } + if (result.exception.has_value() && result.exception->type == schedule::ExceptionType::kSkip) { + return Output({ + MakeToolOutput("status", ToolOutputValue::String("success")), + MakeToolOutput("message", ToolOutputValue::String("已跳过周期规则 rule_id=" + std::to_string(command.rule_id) + " 在 " + properties.value("original_start_time").value() + " 的 occurrence")), + MakeToolOutput("exception", schedule_tool_output::ExceptionOutput(*result.exception)), + MakeToolOutput("warnings", ToolOutputValue::Array(ToolOutputArray{})), + }); + } + return FailureOutput("跳过 occurrence 未返回有效 exception"); }); - }); - if (!status.ok()) return status; + if (!status.ok()) return status; + } // 只有装配操作记录服务时才暴露查询工具;基础运行时保持原有四个日程工具。 if (operation_service == nullptr) return Status::Ok(); diff --git a/components/voicelife_mcp/src/tools/schedule_mcp_tools_input.cc b/components/voicelife_mcp/src/tools/schedule_mcp_tools_input.cc index 6b8178f8..eca1d656 100644 --- a/components/voicelife_mcp/src/tools/schedule_mcp_tools_input.cc +++ b/components/voicelife_mcp/src/tools/schedule_mcp_tools_input.cc @@ -2,8 +2,10 @@ #include #include +#include #include #include +#include #include "schedule_tool_output.h" #include "voicelife/schedule/schedule_rule_commands.h" @@ -39,26 +41,104 @@ std::optional JsonInteger(const JsonValue& object, const std::string& k return static_cast(value->number); } -PropertyList RepeatProperties() { - return PropertyList({ - Property("freq_type", PropertyType::kString) - .with_description( - "周期频率,只能取 daily、weekly、monthly、yearly。不要把不支持的自然语言规则近似成另一种频率"), - Property("interval_val", PropertyType::kInteger, int64_t{1}) - .with_description("周期间隔,例如 1 表示每天、每周、每月或每年一次"), - Property("start_date", PropertyType::kString).with_description("周期规则开始日期,格式 YYYY-MM-DD"), - Property("start_time", PropertyType::kString).with_description("周期日程每日开始时间,格式 HH:mm:ss"), - Property::Optional("end_time", PropertyType::kString).with_description("周期日程每日结束时间,格式 HH:mm:ss"), - Property::Optional("end_date", PropertyType::kString).with_description("周期规则结束日期,格式 YYYY-MM-DD"), - Property::Optional("occurrence_count", PropertyType::kInteger).with_description("周期规则最多发生的次数"), - Property::Optional("weekdays_mask", PropertyType::kInteger) - .with_description("每周重复的星期掩码,weekly 模式使用;只表达指定星期,不表达每月第 N 个星期几"), - Property::Optional("day_of_month", PropertyType::kInteger) - .with_description("每月或每年重复的固定日期;不支持每月第 N 个星期几或最后一个工作日"), - Property::Optional("month_of_year", PropertyType::kInteger).with_description("每年重复的月份,yearly 模式使用"), - Property::Optional("monthly_mode", PropertyType::kString) - .with_description("月重复模式,只能取 specific_day 或 last_day;不支持 ordinal weekday、工作日等相对规则"), - }); +ParsedRepeat ParseFlat(const PropertyList& properties, bool require_anchor) { + ParsedRepeat parsed; + const auto frequency = properties.value("freq_type"); + if (frequency.has_value()) { + parsed.freq_type = ParseFrequency(*frequency); + if (!parsed.freq_type.has_value()) { + parsed.error = "freq_type 必须是 daily、weekly、monthly 或 yearly;请不要传入其他周期名称"; + return parsed; + } + } + + const auto start_time = properties.value("start_time"); + if (start_time.has_value()) { + parsed.start_time = schedule_tool_output::ParseLocalTime(*start_time); + if (!parsed.start_time.has_value()) { + parsed.error = "start_time 必须是严格的 HH:mm:ss 格式,例如 08:30:00"; + return parsed; + } + } + const auto end_time = properties.value("end_time"); + if (end_time.has_value()) { + parsed.end_time = schedule_tool_output::ParseLocalTime(*end_time); + if (!parsed.end_time.has_value()) { + parsed.error = "end_time 必须是严格的 HH:mm:ss 格式,例如 09:30:00"; + return parsed; + } + } + const auto start_date = properties.value("start_date"); + if (start_date.has_value()) { + parsed.start_date = schedule_tool_output::ParseLocalDate(*start_date); + if (!parsed.start_date.has_value()) { + parsed.error = "start_date 必须是严格的 YYYY-MM-DD 格式,例如 2026-08-27"; + return parsed; + } + } + const auto end_date = properties.value("end_date"); + if (end_date.has_value()) { + parsed.end_date = schedule_tool_output::ParseLocalDate(*end_date); + if (!parsed.end_date.has_value()) { + parsed.error = "end_date 必须是严格的 YYYY-MM-DD 格式,例如 2026-12-31"; + return parsed; + } + } + + const auto interval = properties.value("interval_val"); + if (interval.has_value()) { + if (*interval < 1 || *interval > std::numeric_limits::max()) { + parsed.error = "interval_val 必须是 1 到 2147483647 之间的整数"; + return parsed; + } + parsed.interval_val = static_cast(*interval); + } + const auto weekdays = properties.value("weekdays_mask"); + if (weekdays.has_value()) { + if (*weekdays < 1 || *weekdays > 127) { + parsed.error = "weekdays_mask 必须是 1 到 127 之间的整数;仅 weekly 周期需要传入"; + return parsed; + } + parsed.weekdays_mask = static_cast(*weekdays); + } + const auto day = properties.value("day_of_month"); + if (day.has_value()) { + if (*day < 1 || *day > 31) { + parsed.error = "day_of_month 必须是 1 到 31 之间的整数;按月指定日期时传入"; + return parsed; + } + parsed.day_of_month = static_cast(*day); + } + const auto month = properties.value("month_of_year"); + if (month.has_value()) { + if (*month < 1 || *month > 12) { + parsed.error = "month_of_year 必须是 1 到 12 之间的整数;yearly 周期需要传入"; + return parsed; + } + parsed.month_of_year = static_cast(*month); + } + const auto mode = properties.value("monthly_mode"); + if (mode.has_value()) { + parsed.monthly_mode = ParseMonthlyMode(*mode); + if (!parsed.monthly_mode.has_value()) { + parsed.error = "monthly_mode 只能是 specific_day 或 last_day;仅 monthly 周期使用"; + return parsed; + } + } + const auto count = properties.value("occurrence_count"); + if (count.has_value()) { + if (*count < 1 || *count > std::numeric_limits::max()) { + parsed.error = "occurrence_count 必须是 1 到 2147483647 之间的整数"; + return parsed; + } + parsed.occurrence_count = static_cast(*count); + } + + if (require_anchor && (!parsed.freq_type.has_value() || !parsed.start_time.has_value() || + !parsed.start_date.has_value())) { + parsed.error = "创建周期日程时必须传入 freq_type、start_date 和 start_time;这些字段不能省略"; + } + return parsed; } bool IsKnownRepeatField(const std::string& key) { @@ -177,6 +257,10 @@ ParsedRepeat ParseRepeat(const std::optional& repeat, bool require_an return parsed; } +ParsedRepeat ParseRuleProperties(const PropertyList& properties, bool require_anchor) { + return ParseFlat(properties, require_anchor); +} + schedule::CreateScheduleRuleCommand CreateRuleCommand(const PropertyList& properties, const ParsedRepeat& repeat) { schedule::CreateScheduleRuleCommand command; command.event = properties.value("event").value_or(""); @@ -224,22 +308,52 @@ schedule::UpdateScheduleRuleCommand UpdateRuleCommand(const PropertyList& proper PropertyList CreateProperties() { return PropertyList({ - Property("event", PropertyType::kString).with_description("日程标题或事件内容"), + Property("event", PropertyType::kString).with_description("一次性日程标题或事件内容"), Property::Optional("start_time", PropertyType::kString) .with_description("一次性日程开始时间,格式 YYYY-MM-DD HH:mm:ss。不传表示无明确开始时间"), Property::Optional("end_time", PropertyType::kString) .with_description("一次性日程结束时间,格式 YYYY-MM-DD HH:mm:ss。不传表示无明确结束时间"), - Property::Optional("location", PropertyType::kString).with_description("日程地点"), - Property::Optional("notes", PropertyType::kString).with_description("日程备注"), + Property::Optional("location", PropertyType::kString).with_description("一次性日程地点"), + Property::Optional("notes", PropertyType::kString).with_description("一次性日程备注"), Property("ignore_conflict", PropertyType::kBoolean, bool{false}) .with_description("是否忽略时间冲突;为 true 时直接创建并返回创建后的日程"), - Property::OptionalObject("repeat", RepeatProperties()) - .with_description("周期规则。不传时创建一次性日程,传入时创建周期日程并生成未来实例"), + }); +} + +PropertyList CreateRuleProperties() { + return PropertyList({ + Property("event", PropertyType::kString).with_description("周期日程标题或事件内容"), + Property("freq_type", PropertyType::kString) + .with_description("周期频率,只能是 daily、weekly、monthly、yearly"), + Property("start_date", PropertyType::kString).with_description("周期规则开始日期,格式 YYYY-MM-DD"), + Property("start_time", PropertyType::kString).with_description("每次 occurrence 的开始时间,格式 HH:mm:ss"), + Property::Optional("end_time", PropertyType::kString) + .with_description("每次 occurrence 的结束时间,格式 HH:mm:ss"), + Property::Optional("location", PropertyType::kString).with_description("周期日程地点"), + Property::Optional("notes", PropertyType::kString).with_description("周期日程备注"), + Property("interval_val", PropertyType::kInteger, int64_t{1}) + .with_description("重复间隔,默认 1,必须为正整数"), + Property::Optional("weekdays_mask", PropertyType::kInteger) + .with_description("仅 weekly 使用,按位表示星期一至星期日,范围 1 到 127"), + Property::Optional("day_of_month", PropertyType::kInteger) + .with_description("monthly 或 yearly 按固定日期重复时使用,范围 1 到 31"), + Property::Optional("month_of_year", PropertyType::kInteger) + .with_description("仅 yearly 使用,表示月份,范围 1 到 12"), + Property::Optional("monthly_mode", PropertyType::kString) + .with_description("仅 monthly 使用,只能是 specific_day 或 last_day"), + Property::Optional("end_date", PropertyType::kString).with_description("周期结束日期,格式 YYYY-MM-DD"), + Property::Optional("occurrence_count", PropertyType::kInteger).with_description("最多发生次数"), + Property("ignore_conflict", PropertyType::kBoolean, bool{false}) + .with_description("是否忽略首条实例与已有日程的时间冲突"), }); } PropertyList QueryProperties() { return PropertyList({ + Property::Optional("schedule_id", PropertyType::kInteger) + .with_description("查询一条已物化 schedule;可指向一次性日程或周期实例。与 rule_id 互斥"), + Property::Optional("rule_id", PropertyType::kInteger) + .with_description("查询一条周期规则及其已物化实例、未来 occurrence、exception;与 schedule_id 互斥"), Property::Optional("keyword", PropertyType::kString).with_description("按日程标题或备注模糊搜索"), Property("status", PropertyType::kString, std::string("active")) .with_description("日程状态筛选,取值为 all、active、cancelled、completed"), @@ -251,15 +365,7 @@ PropertyList QueryProperties() { PropertyList UpdateProperties() { return PropertyList({ Property::Optional("schedule_id", PropertyType::kInteger) - .with_description("更新或取消已物化日程时使用的日程 ID,由 schedule.query 返回"), - Property::Optional("expected_event", PropertyType::kString) - .with_description("取消单次日程时,必须逐字回传 schedule.query 返回的 event 以确认目标"), - Property::Optional("expected_start_time", PropertyType::kString) - .with_description("取消单次日程时,必须逐字回传 schedule.query 返回的 start_time 以确认目标"), - Property::Optional("rule_id", PropertyType::kInteger) - .with_description("更新未来周期实例或整条周期规则时使用的规则 ID"), - Property::Optional("original_start_time", PropertyType::kString) - .with_description("未来周期实例的原始发生时间,格式 YYYY-MM-DD HH:mm:ss"), + .with_description("要修改的 schedule 表记录 ID;由 schedule.query 返回。不要传 rule_id 或 original_start_time"), Property::Optional("event", PropertyType::kString).with_description("新的日程标题"), Property::Optional("start_time", PropertyType::kString) .with_description("新的开始时间,格式 YYYY-MM-DD HH:mm:ss"), @@ -267,24 +373,78 @@ PropertyList UpdateProperties() { .with_description("新的结束时间,格式 YYYY-MM-DD HH:mm:ss"), Property::Optional("location", PropertyType::kString).with_description("新的地点"), Property::Optional("notes", PropertyType::kString).with_description("新的备注"), - Property::Optional("status", PropertyType::kString) - .with_description("更新日程状态;跳过某次周期日程时传 cancelled,恢复时传 active"), Property("ignore_conflict", PropertyType::kBoolean, bool{false}).with_description("是否忽略时间冲突"), - Property::OptionalObject("repeat", RepeatProperties()).with_description("更新周期规则时使用的新周期配置"), + }); +} + +PropertyList UpdateOccurrenceProperties() { + return PropertyList({ + Property::Optional("rule_id", PropertyType::kInteger) + .with_description("周期规则 ID;只用于定位未来 occurrence"), + Property::Optional("original_start_time", PropertyType::kString) + .with_description("未来 occurrence 的原始发生时间,格式 YYYY-MM-DD HH:mm:ss"), + Property::Optional("event", PropertyType::kString).with_description("仅覆盖这一次 occurrence 的标题"), + Property::Optional("start_time", PropertyType::kString) + .with_description("仅覆盖这一次 occurrence 的开始时间,格式 YYYY-MM-DD HH:mm:ss"), + Property::Optional("end_time", PropertyType::kString) + .with_description("仅覆盖这一次 occurrence 的结束时间,格式 YYYY-MM-DD HH:mm:ss"), + Property::Optional("location", PropertyType::kString).with_description("仅覆盖这一次 occurrence 的地点"), + Property::Optional("notes", PropertyType::kString).with_description("仅覆盖这一次 occurrence 的备注"), + Property("ignore_conflict", PropertyType::kBoolean, bool{false}).with_description("是否忽略时间冲突"), + }); +} + +PropertyList UpdateRuleProperties() { + return PropertyList({ + Property::Optional("rule_id", PropertyType::kInteger) + .with_description("要修改的整条周期规则 ID;由 schedule.query 返回。不要传 schedule_id 或 original_start_time"), + Property::Optional("event", PropertyType::kString).with_description("新的规则标题"), + Property::Optional("freq_type", PropertyType::kString) + .with_description("新的周期频率,只能是 daily、weekly、monthly、yearly"), + Property::Optional("start_date", PropertyType::kString).with_description("新的规则开始日期,格式 YYYY-MM-DD"), + Property::Optional("start_time", PropertyType::kString).with_description("新的规则开始时间,格式 HH:mm:ss"), + Property::Optional("end_time", PropertyType::kString).with_description("新的规则结束时间,格式 HH:mm:ss"), + Property::Optional("location", PropertyType::kString).with_description("新的规则地点"), + Property::Optional("notes", PropertyType::kString).with_description("新的规则备注"), + Property::Optional("interval_val", PropertyType::kInteger).with_description("新的重复间隔"), + Property::Optional("weekdays_mask", PropertyType::kInteger) + .with_description("weekly 新的星期掩码,范围 1 到 127"), + Property::Optional("day_of_month", PropertyType::kInteger).with_description("新的固定日期,范围 1 到 31"), + Property::Optional("month_of_year", PropertyType::kInteger).with_description("yearly 新的月份,范围 1 到 12"), + Property::Optional("monthly_mode", PropertyType::kString) + .with_description("monthly 新模式,只能是 specific_day 或 last_day"), + Property::Optional("end_date", PropertyType::kString).with_description("新的结束日期,格式 YYYY-MM-DD"), + Property::Optional("occurrence_count", PropertyType::kInteger).with_description("新的最大发生次数"), + Property("ignore_conflict", PropertyType::kBoolean, bool{false}).with_description("是否忽略规则重建后的冲突"), }); } PropertyList DeleteProperties() { return PropertyList({ Property::Optional("schedule_id", PropertyType::kInteger) - .with_description("要删除或取消的单次日程 ID;必须先由 schedule.query 返回"), + .with_description("要取消的 schedule 表记录 ID,可指向一次性日程或已物化周期实例;由 schedule.query 返回。不要传 rule_id 或 original_start_time"), Property::Optional("expected_event", PropertyType::kString) - .with_description("按 schedule_id 删除时,必须逐字回传 schedule.query 返回的 event 以确认目标"), + .with_description("删除前必须从 schedule.query 原样回传该记录的 event,用于确认不会取消错误目标"), Property::Optional("expected_start_time", PropertyType::kString) - .with_description("按 schedule_id 删除时,必须逐字回传 schedule.query 返回的 start_time 以确认目标"), - Property::Optional("rule_id", PropertyType::kInteger).with_description("要删除或取消的周期规则 ID"), + .with_description("删除前必须从 schedule.query 原样回传该记录的 start_time;无开始时间的记录不能通过此确认工具取消"), + }); +} + +PropertyList DeleteRuleProperties() { + return PropertyList({ + Property::Optional("rule_id", PropertyType::kInteger) + .with_description("要取消的整条周期规则 ID;会停止后续 occurrence。不要传 schedule_id 或 original_start_time"), + }); +} + +PropertyList SkipOccurrenceProperties() { + return PropertyList({ + Property::Optional("rule_id", PropertyType::kInteger) + .with_description("周期规则 ID;只用于定位一个未来 occurrence"), Property::Optional("original_start_time", PropertyType::kString) - .with_description("删除未来周期单次时使用的原始发生时间,格式 YYYY-MM-DD HH:mm:ss"), + .with_description("要跳过的原始 occurrence 完整本地开始时间,严格使用 YYYY-MM-DD HH:mm:ss;不是规则的 HH:mm:ss 时间部分"), + Property::Optional("expected_event", PropertyType::kString) + .with_description("从 schedule.query 的 future_occurrences 原样回传 event,用于确认跳过的是正确 occurrence"), }); } diff --git a/components/voicelife_mcp/src/tools/schedule_mcp_tools_input.h b/components/voicelife_mcp/src/tools/schedule_mcp_tools_input.h index 9522a75c..c1ac2ea2 100644 --- a/components/voicelife_mcp/src/tools/schedule_mcp_tools_input.h +++ b/components/voicelife_mcp/src/tools/schedule_mcp_tools_input.h @@ -39,6 +39,14 @@ struct ParsedRepeat { */ ParsedRepeat ParseRepeat(const std::optional& repeat, bool require_anchor); +/** + * @brief 解析扁平周期字段。 + * @param properties MCP 调用参数。 + * @param require_anchor 是否要求 freq_type、start_date 和 start_time 必填。 + * @return 解析后的周期字段或错误。 + */ +ParsedRepeat ParseRuleProperties(const PropertyList& properties, bool require_anchor); + /** * @brief 从 MCP 参数和 repeat 字段构造创建周期规则命令。 * @param properties MCP 调用参数。 @@ -58,15 +66,30 @@ schedule::UpdateScheduleRuleCommand UpdateRuleCommand(const PropertyList& proper /** @brief 创建 schedule.create 工具参数定义。 @return 参数定义。 */ PropertyList CreateProperties(); +/** @brief 创建 schedule.create_rule 工具参数定义。 @return 参数定义。 */ +PropertyList CreateRuleProperties(); + /** @brief 创建 schedule.query 工具参数定义。 @return 参数定义。 */ PropertyList QueryProperties(); /** @brief 创建 schedule.update 工具参数定义。 @return 参数定义。 */ PropertyList UpdateProperties(); +/** @brief 创建 schedule.update_occurrence 工具参数定义。 @return 参数定义。 */ +PropertyList UpdateOccurrenceProperties(); + +/** @brief 创建 schedule.update_rule 工具参数定义。 @return 参数定义。 */ +PropertyList UpdateRuleProperties(); + /** @brief 创建 schedule.delete 工具参数定义。 @return 参数定义。 */ PropertyList DeleteProperties(); +/** @brief 创建 schedule.delete_rule 工具参数定义。 @return 参数定义。 */ +PropertyList DeleteRuleProperties(); + +/** @brief 创建 schedule.skip_occurrence 工具参数定义。 @return 参数定义。 */ +PropertyList SkipOccurrenceProperties(); + /** @brief 创建 schedule.operation_query 工具参数定义。 @return 参数定义。 */ PropertyList OperationQueryProperties(); diff --git a/components/voicelife_runtime/src/runtime.cc b/components/voicelife_runtime/src/runtime.cc index d0a45b51..4b45778a 100644 --- a/components/voicelife_runtime/src/runtime.cc +++ b/components/voicelife_runtime/src/runtime.cc @@ -2665,12 +2665,13 @@ class Runtime final { vTaskDelete(nullptr); #endif } - // Keep production at 70. Serial voice validation runs at half volume so - // physical-board stress tests do not disturb the surrounding environment. + // Production uses full output volume. Serial voice validation runs at + // reduced volume so physical-board stress tests do not disturb the + // surrounding environment. #if defined(ESP_PLATFORM) && CONFIG_VOICELIFE_SERIAL_VOICE_TEST int volume_ = 35; #else - int volume_ = 70; + int volume_ = 100; #endif std::atomic capture_started_us_{0}; std::string stt_display_text_; diff --git a/tests/host/schedule_mcp_tools_branch_test.cc b/tests/host/schedule_mcp_tools_branch_test.cc index 495e8337..1d7e0ef0 100644 --- a/tests/host/schedule_mcp_tools_branch_test.cc +++ b/tests/host/schedule_mcp_tools_branch_test.cc @@ -15,15 +15,15 @@ void CheckRuleUpdateReminderResyncFailure() { const auto created = fixture.server.call({ .request_id = "create-before-resync-failure", - .name = "schedule.create", - .arguments = {{"event", std::string("待重同步规则")}, {"repeat", DailyRepeat("2099-01-01")}}, + .name = "schedule.create_rule", + .arguments = {{"event", std::string("待重同步规则")}, {"freq_type", std::string("daily")}, {"start_date", std::string("2099-01-01")}, {"start_time", std::string("09:00:00")}}, }); Check(created.status.ok() && OutputString(created, "status") == "success", "更新前应成功创建周期规则"); fixture.timing.register_acceptance = CommandAcceptance::kUnavailable; const auto updated = fixture.server.call({ .request_id = "update-rule-resync-failure", - .name = "schedule.update", + .name = "schedule.update_rule", .arguments = {{"rule_id", int64_t{fixture.rules.rules.back().id}}, {"event", std::string("重同步失败规则")}}, }); Check(updated.status.ok() && OutputString(updated, "status") == "failure" && diff --git a/tests/host/schedule_mcp_tools_delete_confirmation_test.cc b/tests/host/schedule_mcp_tools_delete_confirmation_test.cc index 8bc228c0..f15fab53 100644 --- a/tests/host/schedule_mcp_tools_delete_confirmation_test.cc +++ b/tests/host/schedule_mcp_tools_delete_confirmation_test.cc @@ -63,17 +63,16 @@ int main() { "错误 ID 时不得取消任何日程"); const ToolResult wrong_id_update = server.call({ - .request_id = "update-cancel-wrong-id", - .name = "schedule.update", + .request_id = "delete-cancel-wrong-id", + .name = "schedule.delete", .arguments = {{"schedule_id", int64_t{2}}, {"expected_event", std::string("带紫色水彩笔")}, - {"expected_start_time", std::string("2026-08-27 14:13:00")}, - {"status", std::string("cancelled")}}, + {"expected_start_time", std::string("2026-08-27 14:13:00")}}, }); - Check(OutputString(wrong_id_update, "status") == "failure", "update 取消遇到错误 ID 也必须失败"); + Check(OutputString(wrong_id_update, "status") == "failure", "删除遇到错误 ID 也必须失败"); Check(schedules.FindSchedule(1).value->status == ScheduleStatus::kActive && schedules.FindSchedule(2).value->status == ScheduleStatus::kActive, - "update 取消遇到错误 ID 时不得取消任何日程"); + "删除遇到错误 ID 时不得取消任何日程"); const ToolResult correct_target = Call(server, "delete-correct-target", {{"schedule_id", int64_t{1}}, diff --git a/tests/host/schedule_mcp_tools_failure_coverage_test.cc b/tests/host/schedule_mcp_tools_failure_coverage_test.cc index 9d6773b8..98c2d1e3 100644 --- a/tests/host/schedule_mcp_tools_failure_coverage_test.cc +++ b/tests/host/schedule_mcp_tools_failure_coverage_test.cc @@ -346,15 +346,6 @@ std::string OutputString(const ToolResult& result, const std::string& key) { return {}; } -/** @brief 构造每日周期 repeat 对象。 @return repeat JSON 对象。 */ -JsonValue DailyRepeat() { - return JsonValue::Object({ - {"freq_type", JsonValue::String("daily")}, - {"start_date", JsonValue::String("2099-01-01")}, - {"start_time", JsonValue::String("09:00:00")}, - }); -} - /** @brief 构造测试规则。 @param id 规则标识。 @return 周期规则。 */ ScheduleRule Rule(ScheduleRuleId id) { ScheduleRule rule; @@ -414,8 +405,11 @@ int main() { rules.next_insert_failure = Status::Error(ErrorCode::kUnavailable, "规则写入失败"); const auto create_rule_failed = server.call({ .request_id = "create-rule-failed", - .name = "schedule.create", - .arguments = {{"event", std::string("周期失败")}, {"repeat", DailyRepeat()}}, + .name = "schedule.create_rule", + .arguments = {{"event", std::string("周期失败")}, + {"freq_type", std::string("daily")}, + {"start_date", std::string("2099-01-01")}, + {"start_time", std::string("09:00:00")}}, }); Check(OutputString(create_rule_failed, "status") == "failure", "周期规则创建非冲突失败应返回 failure"); @@ -456,7 +450,7 @@ int main() { rules.next_cancel_failure = Status::Error(ErrorCode::kUnavailable, "取消规则失败"); const auto delete_rule_failed = server.call({ .request_id = "delete-rule-failed", - .name = "schedule.delete", + .name = "schedule.delete_rule", .arguments = {{"rule_id", int64_t{600}}}, }); Check(OutputString(delete_rule_failed, "status") == "failure", "取消周期规则失败应返回 failure"); @@ -464,7 +458,7 @@ int main() { exceptions.next_upsert_failure = Status::Error(ErrorCode::kUnavailable, "跳过失败"); const auto delete_occurrence_failed = server.call({ .request_id = "delete-occurrence-failed", - .name = "schedule.delete", + .name = "schedule.skip_occurrence", .arguments = {{"rule_id", int64_t{600}}, {"original_start_time", std::string("2099-01-03 09:00:00")}}, }); Check(OutputString(delete_occurrence_failed, "status") == "failure", "删除未来单次失败应返回 failure"); @@ -472,7 +466,7 @@ int main() { exceptions.next_upsert_failure = Status::Error(ErrorCode::kUnavailable, "单次更新失败"); const auto update_occurrence_failed = server.call({ .request_id = "update-occurrence-failed", - .name = "schedule.update", + .name = "schedule.update_occurrence", .arguments = {{"rule_id", int64_t{600}}, {"original_start_time", std::string("2099-01-04 09:00:00")}, {"event", std::string("失败更新")}}, @@ -531,14 +525,13 @@ int main() { failing_schedules.find_by_id_failure.reset(); failing_schedules.delete_failure = Status::Error(ErrorCode::kUnavailable, "取消失败"); const auto update_cancel_failed = failing_server.call({ - .request_id = "update-cancel-failed", - .name = "schedule.update", + .request_id = "delete-cancel-failed", + .name = "schedule.delete", .arguments = {{"schedule_id", int64_t{20}}, {"expected_event", std::string("待更新日程")}, - {"expected_start_time", std::string("2030-03-18 01:46:40")}, - {"status", std::string("cancelled")}}, + {"expected_start_time", std::string("2030-03-18 01:46:40")}}, }); - Check(OutputString(update_cancel_failed, "status") == "failure", "update 取消失败应返回 failure"); + Check(OutputString(update_cancel_failed, "status") == "failure", "delete 取消失败应返回 failure"); failing_schedules.delete_failure.reset(); failing_schedules.find_failure = Status::Error(ErrorCode::kUnavailable, "删除快照失败"); diff --git a/tests/host/schedule_mcp_tools_input_test.cc b/tests/host/schedule_mcp_tools_input_test.cc index 9b4f2ee4..9515b2e1 100644 --- a/tests/host/schedule_mcp_tools_input_test.cc +++ b/tests/host/schedule_mcp_tools_input_test.cc @@ -13,11 +13,16 @@ using voicelife::JsonValue; using voicelife::mcp::PropertyList; using voicelife::mcp::schedule_tool_input::CreateProperties; using voicelife::mcp::schedule_tool_input::CreateRuleCommand; +using voicelife::mcp::schedule_tool_input::CreateRuleProperties; using voicelife::mcp::schedule_tool_input::DeleteProperties; +using voicelife::mcp::schedule_tool_input::DeleteRuleProperties; using voicelife::mcp::schedule_tool_input::ParseRepeat; +using voicelife::mcp::schedule_tool_input::SkipOccurrenceProperties; using voicelife::mcp::schedule_tool_input::QueryProperties; +using voicelife::mcp::schedule_tool_input::UpdateOccurrenceProperties; using voicelife::mcp::schedule_tool_input::UpdateProperties; using voicelife::mcp::schedule_tool_input::UpdateRuleCommand; +using voicelife::mcp::schedule_tool_input::UpdateRuleProperties; using voicelife::schedule::Frequency; using voicelife::schedule::MonthlyMode; using voicelife::test::Check; @@ -103,11 +108,18 @@ int main() { update.monthly_mode == MonthlyMode::kSpecificDay && update.occurrence_count == 7, "UpdateRuleCommand 应把 repeat 字段写入更新命令"); - Check(CreateProperties().to_schema().properties.contains("repeat"), "create 工具应声明 repeat 参数"); + Check(CreateProperties().to_schema().properties.contains("event"), "create 工具应声明 event 参数"); + Check(CreateRuleProperties().to_schema().properties.contains("freq_type"), "create_rule 工具应声明 freq_type 参数"); Check(QueryProperties().to_schema().properties.contains("keyword"), "query 工具应声明 keyword 参数"); - Check(UpdateProperties().to_schema().properties.contains("repeat"), "update 工具应声明 repeat 参数"); - Check(DeleteProperties().to_schema().properties.contains("rule_id"), "delete 工具应声明 rule_id 参数"); + Check(UpdateProperties().to_schema().properties.contains("schedule_id"), "update 工具应声明 schedule_id 参数"); + Check(UpdateOccurrenceProperties().to_schema().properties.contains("original_start_time"), + "update_occurrence 工具应声明 original_start_time 参数"); + Check(UpdateRuleProperties().to_schema().properties.contains("freq_type"), "update_rule 工具应声明 freq_type 参数"); + Check(DeleteProperties().to_schema().properties.contains("schedule_id"), "delete 工具应声明 schedule_id 参数"); Check(DeleteProperties().to_schema().properties.contains("expected_event"), "delete 工具应声明目标确认事件"); Check(DeleteProperties().to_schema().properties.contains("expected_start_time"), "delete 工具应声明目标确认时间"); + Check(DeleteRuleProperties().to_schema().properties.contains("rule_id"), "delete_rule 工具应声明 rule_id 参数"); + Check(SkipOccurrenceProperties().to_schema().properties.contains("original_start_time"), + "skip_occurrence 工具应声明 original_start_time 参数"); return 0; } diff --git a/tests/host/schedule_mcp_tools_reminder_test.cc b/tests/host/schedule_mcp_tools_reminder_test.cc index 7a7ec66e..90dd0b1a 100644 --- a/tests/host/schedule_mcp_tools_reminder_test.cc +++ b/tests/host/schedule_mcp_tools_reminder_test.cc @@ -270,14 +270,6 @@ std::optional OutputInteger(const ToolResult& result, const std::string return std::nullopt; } -JsonValue DailyRepeat(const std::string& start_date = "2099-01-01") { - return JsonValue::Object({ - {"freq_type", JsonValue::String("daily")}, - {"start_date", JsonValue::String(start_date)}, - {"start_time", JsonValue::String("09:00:00")}, - }); -} - void CheckOneShotReminderLifecycle() { InMemoryScheduleRepository schedules; FakeExceptionRepository exceptions; @@ -441,22 +433,22 @@ void CheckRuleReminderSuccessPaths() { const auto created = fixture.server.call({ .request_id = "create-rule-reminder-success", - .name = "schedule.create", - .arguments = {{"event", std::string("可同步规则")}, {"repeat", DailyRepeat("2099-01-01")}}, + .name = "schedule.create_rule", + .arguments = {{"event", std::string("可同步规则")}, {"freq_type", std::string("daily")}, {"start_date", std::string("2099-01-01")}, {"start_time", std::string("09:00:00")}}, }); Check(created.status.ok() && OutputString(created, "status") == "success", "创建周期规则并同步提醒应成功"); const ScheduleRuleId rule_id = fixture.rules.rules.back().id; const auto updated = fixture.server.call({ .request_id = "update-rule-reminder-success", - .name = "schedule.update", + .name = "schedule.update_rule", .arguments = {{"rule_id", int64_t{rule_id}}, {"event", std::string("更新后的可同步规则")}}, }); Check(updated.status.ok() && OutputString(updated, "status") == "success", "更新周期规则并同步提醒应成功"); const auto deleted = fixture.server.call({ .request_id = "delete-rule-reminder-success", - .name = "schedule.delete", + .name = "schedule.delete_rule", .arguments = {{"rule_id", int64_t{rule_id}}}, }); Check(deleted.status.ok() && OutputString(deleted, "status") == "success", "删除周期规则并撤销提醒应成功"); @@ -472,8 +464,8 @@ void CheckRuleReminderRollbackSyncPaths() { "带提醒服务工具应注册成功"); const auto update_created = update_fixture.server.call({ .request_id = "create-rule-before-update-failure", - .name = "schedule.create", - .arguments = {{"event", std::string("更新失败前规则")}, {"repeat", DailyRepeat("2099-01-01")}}, + .name = "schedule.create_rule", + .arguments = {{"event", std::string("更新失败前规则")}, {"freq_type", std::string("daily")}, {"start_date", std::string("2099-01-01")}, {"start_time", std::string("09:00:00")}}, }); Check(update_created.status.ok() && OutputString(update_created, "status") == "success", "更新失败同步测试应先创建规则"); @@ -481,7 +473,7 @@ void CheckRuleReminderRollbackSyncPaths() { update_fixture.rules.next_update_failure = Status::Error(ErrorCode::kUnavailable, "规则更新失败"); const auto update_failed = update_fixture.server.call({ .request_id = "update-rule-then-sync", - .name = "schedule.update", + .name = "schedule.update_rule", .arguments = {{"rule_id", int64_t{update_fixture.rules.rules.back().id}}, {"event", std::string("更新失败")}}, }); Check(update_failed.status.ok() && OutputString(update_failed, "status") == "failure", @@ -496,8 +488,8 @@ void CheckRuleReminderRollbackSyncPaths() { "带提醒服务工具应注册成功"); const auto delete_created = delete_fixture.server.call({ .request_id = "create-rule-before-delete-failure", - .name = "schedule.create", - .arguments = {{"event", std::string("删除失败前规则")}, {"repeat", DailyRepeat("2099-01-01")}}, + .name = "schedule.create_rule", + .arguments = {{"event", std::string("删除失败前规则")}, {"freq_type", std::string("daily")}, {"start_date", std::string("2099-01-01")}, {"start_time", std::string("09:00:00")}}, }); Check(delete_created.status.ok() && OutputString(delete_created, "status") == "success", "删除失败同步测试应先创建规则"); @@ -505,7 +497,7 @@ void CheckRuleReminderRollbackSyncPaths() { delete_fixture.rules.next_cancel_rule_failure = Status::Error(ErrorCode::kUnavailable, "规则删除失败"); const auto delete_failed = delete_fixture.server.call({ .request_id = "delete-rule-then-sync", - .name = "schedule.delete", + .name = "schedule.delete_rule", .arguments = {{"rule_id", int64_t{delete_fixture.rules.rules.back().id}}}, }); Check(delete_failed.status.ok() && OutputString(delete_failed, "status") == "failure", @@ -524,8 +516,8 @@ void CheckRuleReminderSyncFailurePaths() { const auto rule_create_failed = create_fail_fixture.server.call({ .request_id = "create-rule-sync-failed", - .name = "schedule.create", - .arguments = {{"event", std::string("创建规则失败")}, {"repeat", DailyRepeat("2099-01-01")}}, + .name = "schedule.create_rule", + .arguments = {{"event", std::string("创建规则失败")}, {"freq_type", std::string("daily")}, {"start_date", std::string("2099-01-01")}, {"start_time", std::string("09:00:00")}}, }); Check(rule_create_failed.status.ok() && OutputString(rule_create_failed, "status") == "failure" && OutputString(rule_create_failed, "message").find("提醒同步失败") != std::string::npos, @@ -539,15 +531,15 @@ void CheckRuleReminderSyncFailurePaths() { "带提醒服务工具应注册成功"); const auto rule_create = fixture.server.call({ .request_id = "create-rule-for-update", - .name = "schedule.create", - .arguments = {{"event", std::string("可更新规则")}, {"repeat", DailyRepeat("2099-01-01")}}, + .name = "schedule.create_rule", + .arguments = {{"event", std::string("可更新规则")}, {"freq_type", std::string("daily")}, {"start_date", std::string("2099-01-01")}, {"start_time", std::string("09:00:00")}}, }); Check(rule_create.status.ok() && OutputString(rule_create, "status") == "success", "正常周期规则创建应成功"); fixture.timing.cancel_acceptance = CommandAcceptance::kUnavailable; const auto rule_update_failed = fixture.server.call({ .request_id = "update-rule-suspend-failed", - .name = "schedule.update", + .name = "schedule.update_rule", .arguments = {{"rule_id", int64_t{600}}, {"event", std::string("更新规则失败")}}, }); Check(rule_update_failed.status.ok() && OutputString(rule_update_failed, "status") == "failure" && @@ -556,7 +548,7 @@ void CheckRuleReminderSyncFailurePaths() { const auto rule_delete_failed = fixture.server.call({ .request_id = "delete-rule-suspend-failed", - .name = "schedule.delete", + .name = "schedule.delete_rule", .arguments = {{"rule_id", int64_t{600}}}, }); Check(rule_delete_failed.status.ok() && OutputString(rule_delete_failed, "status") == "failure" && diff --git a/tests/host/schedule_mcp_tools_test.cc b/tests/host/schedule_mcp_tools_test.cc index 7401940a..e15da941 100644 --- a/tests/host/schedule_mcp_tools_test.cc +++ b/tests/host/schedule_mcp_tools_test.cc @@ -190,15 +190,6 @@ class FakeRuleRepository final : public voicelife::schedule::ScheduleRuleReposit FakeExceptionRepository& exceptions_; }; -/** @brief 构造每日周期 repeat 对象。 @return 完整周期 repeat JSON 对象。 */ -JsonValue DailyRepeat() { - return JsonValue::Object({ - {"freq_type", JsonValue::String("daily")}, - {"start_date", JsonValue::String("2099-01-01")}, - {"start_time", JsonValue::String("09:00:00")}, - }); -} - /** @brief 从工具输出对象中读取字符串字段。 @param result 工具结果。 @param key 字段名。 @return 字段值或空。 */ std::string OutputString(const ToolResult& result, const std::string& key) { if (!result.output.IsObject()) return {}; @@ -228,7 +219,7 @@ int main() { Check(voicelife::mcp::RegisterScheduleMcpTools(server, service, rule_service).ok(), "日程工具应注册成功"); const auto listed = server.list_tools(); - Check(listed.total == 4, "日程工具应注册四个工具"); + Check(listed.total == 9, "启用周期能力时应注册九个日程工具"); // schedule.create:一次性日程的各个字段与错误路径。 const auto one_shot = server.call({ @@ -259,28 +250,31 @@ int main() { // schedule.create:周期日程创建成功,返回规则与物化首条实例。 const auto rule_create = server.call({ .request_id = "create-rule", - .name = "schedule.create", - .arguments = {{"event", std::string("每日站会")}, {"repeat", DailyRepeat()}}, + .name = "schedule.create_rule", + .arguments = {{"event", std::string("每日站会")}, + {"freq_type", std::string("daily")}, + {"start_date", std::string("2099-01-01")}, + {"start_time", std::string("09:00:00")}}, }); Check(rule_create.status.ok() && OutputString(rule_create, "status") == "success", "周期日程应创建成功"); // schedule.create:周期日程缺少 anchor 字段应失败。 const auto missing_anchor = server.call({ .request_id = "create-rule-missing-anchor", - .name = "schedule.create", + .name = "schedule.create_rule", .arguments = {{"event", std::string("缺字段")}, - {"repeat", JsonValue::Object({{"freq_type", JsonValue::String("daily")}})}}, + {"freq_type", std::string("daily")}}, }); Check(!missing_anchor.status.ok(), "周期日程缺少 anchor 应被参数校验拒绝"); // schedule.create:周期日程 repeat 非法频率应失败。 const auto bad_repeat = server.call({ .request_id = "create-rule-bad-freq", - .name = "schedule.create", + .name = "schedule.create_rule", .arguments = {{"event", std::string("坏频率")}, - {"repeat", JsonValue::Object({{"freq_type", JsonValue::String("bad")}, - {"start_date", JsonValue::String("2099-01-01")}, - {"start_time", JsonValue::String("09:00:00")}})}}, + {"freq_type", std::string("bad")}, + {"start_date", std::string("2099-01-01")}, + {"start_time", std::string("09:00:00")}}, }); Check(OutputString(bad_repeat, "status") == "failure", "非法 repeat.freq_type 应失败"); @@ -292,8 +286,9 @@ int main() { }); Check(queried.status.ok() && OutputString(queried, "status") == "success", "查询应返回成功结果"); Check(!queried.text_output.has_value(), "查询应返回结构化 JSON 而非文本摘要"); - Check(OutputArraySize(queried, "schedules") >= 1, "查询结果应包含结构化 schedules 数组"); - Check(OutputString(queried, "message") == "query success", "查询结果应包含结构化 message 字段"); + Check(OutputArraySize(queried, "one_time_schedules") >= 1, "查询结果应包含一次性日程数组"); + Check(OutputArraySize(queried, "recurring_schedules") >= 1, "查询结果应包含周期实例数组"); + Check(!OutputString(queried, "message").empty(), "查询结果应包含结构化 message 字段"); // schedule.query:带日期范围与关键字,触发规则未来 occurrence 与例外展开。 const auto queried_range = server.call({ @@ -346,23 +341,23 @@ int main() { const auto impossible_repeat_date = server.call({ .request_id = "create-impossible-repeat-date", - .name = "schedule.create", + .name = "schedule.create_rule", .arguments = {{"event", std::string("非法周期日期")}, - {"repeat", JsonValue::Object({{"freq_type", JsonValue::String("daily")}, - {"start_date", JsonValue::String("2099-02-29")}, - {"start_time", JsonValue::String("09:00:00")}})}}, + {"freq_type", std::string("daily")}, + {"start_date", std::string("2099-02-29")}, + {"start_time", std::string("09:00:00")}}, }); Check(OutputString(impossible_repeat_date, "status") == "failure", "不存在的周期起始日期应失败"); const auto unsupported_repeat_field = server.call({ .request_id = "create-unsupported-repeat-field", - .name = "schedule.create", + .name = "schedule.create_rule", .arguments = {{"event", std::string("不支持的相对周期")}, - {"repeat", JsonValue::Object({{"freq_type", JsonValue::String("monthly")}, - {"start_date", JsonValue::String("2099-01-01")}, - {"start_time", JsonValue::String("09:00:00")}, - {"monthly_mode", JsonValue::String("ordinal_weekday")}, - {"weekday_ordinal", JsonValue::Number(2)}})}}, + {"freq_type", std::string("monthly")}, + {"start_date", std::string("2099-01-01")}, + {"start_time", std::string("09:00:00")}, + {"monthly_mode", std::string("ordinal_weekday")}, + {"weekday_ordinal", int64_t{2}}}, }); Check(!unsupported_repeat_field.status.ok() || OutputString(unsupported_repeat_field, "status") == "failure", "未声明的周期字段不得被静默忽略"); @@ -390,7 +385,7 @@ int main() { .name = "schedule.update", .arguments = {{"schedule_id", int64_t{1}}, {"rule_id", int64_t{600}}}, }); - Check(OutputString(both_ids, "status") == "failure", "schedule_id 与 rule_id 同用应失败"); + Check(!both_ids.status.ok(), "schedule_id 与 rule_id 同用应失败"); // schedule.update:original_start_time 必须与 rule_id 一起使用。 const auto orphan_time = server.call({ @@ -398,7 +393,7 @@ int main() { .name = "schedule.update", .arguments = {{"original_start_time", std::string("2099-01-05 09:00:00")}}, }); - Check(OutputString(orphan_time, "status") == "failure", "单独 original_start_time 应失败"); + Check(!orphan_time.status.ok(), "单独 original_start_time 应失败"); // schedule.update:按 schedule_id 修改已物化一次性日程。 const auto update_schedule = server.call({ @@ -417,20 +412,20 @@ int main() { }); Check(OutputString(update_bad_start, "status") == "failure", "更新非法开始时间应失败"); - // schedule.update:按 rule_id + original_start_time 跳过未来单次。 + // schedule.skip_occurrence:按 rule_id + original_start_time 跳过未来单次。 const auto skip_occurrence = server.call({ .request_id = "update-skip", - .name = "schedule.update", + .name = "schedule.skip_occurrence", .arguments = {{"rule_id", int64_t{600}}, {"original_start_time", std::string("2099-01-05 09:00:00")}, - {"status", std::string("cancelled")}}, + {"expected_event", std::string("每日站会")}}, }); Check(skip_occurrence.status.ok() && OutputString(skip_occurrence, "status") == "success", "跳过未来单次应成功"); - // schedule.update:按 rule_id + original_start_time 修改未来单次。 + // schedule.update_occurrence:按 rule_id + original_start_time 修改未来单次。 const auto update_occurrence = server.call({ .request_id = "update-occurrence", - .name = "schedule.update", + .name = "schedule.update_occurrence", .arguments = {{"rule_id", int64_t{600}}, {"original_start_time", std::string("2099-01-06 09:00:00")}, {"event", std::string("改期站会")}}, @@ -438,22 +433,22 @@ int main() { Check(update_occurrence.status.ok() && OutputString(update_occurrence, "status") == "success", "修改未来单次应成功"); - // schedule.update:按 rule_id 更新整条规则。 + // schedule.update_rule:按 rule_id 更新整条规则。 const auto update_rule = server.call({ .request_id = "update-rule", - .name = "schedule.update", + .name = "schedule.update_rule", .arguments = {{"rule_id", int64_t{600}}, {"event", std::string("每日站会(改)")}}, }); Check(update_rule.status.ok() && OutputString(update_rule, "status") == "success", "更新整条规则应成功"); - // schedule.update:按 rule_id 更新时非法 repeat 应失败。 + // schedule.update_rule:按 rule_id 更新时非法 repeat 应失败。 const auto update_rule_bad_repeat = server.call({ .request_id = "update-rule-bad-repeat", - .name = "schedule.update", + .name = "schedule.update_rule", .arguments = {{"rule_id", int64_t{600}}, - {"repeat", JsonValue::Object({{"freq_type", JsonValue::String("bad")}, - {"start_date", JsonValue::String("2099-01-01")}, - {"start_time", JsonValue::String("09:00:00")}})}}, + {"freq_type", std::string("bad")}, + {"start_date", std::string("2099-01-01")}, + {"start_time", std::string("09:00:00")}}, }); Check(OutputString(update_rule_bad_repeat, "status") == "failure", "非法 repeat 应失败"); @@ -473,13 +468,13 @@ int main() { }); Check(OutputString(delete_no_id, "status") == "failure", "缺少 schedule_id 或 rule_id 应失败"); - // schedule.delete:schedule_id 与 rule_id 同用应失败。 + // schedule.delete:schedule_id 与 rule_id 同用应失败(rule_id 不属于 schedule.delete)。 const auto delete_both = server.call({ .request_id = "delete-both", .name = "schedule.delete", .arguments = {{"schedule_id", int64_t{1}}, {"rule_id", int64_t{600}}}, }); - Check(OutputString(delete_both, "status") == "failure", "删除时 schedule_id 与 rule_id 同用应失败"); + Check(!delete_both.status.ok(), "删除时 schedule_id 与 rule_id 同用应失败"); // schedule.delete:删除不存在的日程应失败。 const auto delete_missing = server.call({ @@ -500,19 +495,21 @@ int main() { Check(delete_schedule.status.ok() && OutputString(delete_schedule, "status") == "success", "按 schedule_id 删除应成功"); - // schedule.delete:按 rule_id + original_start_time 删除未来单次。 + // schedule.skip_occurrence:按 rule_id + original_start_time 删除未来单次。 const auto delete_occurrence = server.call({ .request_id = "delete-occurrence", - .name = "schedule.delete", - .arguments = {{"rule_id", int64_t{600}}, {"original_start_time", std::string("2099-01-07 09:00:00")}}, + .name = "schedule.skip_occurrence", + .arguments = {{"rule_id", int64_t{600}}, + {"original_start_time", std::string("2099-01-07 09:00:00")}, + {"expected_event", std::string("每日站会")}}, }); Check(delete_occurrence.status.ok() && OutputString(delete_occurrence, "status") == "success", "删除未来单次应成功"); - // schedule.delete:按 rule_id 取消整条规则。 + // schedule.delete_rule:按 rule_id 取消整条规则。 const auto delete_rule = server.call({ .request_id = "delete-rule", - .name = "schedule.delete", + .name = "schedule.delete_rule", .arguments = {{"rule_id", int64_t{600}}}, }); Check(delete_rule.status.ok() && OutputString(delete_rule, "status") == "success", "取消整条规则应成功"); @@ -538,12 +535,12 @@ int main() { // 周期规则首条实例与已有日程冲突。 const auto conflict_rule = server.call({ .request_id = "conflict-rule", - .name = "schedule.create", + .name = "schedule.create_rule", .arguments = {{"event", std::string("冲突规则")}, - {"repeat", JsonValue::Object({{"freq_type", JsonValue::String("daily")}, - {"start_date", JsonValue::String("2030-03-18")}, - {"start_time", JsonValue::String("09:30:00")}, - {"end_time", JsonValue::String("10:30:00")}})}}, + {"freq_type", std::string("daily")}, + {"start_date", std::string("2030-03-18")}, + {"start_time", std::string("09:30:00")}, + {"end_time", std::string("10:30:00")}}, }); Check(OutputString(conflict_rule, "status") == "conflict", "周期规则冲突应返回 conflict"); @@ -583,16 +580,15 @@ int main() { }); Check(OutputString(update_bad_end, "status") == "failure", "更新非法 end_time 应失败"); - // 按 schedule_id 取消已物化日程。 + // 按 schedule_id 删除已物化日程。 const auto cancel_by_id = server.call({ - .request_id = "update-cancel", - .name = "schedule.update", + .request_id = "delete-cancel", + .name = "schedule.delete", .arguments = {{"schedule_id", int64_t{4}}, {"expected_event", std::string("更新目标")}, - {"expected_start_time", std::string("2030-05-02 09:00:00")}, - {"status", std::string("cancelled")}}, + {"expected_start_time", std::string("2030-05-02 09:00:00")}}, }); - Check(OutputString(cancel_by_id, "status") == "success", "按 schedule_id 取消应成功"); + Check(OutputString(cancel_by_id, "status") == "success", "按 schedule_id 删除应成功"); // 更新不存在的日程。 const auto update_missing = server.call({ @@ -620,7 +616,7 @@ int main() { // 未来单次:非法 original_start_time。 const auto occ_bad_original = server.call({ .request_id = "occ-bad-original", - .name = "schedule.update", + .name = "schedule.update_occurrence", .arguments = {{"rule_id", int64_t{600}}, {"original_start_time", std::string("bad")}}, }); Check(OutputString(occ_bad_original, "status") == "failure", "非法 original_start_time 应失败"); @@ -628,7 +624,7 @@ int main() { // 未来单次:非法 start_time / end_time。 const auto occ_bad_start = server.call({ .request_id = "occ-bad-start", - .name = "schedule.update", + .name = "schedule.update_occurrence", .arguments = {{"rule_id", int64_t{600}}, {"original_start_time", std::string("2099-01-06 09:00:00")}, {"start_time", std::string("bad")}}, @@ -637,7 +633,7 @@ int main() { const auto occ_bad_end = server.call({ .request_id = "occ-bad-end", - .name = "schedule.update", + .name = "schedule.update_occurrence", .arguments = {{"rule_id", int64_t{600}}, {"original_start_time", std::string("2099-01-06 09:00:00")}, {"end_time", std::string("bad")}}, @@ -647,7 +643,7 @@ int main() { // 未来单次:合法全字段修改。 const auto occ_valid = server.call({ .request_id = "occ-valid", - .name = "schedule.update", + .name = "schedule.update_occurrence", .arguments = {{"rule_id", int64_t{600}}, {"original_start_time", std::string("2099-01-08 09:00:00")}, {"start_time", std::string("2099-01-08 10:00:00")}, @@ -660,7 +656,7 @@ int main() { // 更新不存在的规则。 const auto update_rule_missing = server.call({ .request_id = "update-rule-missing", - .name = "schedule.update", + .name = "schedule.update_rule", .arguments = {{"rule_id", int64_t{9999}}, {"event", std::string("不存在规则")}}, }); Check(OutputString(update_rule_missing, "status") == "failure", "更新不存在规则应失败"); @@ -668,26 +664,29 @@ int main() { // 新建活跃规则后更新为冲突时间。 const auto new_rule = server.call({ .request_id = "new-rule", - .name = "schedule.create", - .arguments = {{"event", std::string("新规则")}, {"repeat", DailyRepeat()}}, + .name = "schedule.create_rule", + .arguments = {{"event", std::string("新规则")}, + {"freq_type", std::string("daily")}, + {"start_date", std::string("2099-01-01")}, + {"start_time", std::string("09:00:00")}}, }); Check(new_rule.status.ok() && OutputString(new_rule, "status") == "success", "新规则应创建成功"); const auto update_rule_conflict = server.call({ .request_id = "update-rule-conflict", - .name = "schedule.update", + .name = "schedule.update_rule", .arguments = {{"rule_id", int64_t{601}}, - {"repeat", JsonValue::Object({{"freq_type", JsonValue::String("daily")}, - {"start_date", JsonValue::String("2030-03-18")}, - {"start_time", JsonValue::String("09:30:00")}, - {"end_time", JsonValue::String("10:30:00")}})}}, + {"freq_type", std::string("daily")}, + {"start_date", std::string("2030-03-18")}, + {"start_time", std::string("09:30:00")}, + {"end_time", std::string("10:30:00")}}, }); Check(OutputString(update_rule_conflict, "status") == "conflict", "更新规则冲突应返回 conflict"); // 删除未来单次:非法 original_start_time。 const auto delete_bad_original = server.call({ .request_id = "delete-bad-original", - .name = "schedule.delete", + .name = "schedule.skip_occurrence", .arguments = {{"rule_id", int64_t{600}}, {"original_start_time", std::string("bad")}}, }); Check(OutputString(delete_bad_original, "status") == "failure", "删除未来单次非法时间应失败"); @@ -708,51 +707,51 @@ int main() { // weekly 规则:覆盖 FrequencyName 的 weekly 分支,并带 end_time 用于后续未来实例展开。 const auto weekly_rule = server.call({ .request_id = "create-weekly", - .name = "schedule.create", + .name = "schedule.create_rule", .arguments = {{"event", std::string("每周复盘")}, - {"repeat", JsonValue::Object({{"freq_type", JsonValue::String("weekly")}, - {"start_date", JsonValue::String("2099-01-01")}, - {"start_time", JsonValue::String("08:00:00")}, - {"end_time", JsonValue::String("09:00:00")}, - {"weekdays_mask", JsonValue::Number(1)}})}}, + {"freq_type", std::string("weekly")}, + {"start_date", std::string("2099-01-01")}, + {"start_time", std::string("08:00:00")}, + {"end_time", std::string("09:00:00")}, + {"weekdays_mask", int64_t{1}}}, }); Check(weekly_rule.status.ok() && OutputString(weekly_rule, "status") == "success", "每周规则应创建成功"); // monthly last_day 规则:覆盖 FrequencyName monthly 与 MonthlyModeName last_day 分支。 const auto monthly_last = server.call({ .request_id = "create-monthly-last", - .name = "schedule.create", + .name = "schedule.create_rule", .arguments = {{"event", std::string("月末总结")}, - {"repeat", JsonValue::Object({{"freq_type", JsonValue::String("monthly")}, - {"start_date", JsonValue::String("2099-01-01")}, - {"start_time", JsonValue::String("07:00:00")}, - {"monthly_mode", JsonValue::String("last_day")}})}}, + {"freq_type", std::string("monthly")}, + {"start_date", std::string("2099-01-01")}, + {"start_time", std::string("07:00:00")}, + {"monthly_mode", std::string("last_day")}}, }); Check(monthly_last.status.ok() && OutputString(monthly_last, "status") == "success", "月末规则应创建成功"); // monthly specific_day 规则:覆盖 MonthlyModeName specific_day 分支。 const auto monthly_day = server.call({ .request_id = "create-monthly-day", - .name = "schedule.create", + .name = "schedule.create_rule", .arguments = {{"event", std::string("每月十五号")}, - {"repeat", JsonValue::Object({{"freq_type", JsonValue::String("monthly")}, - {"start_date", JsonValue::String("2099-01-01")}, - {"start_time", JsonValue::String("06:00:00")}, - {"monthly_mode", JsonValue::String("specific_day")}, - {"day_of_month", JsonValue::Number(15)}})}}, + {"freq_type", std::string("monthly")}, + {"start_date", std::string("2099-01-01")}, + {"start_time", std::string("06:00:00")}, + {"monthly_mode", std::string("specific_day")}, + {"day_of_month", int64_t{15}}}, }); Check(monthly_day.status.ok() && OutputString(monthly_day, "status") == "success", "指定日期规则应创建成功"); // yearly 规则:覆盖 FrequencyName yearly 分支。 const auto yearly_rule = server.call({ .request_id = "create-yearly", - .name = "schedule.create", + .name = "schedule.create_rule", .arguments = {{"event", std::string("年度纪念")}, - {"repeat", JsonValue::Object({{"freq_type", JsonValue::String("yearly")}, - {"start_date", JsonValue::String("2099-01-01")}, - {"start_time", JsonValue::String("05:00:00")}, - {"month_of_year", JsonValue::Number(6)}, - {"day_of_month", JsonValue::Number(15)}})}}, + {"freq_type", std::string("yearly")}, + {"start_date", std::string("2099-01-01")}, + {"start_time", std::string("05:00:00")}, + {"month_of_year", int64_t{6}}, + {"day_of_month", int64_t{15}}}, }); Check(yearly_rule.status.ok() && OutputString(yearly_rule, "status") == "success", "每年规则应创建成功"); @@ -784,7 +783,7 @@ int main() { // 更新整条规则时传入 location 与 notes,覆盖 UpdateRuleCommand 的可选字段赋值分支。 const auto update_rule_full = server.call({ .request_id = "update-rule-full", - .name = "schedule.update", + .name = "schedule.update_rule", .arguments = {{"rule_id", int64_t{601}}, {"location", std::string("新会议室")}, {"notes", std::string("新备注")}}, @@ -830,31 +829,34 @@ int main() { OutputArraySize(capped_query, "exceptions") == 100, "查询输出必须限制到 Gateway 每个数组的 100 项上限"); - // 未启用周期日程能力时(2 参数重载),repeat / rule_id 路径应返回明确失败。 + // 未启用周期日程能力时(2 参数重载),周期字段 / rule_id 路径应返回明确失败。 McpServer one_shot_server; ScheduleService one_shot_service(schedules); Check(voicelife::mcp::RegisterScheduleMcpTools(one_shot_server, one_shot_service).ok(), "2 参数重载应注册成功"); const auto disabled_rule = one_shot_server.call({ .request_id = "disabled-rule", - .name = "schedule.create", - .arguments = {{"event", std::string("无规则能力")}, {"repeat", DailyRepeat()}}, + .name = "schedule.create_rule", + .arguments = {{"event", std::string("无规则能力")}, + {"freq_type", std::string("daily")}, + {"start_date", std::string("2099-01-01")}, + {"start_time", std::string("09:00:00")}}, }); - Check(OutputString(disabled_rule, "status") == "failure", "未启用周期能力时创建周期日程应失败"); + Check(!disabled_rule.status.ok(), "未启用周期能力时创建周期日程应失败"); const auto disabled_update = one_shot_server.call({ .request_id = "disabled-update", - .name = "schedule.update", + .name = "schedule.update_rule", .arguments = {{"rule_id", int64_t{600}}}, }); - Check(OutputString(disabled_update, "status") == "failure", "未启用周期能力时按 rule_id 更新应失败"); + Check(!disabled_update.status.ok(), "未启用周期能力时按 rule_id 更新应失败"); const auto disabled_delete = one_shot_server.call({ .request_id = "disabled-delete", - .name = "schedule.delete", + .name = "schedule.delete_rule", .arguments = {{"rule_id", int64_t{600}}}, }); - Check(OutputString(disabled_delete, "status") == "failure", "未启用周期能力时按 rule_id 删除应失败"); + Check(!disabled_delete.status.ok(), "未启用周期能力时按 rule_id 删除应失败"); return 0; } From 8f7d2290f1404e574527040224da328913bab840 Mon Sep 17 00:00:00 2001 From: huxiaohui <19946728049@163.com> Date: Fri, 28 Aug 2026 18:37:11 +0800 Subject: [PATCH 3/3] =?UTF-8?q?=E2=9C=85=20test(mcp):=20=E8=A1=A5=E5=85=85?= =?UTF-8?q?=E6=89=81=E5=B9=B3=E5=91=A8=E6=9C=9F=E5=AD=97=E6=AE=B5=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C=E4=B8=8E=20query=20=E4=BA=92=E6=96=A5=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E5=B9=B6=E4=BF=AE=E6=AD=A3=20clang-format?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/tools/schedule_mcp_tools.cc | 80 ++++++++++++------- .../src/tools/schedule_mcp_tools_input.cc | 28 ++++--- tests/host/schedule_mcp_tools_branch_test.cc | 5 +- tests/host/schedule_mcp_tools_input_test.cc | 36 ++++++++- .../host/schedule_mcp_tools_reminder_test.cc | 25 ++++-- tests/host/schedule_mcp_tools_test.cc | 11 ++- 6 files changed, 135 insertions(+), 50 deletions(-) diff --git a/components/voicelife_mcp/src/tools/schedule_mcp_tools.cc b/components/voicelife_mcp/src/tools/schedule_mcp_tools.cc index b648ec12..5e06cdfe 100644 --- a/components/voicelife_mcp/src/tools/schedule_mcp_tools.cc +++ b/components/voicelife_mcp/src/tools/schedule_mcp_tools.cc @@ -43,11 +43,11 @@ using voicelife::mcp::schedule_tool_input::CreateRuleProperties; using voicelife::mcp::schedule_tool_input::DeleteProperties; using voicelife::mcp::schedule_tool_input::DeleteRuleProperties; using voicelife::mcp::schedule_tool_input::OperationQueryProperties; -using voicelife::mcp::schedule_tool_input::SkipOccurrenceProperties; using voicelife::mcp::schedule_tool_input::ParsedRepeat; using voicelife::mcp::schedule_tool_input::ParseRepeat; using voicelife::mcp::schedule_tool_input::ParseRuleProperties; using voicelife::mcp::schedule_tool_input::QueryProperties; +using voicelife::mcp::schedule_tool_input::SkipOccurrenceProperties; using voicelife::mcp::schedule_tool_input::UpdateOccurrenceProperties; using voicelife::mcp::schedule_tool_input::UpdateProperties; using voicelife::mcp::schedule_tool_input::UpdateRuleCommand; @@ -301,7 +301,8 @@ Status RegisterScheduleMcpTools(McpServer& server, ScheduleService& service, Sch if (rule_service != nullptr) { status = server.add_tool( "schedule.create_rule", - "创建周期日程:在 schedule_rule 表创建周期规则,并物化首条 schedule 实例。周期字段必须直接作为顶层参数传入,不使用 repeat 对象。", + "创建周期日程:在 schedule_rule 表创建周期规则,并物化首条 schedule " + "实例。周期字段必须直接作为顶层参数传入,不使用 repeat 对象。", CreateRuleProperties(), [rule_service, reminder_service](const PropertyList& properties) { if (rule_service == nullptr) return FailureOutput("当前运行时未启用周期日程能力"); const ParsedRepeat parsed_repeat = ParseRuleProperties(properties, true); @@ -325,9 +326,10 @@ Status RegisterScheduleMcpTools(McpServer& server, ScheduleService& service, Sch MakeToolOutput("message", ToolOutputValue::String("created success")), MakeToolOutput("rule", result.rule.has_value() ? schedule_tool_output::RuleOutput(*result.rule) : ToolOutputValue::Null()), - MakeToolOutput("schedule", (!result.schedules.empty() && result.rule.has_value()) - ? schedule_tool_output::ScheduleOutput(result.schedules.front(), &*result.rule) - : ToolOutputValue::Null()), + MakeToolOutput("schedule", + (!result.schedules.empty() && result.rule.has_value()) + ? schedule_tool_output::ScheduleOutput(result.schedules.front(), &*result.rule) + : ToolOutputValue::Null()), MakeToolOutput("conflicts", ToolOutputValue::Array(schedule_tool_output::ScheduleArrayOutput(result.conflicts))), }); @@ -337,7 +339,9 @@ Status RegisterScheduleMcpTools(McpServer& server, ScheduleService& service, Sch status = server.add_tool_with_context( "schedule.query", - "统一查询一次性日程和周期日程。返回结果按 one_time_schedules、recurring_rules、recurring_schedules、future_occurrences、exceptions 分类;schedule_id 与 rule_id 互斥。", + "统一查询一次性日程和周期日程。返回结果按 " + "one_time_schedules、recurring_rules、recurring_schedules、future_occurrences、exceptions 分类;schedule_id 与 " + "rule_id 互斥。", QueryProperties(), [&service, rule_service, reporting_context](const ToolCall& call) { const PropertyList properties = QueryProperties().with_values(call.arguments); const auto start = ParseDateStart(properties); @@ -355,7 +359,8 @@ Status RegisterScheduleMcpTools(McpServer& server, ScheduleService& service, Sch const auto schedule_id = properties.value("schedule_id"); const auto rule_id = properties.value("rule_id"); if (schedule_id.has_value() && rule_id.has_value()) { - return FailureOutput("schedule_id 和 rule_id 不能同时传入;查询 schedule 使用前者,查询周期规则使用后者"); + return FailureOutput( + "schedule_id 和 rule_id 不能同时传入;查询 schedule 使用前者,查询周期规则使用后者"); } schedule::QueryScheduleCommand command; @@ -370,7 +375,7 @@ Status RegisterScheduleMcpTools(McpServer& server, ScheduleService& service, Sch const auto result = service.query_schedule(command); if (!result.result.ok()) { return FailureOutput(result.result.status.message.empty() ? "查询已物化日程失败" - : result.result.status.message); + : result.result.status.message); } ToolOutputArray one_time; @@ -417,8 +422,8 @@ Status RegisterScheduleMcpTools(McpServer& server, ScheduleService& service, Sch } } - const int64_t result_count = static_cast(one_time.size() + recurring_schedules.size() + - future_occurrences.size()); + const int64_t result_count = + static_cast(one_time.size() + recurring_schedules.size() + future_occurrences.size()); const std::string keyword = properties.value("keyword").value_or(""); const std::string prefix = keyword.empty() ? "查询到" : "根据“" + keyword + "”关键字查询到"; const std::string message = prefix + " " + std::to_string(one_time.size()) + " 条一次性日程、" + @@ -455,10 +460,10 @@ Status RegisterScheduleMcpTools(McpServer& server, ScheduleService& service, Sch intent.exceptions = *exceptions_json; intent.queriedAt = NowIso(); const voicelife::im::ReportResult report = reporting_channel->SubmitScheduleQueryResult(intent); - report_state = report.status == voicelife::im::ReportStatus::kSubmitted - ? "submitted" - : (report.status == voicelife::im::ReportStatus::kRetryable ? "retryable_failed" - : "failed"); + report_state = + report.status == voicelife::im::ReportStatus::kSubmitted + ? "submitted" + : (report.status == voicelife::im::ReportStatus::kRetryable ? "retryable_failed" : "failed"); } return Output({ @@ -470,15 +475,15 @@ Status RegisterScheduleMcpTools(McpServer& server, ScheduleService& service, Sch MakeToolOutput("recurring_schedules", ToolOutputValue::Array(std::move(recurring_schedules))), MakeToolOutput("future_occurrences", ToolOutputValue::Array(std::move(future_occurrences))), MakeToolOutput("exceptions", ToolOutputValue::Array(std::move(exceptions))), - MakeToolOutput("im_delivery", reporting_channel == nullptr - ? ToolOutputValue::Null() - : ToolOutputValue::String(report_state)), + MakeToolOutput("im_delivery", reporting_channel == nullptr ? ToolOutputValue::Null() + : ToolOutputValue::String(report_state)), }); }); if (!status.ok()) return status; status = server.add_tool( "schedule.update", - "修改一次性日程或已经物化到 schedule 表的周期实例。必须只传 schedule_id;不要传 rule_id、original_start_time 或周期规则字段。", + "修改一次性日程或已经物化到 schedule 表的周期实例。必须只传 schedule_id;不要传 rule_id、original_start_time " + "或周期规则字段。", UpdateProperties(), [&service, reminder_service](const PropertyList& properties) { const auto schedule_id = properties.value("schedule_id"); if (!schedule_id.has_value()) return FailureOutput("请提供 schedule_id"); @@ -531,7 +536,8 @@ Status RegisterScheduleMcpTools(McpServer& server, ScheduleService& service, Sch if (rule_service != nullptr) { status = server.add_tool( "schedule.update_occurrence", - "修改未来周期中的某一次尚未物化 occurrence。必须传 rule_id + original_start_time;已物化时请改用 schedule.update。", + "修改未来周期中的某一次尚未物化 occurrence。必须传 rule_id + original_start_time;已物化时请改用 " + "schedule.update。", UpdateOccurrenceProperties(), [rule_service](const PropertyList& properties) { if (rule_service == nullptr) return FailureOutput("当前运行时未启用周期日程能力"); const auto original = schedule_tool_output::ParseDateTime( @@ -577,7 +583,8 @@ Status RegisterScheduleMcpTools(McpServer& server, ScheduleService& service, Sch if (rule_service != nullptr) { status = server.add_tool( "schedule.update_rule", - "修改整条周期规则并按新规则重建未来实例。必须只传 rule_id;周期字段直接作为顶层参数传入,不使用 repeat 对象。", + "修改整条周期规则并按新规则重建未来实例。必须只传 rule_id;周期字段直接作为顶层参数传入,不使用 repeat " + "对象。", UpdateRuleProperties(), [rule_service, reminder_service](const PropertyList& properties) { if (rule_service == nullptr) return FailureOutput("当前运行时未启用周期日程能力"); const ParsedRepeat parsed_repeat = ParseRuleProperties(properties, false); @@ -610,7 +617,9 @@ Status RegisterScheduleMcpTools(McpServer& server, ScheduleService& service, Sch } status = server.add_tool( "schedule.delete", - "取消一次性日程或已经物化到 schedule 表的周期实例。必须传 schedule_id、expected_event、expected_start_time;这三个字段用于确认具体记录。不要传 rule_id 或 original_start_time。", + "取消一次性日程或已经物化到 schedule 表的周期实例。必须传 " + "schedule_id、expected_event、expected_start_time;这三个字段用于确认具体记录。不要传 rule_id 或 " + "original_start_time。", DeleteProperties(), [&service, reminder_service](const PropertyList& properties) { const schedule::ScheduleId id = properties.value("schedule_id").value_or(0); schedule::QueryScheduleCommand query; @@ -628,14 +637,16 @@ Status RegisterScheduleMcpTools(McpServer& server, ScheduleService& service, Sch } const auto result = service.cancel_schedule({.schedule_id = id}); if (!result.result.ok()) { - return FailureOutput(result.result.status.message.empty() ? "日程取消失败" : result.result.status.message); + return FailureOutput(result.result.status.message.empty() ? "日程取消失败" + : result.result.status.message); } if (const auto reminder = CancelReminder(reminder_service, id); reminder.has_value()) return *reminder; schedule::Schedule cancelled = loaded.result.value.front(); cancelled.status = schedule::ScheduleStatus::kCancelled; return Output({ MakeToolOutput("status", ToolOutputValue::String("success")), - MakeToolOutput("message", ToolOutputValue::String("已取消 schedule_id=" + std::to_string(id) + " 的日程")), + MakeToolOutput("message", + ToolOutputValue::String("已取消 schedule_id=" + std::to_string(id) + " 的日程")), MakeToolOutput("schedule", schedule_tool_output::ScheduleOutput(cancelled)), MakeToolOutput("warnings", ToolOutputValue::Array(ToolOutputArray{})), }); @@ -645,7 +656,8 @@ Status RegisterScheduleMcpTools(McpServer& server, ScheduleService& service, Sch if (rule_service != nullptr) { status = server.add_tool( "schedule.delete_rule", - "取消整条周期规则及其已物化实例,并停止后续 occurrence 生成。必须只传 rule_id;不要传 schedule_id 或 original_start_time。", + "取消整条周期规则及其已物化实例,并停止后续 occurrence 生成。必须只传 rule_id;不要传 schedule_id 或 " + "original_start_time。", DeleteRuleProperties(), [rule_service, reminder_service](const PropertyList& properties) { if (rule_service == nullptr) return FailureOutput("当前运行时未启用周期日程能力,无法取消规则"); const auto id = properties.value("rule_id").value_or(0); @@ -653,10 +665,12 @@ Status RegisterScheduleMcpTools(McpServer& server, ScheduleService& service, Sch if (!result.status.ok()) { return FailureOutput(result.status.message.empty() ? "周期规则取消失败" : result.status.message); } - if (const auto reminder = SuspendRuleReminders(reminder_service, id); reminder.has_value()) return *reminder; + if (const auto reminder = SuspendRuleReminders(reminder_service, id); reminder.has_value()) + return *reminder; return Output({ MakeToolOutput("status", ToolOutputValue::String("success")), - MakeToolOutput("message", ToolOutputValue::String("已取消周期规则 rule_id=" + std::to_string(id) + ",后续 occurrence 将不再生成")), + MakeToolOutput("message", ToolOutputValue::String("已取消周期规则 rule_id=" + std::to_string(id) + + ",后续 occurrence 将不再生成")), MakeToolOutput("rule", result.rule.has_value() ? schedule_tool_output::RuleOutput(*result.rule) : ToolOutputValue::Null()), MakeToolOutput("cancelled_schedule_count", ToolOutputValue::Integer(result.cancelled_count)), @@ -669,7 +683,8 @@ Status RegisterScheduleMcpTools(McpServer& server, ScheduleService& service, Sch if (rule_service != nullptr) { status = server.add_tool( "schedule.skip_occurrence", - "跳过未来周期中的某一次尚未物化 occurrence,实际写入 schedule_rule_exception,而不是删除周期规则。必须传 rule_id + original_start_time + expected_event;不要传 schedule_id。已物化时请改用 schedule.delete。", + "跳过未来周期中的某一次尚未物化 occurrence,实际写入 schedule_rule_exception,而不是删除周期规则。必须传 " + "rule_id + original_start_time + expected_event;不要传 schedule_id。已物化时请改用 schedule.delete。", SkipOccurrenceProperties(), [rule_service](const PropertyList& properties) { if (rule_service == nullptr) return FailureOutput("当前运行时未启用周期日程能力,无法跳过 occurrence"); const auto original = schedule_tool_output::ParseDateTime( @@ -683,13 +698,18 @@ Status RegisterScheduleMcpTools(McpServer& server, ScheduleService& service, Sch }; const auto result = rule_service->skip_schedule_occurrence(command); if (!result.status.ok()) { - return FailureOutput(result.status.message.empty() ? "跳过未来 occurrence 失败;如果已物化请改用 schedule.delete" - : result.status.message); + return FailureOutput(result.status.message.empty() + ? "跳过未来 occurrence 失败;如果已物化请改用 schedule.delete" + : result.status.message); } if (result.exception.has_value() && result.exception->type == schedule::ExceptionType::kSkip) { return Output({ MakeToolOutput("status", ToolOutputValue::String("success")), - MakeToolOutput("message", ToolOutputValue::String("已跳过周期规则 rule_id=" + std::to_string(command.rule_id) + " 在 " + properties.value("original_start_time").value() + " 的 occurrence")), + MakeToolOutput( + "message", + ToolOutputValue::String( + "已跳过周期规则 rule_id=" + std::to_string(command.rule_id) + " 在 " + + properties.value("original_start_time").value() + " 的 occurrence")), MakeToolOutput("exception", schedule_tool_output::ExceptionOutput(*result.exception)), MakeToolOutput("warnings", ToolOutputValue::Array(ToolOutputArray{})), }); diff --git a/components/voicelife_mcp/src/tools/schedule_mcp_tools_input.cc b/components/voicelife_mcp/src/tools/schedule_mcp_tools_input.cc index eca1d656..0fd9ce4e 100644 --- a/components/voicelife_mcp/src/tools/schedule_mcp_tools_input.cc +++ b/components/voicelife_mcp/src/tools/schedule_mcp_tools_input.cc @@ -134,8 +134,8 @@ ParsedRepeat ParseFlat(const PropertyList& properties, bool require_anchor) { parsed.occurrence_count = static_cast(*count); } - if (require_anchor && (!parsed.freq_type.has_value() || !parsed.start_time.has_value() || - !parsed.start_date.has_value())) { + if (require_anchor && + (!parsed.freq_type.has_value() || !parsed.start_time.has_value() || !parsed.start_date.has_value())) { parsed.error = "创建周期日程时必须传入 freq_type、start_date 和 start_time;这些字段不能省略"; } return parsed; @@ -331,8 +331,7 @@ PropertyList CreateRuleProperties() { .with_description("每次 occurrence 的结束时间,格式 HH:mm:ss"), Property::Optional("location", PropertyType::kString).with_description("周期日程地点"), Property::Optional("notes", PropertyType::kString).with_description("周期日程备注"), - Property("interval_val", PropertyType::kInteger, int64_t{1}) - .with_description("重复间隔,默认 1,必须为正整数"), + Property("interval_val", PropertyType::kInteger, int64_t{1}).with_description("重复间隔,默认 1,必须为正整数"), Property::Optional("weekdays_mask", PropertyType::kInteger) .with_description("仅 weekly 使用,按位表示星期一至星期日,范围 1 到 127"), Property::Optional("day_of_month", PropertyType::kInteger) @@ -365,7 +364,8 @@ PropertyList QueryProperties() { PropertyList UpdateProperties() { return PropertyList({ Property::Optional("schedule_id", PropertyType::kInteger) - .with_description("要修改的 schedule 表记录 ID;由 schedule.query 返回。不要传 rule_id 或 original_start_time"), + .with_description( + "要修改的 schedule 表记录 ID;由 schedule.query 返回。不要传 rule_id 或 original_start_time"), Property::Optional("event", PropertyType::kString).with_description("新的日程标题"), Property::Optional("start_time", PropertyType::kString) .with_description("新的开始时间,格式 YYYY-MM-DD HH:mm:ss"), @@ -397,7 +397,8 @@ PropertyList UpdateOccurrenceProperties() { PropertyList UpdateRuleProperties() { return PropertyList({ Property::Optional("rule_id", PropertyType::kInteger) - .with_description("要修改的整条周期规则 ID;由 schedule.query 返回。不要传 schedule_id 或 original_start_time"), + .with_description( + "要修改的整条周期规则 ID;由 schedule.query 返回。不要传 schedule_id 或 original_start_time"), Property::Optional("event", PropertyType::kString).with_description("新的规则标题"), Property::Optional("freq_type", PropertyType::kString) .with_description("新的周期频率,只能是 daily、weekly、monthly、yearly"), @@ -422,18 +423,21 @@ PropertyList UpdateRuleProperties() { PropertyList DeleteProperties() { return PropertyList({ Property::Optional("schedule_id", PropertyType::kInteger) - .with_description("要取消的 schedule 表记录 ID,可指向一次性日程或已物化周期实例;由 schedule.query 返回。不要传 rule_id 或 original_start_time"), + .with_description("要取消的 schedule 表记录 ID,可指向一次性日程或已物化周期实例;由 schedule.query " + "返回。不要传 rule_id 或 original_start_time"), Property::Optional("expected_event", PropertyType::kString) .with_description("删除前必须从 schedule.query 原样回传该记录的 event,用于确认不会取消错误目标"), Property::Optional("expected_start_time", PropertyType::kString) - .with_description("删除前必须从 schedule.query 原样回传该记录的 start_time;无开始时间的记录不能通过此确认工具取消"), + .with_description( + "删除前必须从 schedule.query 原样回传该记录的 start_time;无开始时间的记录不能通过此确认工具取消"), }); } PropertyList DeleteRuleProperties() { return PropertyList({ Property::Optional("rule_id", PropertyType::kInteger) - .with_description("要取消的整条周期规则 ID;会停止后续 occurrence。不要传 schedule_id 或 original_start_time"), + .with_description( + "要取消的整条周期规则 ID;会停止后续 occurrence。不要传 schedule_id 或 original_start_time"), }); } @@ -442,9 +446,11 @@ PropertyList SkipOccurrenceProperties() { Property::Optional("rule_id", PropertyType::kInteger) .with_description("周期规则 ID;只用于定位一个未来 occurrence"), Property::Optional("original_start_time", PropertyType::kString) - .with_description("要跳过的原始 occurrence 完整本地开始时间,严格使用 YYYY-MM-DD HH:mm:ss;不是规则的 HH:mm:ss 时间部分"), + .with_description( + "要跳过的原始 occurrence 完整本地开始时间,严格使用 YYYY-MM-DD HH:mm:ss;不是规则的 HH:mm:ss 时间部分"), Property::Optional("expected_event", PropertyType::kString) - .with_description("从 schedule.query 的 future_occurrences 原样回传 event,用于确认跳过的是正确 occurrence"), + .with_description( + "从 schedule.query 的 future_occurrences 原样回传 event,用于确认跳过的是正确 occurrence"), }); } diff --git a/tests/host/schedule_mcp_tools_branch_test.cc b/tests/host/schedule_mcp_tools_branch_test.cc index 1d7e0ef0..efe30d88 100644 --- a/tests/host/schedule_mcp_tools_branch_test.cc +++ b/tests/host/schedule_mcp_tools_branch_test.cc @@ -16,7 +16,10 @@ void CheckRuleUpdateReminderResyncFailure() { const auto created = fixture.server.call({ .request_id = "create-before-resync-failure", .name = "schedule.create_rule", - .arguments = {{"event", std::string("待重同步规则")}, {"freq_type", std::string("daily")}, {"start_date", std::string("2099-01-01")}, {"start_time", std::string("09:00:00")}}, + .arguments = {{"event", std::string("待重同步规则")}, + {"freq_type", std::string("daily")}, + {"start_date", std::string("2099-01-01")}, + {"start_time", std::string("09:00:00")}}, }); Check(created.status.ok() && OutputString(created, "status") == "success", "更新前应成功创建周期规则"); diff --git a/tests/host/schedule_mcp_tools_input_test.cc b/tests/host/schedule_mcp_tools_input_test.cc index 9515b2e1..8188fa9c 100644 --- a/tests/host/schedule_mcp_tools_input_test.cc +++ b/tests/host/schedule_mcp_tools_input_test.cc @@ -10,6 +10,7 @@ #include "voicelife/schedule/schedule_rule_commands.h" using voicelife::JsonValue; +using voicelife::ToolArguments; using voicelife::mcp::PropertyList; using voicelife::mcp::schedule_tool_input::CreateProperties; using voicelife::mcp::schedule_tool_input::CreateRuleCommand; @@ -17,8 +18,9 @@ using voicelife::mcp::schedule_tool_input::CreateRuleProperties; using voicelife::mcp::schedule_tool_input::DeleteProperties; using voicelife::mcp::schedule_tool_input::DeleteRuleProperties; using voicelife::mcp::schedule_tool_input::ParseRepeat; -using voicelife::mcp::schedule_tool_input::SkipOccurrenceProperties; +using voicelife::mcp::schedule_tool_input::ParseRuleProperties; using voicelife::mcp::schedule_tool_input::QueryProperties; +using voicelife::mcp::schedule_tool_input::SkipOccurrenceProperties; using voicelife::mcp::schedule_tool_input::UpdateOccurrenceProperties; using voicelife::mcp::schedule_tool_input::UpdateProperties; using voicelife::mcp::schedule_tool_input::UpdateRuleCommand; @@ -94,6 +96,38 @@ int main() { std::optional{JsonValue::Object({{"end_date", JsonValue::String("2099-00-01")}})}, false); Check(!bad_end_date.ok(), "无效 end_date 应失败"); + // ParseRuleProperties(扁平周期字段)的字段级校验。 + const auto parse_flat = [](ToolArguments args, bool require_anchor = false) { + return ParseRuleProperties(CreateRuleProperties().with_values(std::move(args)), require_anchor); + }; + const auto flat_ok = parse_flat({{"freq_type", std::string("weekly")}, + {"start_date", std::string("2099-01-01")}, + {"start_time", std::string("09:00:00")}, + {"interval_val", int64_t{2}}}, + true); + Check(flat_ok.ok() && flat_ok.interval_val == 2, "扁平周期字段应解析成功"); + + const auto flat_missing_anchor = parse_flat({{"freq_type", std::string("daily")}}, true); + Check(!flat_missing_anchor.ok(), "扁平周期缺少 anchor 应失败"); + const auto flat_bad_start_time = parse_flat({{"start_time", std::string("25:00:00")}}); + Check(!flat_bad_start_time.ok(), "扁平 start_time 非法格式应失败"); + const auto flat_bad_end_time = parse_flat({{"end_time", std::string("99:00:00")}}); + Check(!flat_bad_end_time.ok(), "扁平 end_time 非法格式应失败"); + const auto flat_bad_end_date = parse_flat({{"end_date", std::string("2099-00-01")}}); + Check(!flat_bad_end_date.ok(), "扁平 end_date 非法格式应失败"); + const auto flat_bad_interval = parse_flat({{"interval_val", int64_t{0}}}); + Check(!flat_bad_interval.ok(), "扁平 interval_val 越界应失败"); + const auto flat_bad_weekdays = parse_flat({{"weekdays_mask", int64_t{128}}}); + Check(!flat_bad_weekdays.ok(), "扁平 weekdays_mask 越界应失败"); + const auto flat_bad_day = parse_flat({{"day_of_month", int64_t{32}}}); + Check(!flat_bad_day.ok(), "扁平 day_of_month 越界应失败"); + const auto flat_bad_month = parse_flat({{"month_of_year", int64_t{13}}}); + Check(!flat_bad_month.ok(), "扁平 month_of_year 越界应失败"); + const auto flat_bad_mode = parse_flat({{"monthly_mode", std::string("ordinal_weekday")}}); + Check(!flat_bad_mode.ok(), "扁平 monthly_mode 非法值应失败"); + const auto flat_bad_count = parse_flat({{"occurrence_count", int64_t{0}}}); + Check(!flat_bad_count.ok(), "扁平 occurrence_count 越界应失败"); + PropertyList create_properties; const auto create = CreateRuleCommand(create_properties, parsed); Check(create.freq_type == Frequency::kWeekly && create.interval_val == 2 && create.weekdays_mask == 3 && diff --git a/tests/host/schedule_mcp_tools_reminder_test.cc b/tests/host/schedule_mcp_tools_reminder_test.cc index 90dd0b1a..04f84eb5 100644 --- a/tests/host/schedule_mcp_tools_reminder_test.cc +++ b/tests/host/schedule_mcp_tools_reminder_test.cc @@ -434,7 +434,10 @@ void CheckRuleReminderSuccessPaths() { const auto created = fixture.server.call({ .request_id = "create-rule-reminder-success", .name = "schedule.create_rule", - .arguments = {{"event", std::string("可同步规则")}, {"freq_type", std::string("daily")}, {"start_date", std::string("2099-01-01")}, {"start_time", std::string("09:00:00")}}, + .arguments = {{"event", std::string("可同步规则")}, + {"freq_type", std::string("daily")}, + {"start_date", std::string("2099-01-01")}, + {"start_time", std::string("09:00:00")}}, }); Check(created.status.ok() && OutputString(created, "status") == "success", "创建周期规则并同步提醒应成功"); const ScheduleRuleId rule_id = fixture.rules.rules.back().id; @@ -465,7 +468,10 @@ void CheckRuleReminderRollbackSyncPaths() { const auto update_created = update_fixture.server.call({ .request_id = "create-rule-before-update-failure", .name = "schedule.create_rule", - .arguments = {{"event", std::string("更新失败前规则")}, {"freq_type", std::string("daily")}, {"start_date", std::string("2099-01-01")}, {"start_time", std::string("09:00:00")}}, + .arguments = {{"event", std::string("更新失败前规则")}, + {"freq_type", std::string("daily")}, + {"start_date", std::string("2099-01-01")}, + {"start_time", std::string("09:00:00")}}, }); Check(update_created.status.ok() && OutputString(update_created, "status") == "success", "更新失败同步测试应先创建规则"); @@ -489,7 +495,10 @@ void CheckRuleReminderRollbackSyncPaths() { const auto delete_created = delete_fixture.server.call({ .request_id = "create-rule-before-delete-failure", .name = "schedule.create_rule", - .arguments = {{"event", std::string("删除失败前规则")}, {"freq_type", std::string("daily")}, {"start_date", std::string("2099-01-01")}, {"start_time", std::string("09:00:00")}}, + .arguments = {{"event", std::string("删除失败前规则")}, + {"freq_type", std::string("daily")}, + {"start_date", std::string("2099-01-01")}, + {"start_time", std::string("09:00:00")}}, }); Check(delete_created.status.ok() && OutputString(delete_created, "status") == "success", "删除失败同步测试应先创建规则"); @@ -517,7 +526,10 @@ void CheckRuleReminderSyncFailurePaths() { const auto rule_create_failed = create_fail_fixture.server.call({ .request_id = "create-rule-sync-failed", .name = "schedule.create_rule", - .arguments = {{"event", std::string("创建规则失败")}, {"freq_type", std::string("daily")}, {"start_date", std::string("2099-01-01")}, {"start_time", std::string("09:00:00")}}, + .arguments = {{"event", std::string("创建规则失败")}, + {"freq_type", std::string("daily")}, + {"start_date", std::string("2099-01-01")}, + {"start_time", std::string("09:00:00")}}, }); Check(rule_create_failed.status.ok() && OutputString(rule_create_failed, "status") == "failure" && OutputString(rule_create_failed, "message").find("提醒同步失败") != std::string::npos, @@ -532,7 +544,10 @@ void CheckRuleReminderSyncFailurePaths() { const auto rule_create = fixture.server.call({ .request_id = "create-rule-for-update", .name = "schedule.create_rule", - .arguments = {{"event", std::string("可更新规则")}, {"freq_type", std::string("daily")}, {"start_date", std::string("2099-01-01")}, {"start_time", std::string("09:00:00")}}, + .arguments = {{"event", std::string("可更新规则")}, + {"freq_type", std::string("daily")}, + {"start_date", std::string("2099-01-01")}, + {"start_time", std::string("09:00:00")}}, }); Check(rule_create.status.ok() && OutputString(rule_create, "status") == "success", "正常周期规则创建应成功"); diff --git a/tests/host/schedule_mcp_tools_test.cc b/tests/host/schedule_mcp_tools_test.cc index e15da941..f318738f 100644 --- a/tests/host/schedule_mcp_tools_test.cc +++ b/tests/host/schedule_mcp_tools_test.cc @@ -262,8 +262,7 @@ int main() { const auto missing_anchor = server.call({ .request_id = "create-rule-missing-anchor", .name = "schedule.create_rule", - .arguments = {{"event", std::string("缺字段")}, - {"freq_type", std::string("daily")}}, + .arguments = {{"event", std::string("缺字段")}, {"freq_type", std::string("daily")}}, }); Check(!missing_anchor.status.ok(), "周期日程缺少 anchor 应被参数校验拒绝"); @@ -379,6 +378,14 @@ int main() { Check(q.status.ok(), "状态查询应成功"); } + // schedule.query:schedule_id 与 rule_id 互斥。 + const auto query_both_ids = server.call({ + .request_id = "query-both-ids", + .name = "schedule.query", + .arguments = {{"schedule_id", int64_t{1}}, {"rule_id", int64_t{600}}}, + }); + Check(OutputString(query_both_ids, "status") == "failure", "query 同时传 schedule_id 与 rule_id 应失败"); + // schedule.update:schedule_id 与 rule_id 互斥。 const auto both_ids = server.call({ .request_id = "update-both",