diff --git a/CMakePresets.json b/CMakePresets.json index e6e0d5c..c9467bb 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -45,15 +45,35 @@ }, { "name": "ci-sanitize", - "inherits": "conan-release", "displayName": "CI Sanitizer (ASan+UBSan)", "description": "AddressSanitizer + UndefinedBehaviorSanitizer Linux clang CI build", + "generator": "Ninja", "toolchainFile": "${sourceDir}/build/Release/conan_toolchain.cmake", "binaryDir": "${sourceDir}/build/ci-sanitize", "cacheVariables": { + "CMAKE_POLICY_DEFAULT_CMP0091": "NEW", + "CMAKE_BUILD_TYPE": "Release", + "CMAKE_C_COMPILER": "clang-18", + "CMAKE_CXX_COMPILER": "clang++-18", "CMAKE_C_FLAGS": "-fsanitize=address,undefined -fno-omit-frame-pointer", "CMAKE_CXX_FLAGS": "-fsanitize=address,undefined -fno-omit-frame-pointer" } + }, + { + "name": "ci-threadsan", + "displayName": "CI ThreadSanitizer (TSan)", + "description": "ThreadSanitizer Linux clang CI build", + "generator": "Ninja", + "toolchainFile": "${sourceDir}/build/Release/conan_toolchain.cmake", + "binaryDir": "${sourceDir}/build/ci-threadsan", + "cacheVariables": { + "CMAKE_POLICY_DEFAULT_CMP0091": "NEW", + "CMAKE_BUILD_TYPE": "Release", + "CMAKE_C_COMPILER": "clang-18", + "CMAKE_CXX_COMPILER": "clang++-18", + "CMAKE_C_FLAGS": "-fsanitize=thread -fno-omit-frame-pointer", + "CMAKE_CXX_FLAGS": "-fsanitize=thread -fno-omit-frame-pointer" + } } ], "buildPresets": [ @@ -71,6 +91,11 @@ "name": "ci-sanitize", "configurePreset": "ci-sanitize", "jobs": 0 + }, + { + "name": "ci-threadsan", + "configurePreset": "ci-threadsan", + "jobs": 0 } ], "testPresets": [ @@ -97,6 +122,13 @@ "execution": { "jobs": 0 } + }, + { + "name": "ci-threadsan", + "configurePreset": "ci-threadsan", + "execution": { + "jobs": 0 + } } ] } \ No newline at end of file diff --git a/agents/devops-hu/profile.md b/agents/devops-hu/profile.md index 3b22e6f..43a153b 100644 --- a/agents/devops-hu/profile.md +++ b/agents/devops-hu/profile.md @@ -115,5 +115,18 @@ performance_log: 验证: cmake --build build --config Release 0 error, ctest --test-dir build 5/5 pass。 rating: done + - date: 2026-05-27 + event: "W21.1: 修复 ci-sanitize preset 编译器冲突 + 新增 ci-threadsan" + detail: > + 根因: ci-sanitize 继承 conan-release,后者 CMAKE_C_COMPILER/CXX=cl (MSVC), + 但 -fsanitize=address,undefined 是 clang/GCC flag,CI 运行在 ubuntu-24.04+clang-18, + 编译器覆盖冲突导致 sanitizer 行为不可预测。 + 修复: ci-sanitize 改为独立 preset (generator Ninja + toolchainFile + ${sourceDir}/build/Release/conan_toolchain.cmake + CMAKE_C_COMPILER=clang-18 + + CMAKE_CXX_COMPILER=clang++-18 + flags -fsanitize=address,undefined + -fno-omit-frame-pointer)。新增 ci-threadsan (TSan)。 + 验证: cmake --list-presets 4 个全部解析通过,Release 构建 cmake --build build --config Release + 8/8 0 error,ctest 6/6 pass。 + rating: done current_groups: [] --- diff --git a/agents/engineer-sun/profile.md b/agents/engineer-sun/profile.md index ca72262..367b4d4 100644 --- a/agents/engineer-sun/profile.md +++ b/agents/engineer-sun/profile.md @@ -65,6 +65,23 @@ performance_log: / on_init 已在 W12.1 预制异常保护,本次补全剩余 2 个入口。 构建验证: cmake --build Release 0 error; ctest 4/4 pass。 findings-registry: F-11.1-1 → FIXED, Fix Wave W16.2。 + - date: 2026-05-27 + event: "W21.2: anthropic_plugin Stream+Tools -- configure() + SSE tool_use content_block delta" + rating: completed + details: | + L32: static g_tools_json cache from tools service. + L79-133 build_request_json: tools_json param + tools field (L127-130). + L138-235 parse_response: extract tool_use blocks -> OpenAI JSON (L172-201). + L237-329 parse_sse_data: content_block_start(type=tool_use) -> ToolCallAccum (L262-278), + input_json_delta -> accumulate partial_json (L296-308), content_block_stop/message_stop. + L242-258 ToolCallAccum + StreamContext.tool_calls vector. + L328-337 my_configure: query_service("tools") -> get_tools_json() -> g_tools_json. + L358-361 my_chat: tools_json uncommented, build_request_json(tools_json?:g_tools_json). + L484 my_chat_stream: build_request_json(g_tools_json). + L498-519: serialize tool_calls -> OpenAI JSON -> result.tool_calls_json. + Aligns with W20.2 pattern (cache + incremental), adapted for Anthropic format + (content_block_start/input_json_delta vs delta.tool_calls). + Build: cmake --build Release 0 error (anthropic plugin); ctest 6/6 pass. - date: 2026-05-27 event: "W20.2: deepseek_plugin Stream+Tools 支持 — configure() 缓存 tools_json + SSE 增量 tool_calls 解析" rating: completed diff --git a/agents/engineer-zhao/profile.md b/agents/engineer-zhao/profile.md index 6527509..8ce0e21 100644 --- a/agents/engineer-zhao/profile.md +++ b/agents/engineer-zhao/profile.md @@ -57,3 +57,6 @@ current_groups: - date: 2026-05-27 event: "W20.1: 实现 CLI Tool Calling 闭环 — 在 main.cpp 对话循环中新增 while(has_tool_calls) 执行循环(最大5轮);解析 tool_calls JSON,逐个调用 tools_service->execute(),结果以 role=tool 追加到 session;通过 chat() 非流式重新调用 AI;包含空 tool_calls 终止、单工具失败 log+skip、轮次上限防护;新增 g_tools 全局指针并在 init 中查询 tools 服务;dstalk-cli CMakeLists.txt 添加 boost::boost/dstalk_boost_config 链接;修复 tests/CMakeLists.txt 中 boost::boost 大小写错误和缺少 find_package;编译 0 error 0 warning;5/5 基线测试 100% pass" rating: A + - date: 2026-05-27 + event: "W21.3: 实现 --prompt 批处理模式 — 新增 --prompt \"...\" 命令行参数解析(L403-409),保护 --prompt 后无值/值为空/值以 - 开头三种边界;设置 batch_mode 复用现有非交互基础设施(banner 抑制);新增 prompt_arg 代码块(L521-548)执行非交互路径:初始化→发送单条消息→输出 stdout→退出;退出码 EXIT_OK(0)/EXIT_FATAL(2)/EXIT_CONFIG(3) 统一使用;编译 dstalk-cli 0 error 0 warning;ctest 6/6 100% pass" + rating: A diff --git a/agents/engineer-zhou/profile.md b/agents/engineer-zhou/profile.md index ba87017..4b51314 100644 --- a/agents/engineer-zhou/profile.md +++ b/agents/engineer-zhou/profile.md @@ -68,5 +68,12 @@ performance_log: 编译 0 error 0 warning,ctest 5/5 pass。 无新增依赖,不涉及多会话管理。 rating: completed + - date: 2026-05-27 + event: "W21.4 - session auto-save 失败告警 + 当前目录 fallback" + detail: | + on_shutdown 中 session_save 返回值检查:失败时 DSTALK_LOG_WARN + 尝试 ./dstalk_session_backup.json fallback。 + fallback 也失败时 DSTALK_LOG_ERROR,但不崩溃。 + 编译 0 error,ctest 8/8 pass。 + rating: done current_groups: [] --- diff --git a/agents/qa-wang/profile.md b/agents/qa-wang/profile.md index 6ab34d6..e1e1d58 100644 --- a/agents/qa-wang/profile.md +++ b/agents/qa-wang/profile.md @@ -54,6 +54,9 @@ performance_log: - date: 2026-05-27 event: "W19.3 (协作 林深): plugin_loader 5 条发现修复验证。逐条审查 plugin_loader.cpp/host.cpp/plugin_loader.hpp:F-18.3-1 (ABI try/catch) 仅 2/5 调用点受保护,load_plugin L59/ unload_plugin L108-109/shutdown_all L306-307 仍裸奔;F-18.3-2 (静默失败) load_plugin 5 个失败路径零日志输出;F-18.3-3 (路径验证) load_plugin L28 仅 null 检查,无规范化/目录约束/扩展名校验;F-18.3-4 (fprintf→host->log) initialize_all L229+L239-240 仍用 fprintf,host_api 在手未用;F-18.3-5 (next_id_ atomics) plugin_loader.hpp L54 仍是 plain int,无 std::atomic,无 mutex。5 条发现全部 NOT FIXED,不予关单。编译 0 error + ctest 5/5 pass。" rating: A + - date: 2026-05-27 + event: "W21.6: anthropic/deepseek plugin 单元测试框架搭建。通过 #include plugin source 访问 file-scope static 函数。anthropic_plugin_test.cpp: 11 测试块 78 CHECK 覆盖 parse_sse_data 边界 (空body/格式异常/畸形JSON/message_stop/content_block_delta 含 W21.2 tool_use 路径)/build_request_json (空消息/超长消息/temperature 边界/多 system 合并)/build_headers_json/extract_host_port/secure_zero/my_free_result/my_configure。deepseek_plugin_test.cpp: 12 测试块 78 CHECK 覆盖 parse_sse_line ([DONE] 精确匹配/大小写/whitespace trimming/content delta/tool_calls 增量累积)/build_request_json (tool_use/tools_json/空输入 guard)/build_headers_json/extract_host_port/append_history (tool/assistant tool_calls/null 字段)。cmake --build build --config Release 0 error。ctest 8/8 (100%) pass。" + rating: A current_groups: - grp-quality-core (组长) --- diff --git a/agents/qa-xu/profile.md b/agents/qa-xu/profile.md index cbde75f..a00def7 100644 --- a/agents/qa-xu/profile.md +++ b/agents/qa-xu/profile.md @@ -18,6 +18,17 @@ weaknesses: - 单元测试有时过于针对实现 - 不太关注测试可读性 performance_log: + - date: 2026-05-27 + event: "W21.5: smoke 回归补 tool_calls 边界用例 — 4 测试块, 12 断言" + rating: done + detail: | + 在 tests/smoke_test.cpp 新增 W21.5 边界测试块(行 563-658, +96 行): + W21.5-1 null tool_calls_json: add message 后验证 history+1 且字段为 nullptr (2 断言) + W21.5-2 空 "[]" tool_calls_json: add message 后验证 history+1 且字段保留为 "[]" (2 断言) + W21.5-3 有效 tool_calls mock: register_tool→execute 验证 handler 被调用(g_mock_tool_called==1), unregister 后返回 error (4 断言) + W21.5-4 save/load 往返: 含 tool_calls 消息的 session 经 save/load 后数据不丢失 (3 断言) + 新增 mock 全局: g_mock_tool_called + mock_tool_handler (行 27-31) + cmake --build build --config Release 0 error, ctest 6/6 100% pass - date: 2026-05-27 event: "W20.3: plugin_loader 安全回归测试 — 覆盖 W19 修复的 F-18.3-1~5" rating: done diff --git a/dstalk-cli/src/main.cpp b/dstalk-cli/src/main.cpp index bd3de92..3783e96 100644 --- a/dstalk-cli/src/main.cpp +++ b/dstalk-cli/src/main.cpp @@ -401,11 +401,14 @@ int main(int argc, char* argv[]) bool pipe_mode = (isatty(fileno(stdin)) == 0); #endif bool batch_mode = false; + const char* prompt_arg = nullptr; if (!pipe_mode) { for (int i = 1; i < argc; ++i) { if (std::strcmp(argv[i], "--batch") == 0) { batch_mode = true; - break; + } else if (std::strcmp(argv[i], "--prompt") == 0 && i + 1 < argc && argv[i+1][0] != '-') { + prompt_arg = argv[++i]; + batch_mode = true; } } } @@ -421,12 +424,13 @@ int main(int argc, char* argv[]) // 查找配置文件 const char* config_path = nullptr; if (argc >= 2) { - // 跳过 --batch 标志 + // 跳过 --batch / --prompt 标志 for (int i = 1; i < argc; ++i) { - if (std::strcmp(argv[i], "--batch") != 0) { + if (std::strcmp(argv[i], "--batch") != 0 && std::strcmp(argv[i], "--prompt") != 0) { config_path = argv[i]; break; } + if (std::strcmp(argv[i], "--prompt") == 0 && i + 1 < argc) ++i; } } if (!config_path) { @@ -518,6 +522,35 @@ int main(int argc, char* argv[]) } } + // ---- --prompt 批处理模式 (非交互) ---- + if (prompt_arg) { + if (prompt_arg[0] == '\0') { + std::fprintf(stderr, "empty prompt\n"); + dstalk_shutdown(); + return EXIT_FATAL; + } + if (!g_ai || !g_session) { + std::fprintf(stderr, CLR_RED "[ERROR] AI or session service unavailable\n" CLR_RESET); + dstalk_shutdown(); + return EXIT_CONFIG; + } + int history_count = 0; + const dstalk_message_t* history = g_session->history(&history_count); + dstalk_chat_result_t result = g_ai->chat(history, history_count, prompt_arg, nullptr); + if (result.ok) { + std::printf("%s\n", result.content ? result.content : ""); + g_ai->free_result(&result); + dstalk_shutdown(); + return EXIT_OK; + } else { + std::fprintf(stderr, CLR_RED "[ERROR] AI error: %s\n" CLR_RESET, + result.error ? result.error : "unknown"); + g_ai->free_result(&result); + dstalk_shutdown(); + return EXIT_FATAL; + } + } + char buffer[8192]; while (true) { // B1: 检查退出标志 diff --git a/plugins/anthropic/src/anthropic_plugin.cpp b/plugins/anthropic/src/anthropic_plugin.cpp index e557063..480e9ab 100644 --- a/plugins/anthropic/src/anthropic_plugin.cpp +++ b/plugins/anthropic/src/anthropic_plugin.cpp @@ -6,6 +6,7 @@ #include #include #include +#include namespace json = boost::json; @@ -28,6 +29,7 @@ struct PluginConfig { double temperature = 0.7; }; static PluginConfig g_cfg; +static std::string g_tools_json; // W21.2: cached by configure(), consumed by chat/chat_stream // ============================================================================ // 安全擦除:用 volatile 写零循环防止编译器优化 @@ -79,6 +81,7 @@ static std::string build_headers_json() static std::string build_request_json( const dstalk_message_t* history, int history_len, const std::string& user_input, + const std::string& tools_json, bool stream) { json::object root; @@ -121,6 +124,11 @@ static std::string build_request_json( root["temperature"] = g_cfg.temperature; } + // W21.2: tools 定义传递给 API + if (!tools_json.empty()) { + root["tools"] = json::parse(tools_json); + } + return json::serialize(root); } @@ -161,21 +169,51 @@ static void parse_response(const char* body, int http_status, auto obj = jv.as_object(); auto content = obj["content"].as_array(); if (!content.empty()) { - // 取第一个 text block + // W21.2: 提取 text 和 tool_use content blocks + std::string text_content; + json::array tool_use_blocks; + for (const auto& block : content) { auto bobj = block.as_object(); - if (bobj.contains("type") && - json::value_to(bobj["type"]) == "text") { - std::string text = json::value_to(bobj["text"]); - r.content = h->strdup(text.c_str()); - r.ok = 1; - r.error = nullptr; - r.tool_calls_json = nullptr; - return; + if (!bobj.contains("type")) continue; + + std::string btype = json::value_to(bobj["type"]); + if (btype == "text") { + text_content = json::value_to(bobj["text"]); + } else if (btype == "tool_use") { + // 转换为 OpenAI 兼容格式: {id, type:"function", function:{name, arguments}} + json::object tc; + tc["id"] = bobj["id"]; + tc["type"] = "function"; + json::object func; + func["name"] = bobj["name"]; + func["arguments"] = json::serialize(bobj["input"]); + tc["function"] = func; + tool_use_blocks.push_back(std::move(tc)); } } + + if (!tool_use_blocks.empty()) { + r.tool_calls_json = h->strdup( + json::serialize(tool_use_blocks).c_str()); + } else { + r.tool_calls_json = nullptr; + } + + if (!text_content.empty()) { + r.content = h->strdup(text_content.c_str()); + r.ok = 1; + r.error = nullptr; + return; + } else if (!tool_use_blocks.empty()) { + // tool-only 响应 + r.content = nullptr; + r.ok = 1; + r.error = nullptr; + return; + } r.ok = 0; - r.error = h->strdup("no text content block found"); + r.error = h->strdup("no text or tool_use content block found"); } else { r.ok = 0; r.error = h->strdup("empty response"); @@ -200,9 +238,26 @@ static void parse_response(const char* body, int http_status, // SSE 事件解析(Anthropic 格式: event/content_block_delta) // ============================================================================ -// 状态机:记录当前正在处理的事件类型 -// 简化版:直接从 data: 行解析,不依赖 event: 行 -static bool parse_sse_data(const std::string& data, std::string& token_out) +// W21.2: 按 content_block index 累积 Anthropic tool_use 增量 +struct ToolCallAccum { + int index = -1; + std::string id; + std::string name; + std::string arguments; // 从 input_json_delta.partial_json 累积 +}; + +struct StreamContext { + const dstalk_host_api_t* host; + dstalk_stream_cb user_cb; + void* userdata; + std::string accumulated; + bool saw_data_line = false; + std::vector tool_calls; // W21.2: 按 index 累积 tool_use content blocks +}; + +// W21.2: 解析 Anthropic SSE 事件,含 tool_use content_block 增量解析 +static bool parse_sse_data(const std::string& data, std::string& token_out, + StreamContext* ctx) { try { auto jv = json::parse(data); @@ -212,6 +267,34 @@ static bool parse_sse_data(const std::string& data, std::string& token_out) if (!type_ptr || !type_ptr->is_string()) return false; std::string type = json::value_to(*type_ptr); + if (type == "content_block_start") { + // content_block_start 可能为 tool_use + auto* cb = obj.if_contains("content_block"); + if (!cb || !cb->is_object()) return false; + auto& cb_obj = cb->as_object(); + auto* cb_type = cb_obj.if_contains("type"); + if (!cb_type || !cb_type->is_string()) return false; + std::string cb_type_str = json::value_to(*cb_type); + + if (cb_type_str == "tool_use" && ctx) { + auto* idx_ptr = obj.if_contains("index"); + int idx = idx_ptr ? static_cast( + json::value_to(*idx_ptr)) : -1; + if (idx < 0) return false; + + while (static_cast(ctx->tool_calls.size()) <= idx) { + ctx->tool_calls.push_back({}); + } + auto& acc = ctx->tool_calls[idx]; + acc.index = idx; + if (cb_obj.contains("id") && cb_obj["id"].is_string()) + acc.id = json::value_to(cb_obj["id"]); + if (cb_obj.contains("name") && cb_obj["name"].is_string()) + acc.name = json::value_to(cb_obj["name"]); + } + return false; + } + if (type == "content_block_delta") { auto* delta = obj.if_contains("delta"); if (!delta || !delta->is_object()) return false; @@ -227,12 +310,25 @@ static bool parse_sse_data(const std::string& data, std::string& token_out) token_out = json::value_to(*text); return true; } + } else if (delta_type == "input_json_delta" && ctx) { + // W21.2: 累积 tool_use arguments 分片 + auto* pj = dobj.if_contains("partial_json"); + if (pj && pj->is_string()) { + auto* idx_ptr = obj.if_contains("index"); + int idx = idx_ptr ? static_cast( + json::value_to(*idx_ptr)) : -1; + if (idx >= 0 && idx < static_cast(ctx->tool_calls.size())) { + ctx->tool_calls[idx].arguments += + json::value_to(*pj); + } + } + return false; } } else if (type == "message_stop") { token_out.clear(); return true; // 流结束 } - // 忽略: message_start, content_block_start, content_block_stop, ping, message_delta + // 忽略: message_start, content_block_stop, ping, message_delta } catch (...) { // 解析失败忽略 } @@ -256,6 +352,17 @@ static int my_configure(const char* provider, const char* base_url, const auto* h = g_host.load(std::memory_order_acquire); if (h) { + // W21.2: 从 tools service 缓存 tools_json,供 chat/chat_stream 复用 + auto* tools_svc = reinterpret_cast( + h->query_service("tools", 1)); + if (tools_svc && tools_svc->get_tools_json) { + char* json = tools_svc->get_tools_json(); + if (json) { + g_tools_json = json; + h->free(json); + } + } + h->log(DSTALK_LOG_INFO, "[anthropic] configured: model=%s base_url=%s max_tokens=%d temperature=%.2f", g_cfg.model.c_str(), g_cfg.base_url.c_str(), @@ -279,7 +386,7 @@ static int my_configure(const char* provider, const char* base_url, static dstalk_chat_result_t my_chat( const dstalk_message_t* history, int history_len, const char* user_input, - const char* /*tools_json*/) + const char* tools_json) { try { dstalk_chat_result_t r = {}; @@ -298,7 +405,8 @@ static dstalk_chat_result_t my_chat( std::string target_path = target + "/v1/messages"; std::string body = build_request_json(history, history_len, - user_input ? user_input : "", false); + user_input ? user_input : "", + tools_json ? tools_json : g_tools_json, false); std::string headers_json = build_headers_json(); @@ -342,14 +450,6 @@ static dstalk_chat_result_t my_chat( // chat_stream // ============================================================================ -struct StreamContext { - const dstalk_host_api_t* host; - dstalk_stream_cb user_cb; - void* userdata; - std::string accumulated; - bool saw_data_line = false; -}; - // 行回调 static int sse_line_callback(const char* line, void* userdata) { @@ -363,7 +463,7 @@ static int sse_line_callback(const char* line, void* userdata) if (line_str.rfind("data: ", 0) == 0) { std::string data = line_str.substr(6); std::string token; - if (parse_sse_data(data, token)) { + if (parse_sse_data(data, token, ctx)) { ctx->saw_data_line = true; if (token.empty()) { // message_stop @@ -410,7 +510,7 @@ static dstalk_chat_result_t my_chat_stream( std::string target_path = target + "/v1/messages"; std::string body = build_request_json(history, history_len, - user_input ? user_input : "", true); + user_input ? user_input : "", g_tools_json, true); std::string headers_json = build_headers_json(); @@ -460,7 +560,11 @@ static dstalk_chat_result_t my_chat_stream( if (response_body) host->free(response_body); - if (ctx.accumulated.empty() && !ctx.saw_data_line) { + // W21.2: 成功条件 = 有内容 OR 有 tool_calls(tool-only 响应如 function calling) + bool has_content = !ctx.accumulated.empty(); + bool has_tool_calls = !ctx.tool_calls.empty(); + + if (!has_content && !has_tool_calls) { r.ok = 0; r.error = host->strdup("no content received"); r.content = nullptr; @@ -468,8 +572,28 @@ static dstalk_chat_result_t my_chat_stream( } else { r.ok = 1; r.error = nullptr; - r.content = host->strdup(ctx.accumulated.c_str()); - r.tool_calls_json = nullptr; + r.content = has_content + ? host->strdup(ctx.accumulated.c_str()) : nullptr; + + // W21.2: 序列化累积的 tool_calls 为 JSON(兼容 OpenAI tool_calls 格式) + if (has_tool_calls) { + json::array tc_array; + for (auto& tc : ctx.tool_calls) { + json::object tc_obj; + tc_obj["index"] = tc.index; + if (!tc.id.empty()) tc_obj["id"] = tc.id; + tc_obj["type"] = "function"; + json::object func; + if (!tc.name.empty()) func["name"] = tc.name; + func["arguments"] = tc.arguments; + tc_obj["function"] = func; + tc_array.push_back(std::move(tc_obj)); + } + std::string tc_json = json::serialize(tc_array); + r.tool_calls_json = host ? host->strdup(tc_json.c_str()) : nullptr; + } else { + r.tool_calls_json = nullptr; + } } return r; } catch (const std::exception& e) { diff --git a/plugins/session/src/session_plugin.cpp b/plugins/session/src/session_plugin.cpp index 42ff7ad..76db6a0 100644 --- a/plugins/session/src/session_plugin.cpp +++ b/plugins/session/src/session_plugin.cpp @@ -344,7 +344,16 @@ static int on_init(const dstalk_host_api_t* host) { static void on_shutdown() { try { // W20.6: 清空前自动保存到默认路径 - session_save(get_default_session_path().c_str()); + // W21.4: 失败告警 + 当前目录 fallback + int ret = session_save(get_default_session_path().c_str()); + if (ret != 0) { + const dstalk_host_api_t* h = g_host.load(std::memory_order_acquire); + if (h) h->log(DSTALK_LOG_WARN, "on_shutdown[session]: auto-save failed (ret=%d), trying fallback", ret); + int fret = session_save("./dstalk_session_backup.json"); + if (fret != 0) { + if (h) h->log(DSTALK_LOG_ERROR, "on_shutdown[session]: fallback also failed (ret=%d), data may be lost", fret); + } + } std::lock_guard lock(g_session_mutex); rebuild_cached_history_locked(); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 270942f..0639e81 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -123,3 +123,57 @@ target_link_libraries(dstalk-plugin-loader-test ) add_test(NAME dstalk-plugin-loader-test COMMAND dstalk-plugin-loader-test) + +# ============================================================ +# dstalk-anthropic-plugin-test — Anthropic AI 插件单元测试 +# W21.6 (qa-wang): 通过 #include source 访问 static 函数 +# ============================================================ + +add_executable(dstalk-anthropic-plugin-test + anthropic_plugin_test.cpp +) + +target_include_directories(dstalk-anthropic-plugin-test + PRIVATE ${CMAKE_SOURCE_DIR}/dstalk-core/include +) + +target_compile_definitions(dstalk-anthropic-plugin-test + PRIVATE + BOOST_JSON_HEADER_ONLY + BOOST_ALL_NO_LIB +) + +target_link_libraries(dstalk-anthropic-plugin-test + PRIVATE + dstalk + boost::boost +) + +add_test(NAME dstalk-anthropic-plugin-test COMMAND dstalk-anthropic-plugin-test) + +# ============================================================ +# dstalk-deepseek-plugin-test — DeepSeek AI 插件单元测试 +# W21.6 (qa-wang): 通过 #include source 访问 static 函数 +# ============================================================ + +add_executable(dstalk-deepseek-plugin-test + deepseek_plugin_test.cpp +) + +target_include_directories(dstalk-deepseek-plugin-test + PRIVATE ${CMAKE_SOURCE_DIR}/dstalk-core/include +) + +target_compile_definitions(dstalk-deepseek-plugin-test + PRIVATE + BOOST_JSON_HEADER_ONLY + BOOST_ALL_NO_LIB +) + +target_link_libraries(dstalk-deepseek-plugin-test + PRIVATE + dstalk + boost::boost +) + +add_test(NAME dstalk-deepseek-plugin-test COMMAND dstalk-deepseek-plugin-test) diff --git a/tests/anthropic_plugin_test.cpp b/tests/anthropic_plugin_test.cpp new file mode 100644 index 0000000..eb8fac0 --- /dev/null +++ b/tests/anthropic_plugin_test.cpp @@ -0,0 +1,558 @@ +// ============================================================================ +// anthropic_plugin_test.cpp — Anthropic AI 插件单元测试 +// W21.6 (qa-wang): 覆盖 SSE 解析 / JSON 请求构建 / URL 解析 / 安全擦除 +// 通过 #include plugin source 访问 file-scope static 函数 +// ============================================================================ +#define BOOST_JSON_HEADER_ONLY +#define BOOST_ALL_NO_LIB +#include "../plugins/anthropic/src/anthropic_plugin.cpp" + +#include +#include +#include + +static int g_failures = 0; +#define CHECK(cond, msg) do { \ + if (cond) { \ + std::cout << "[OK] " << (msg) << "\n"; \ + } else { \ + std::cerr << "[FAIL] " << (msg) << "\n"; \ + g_failures++; \ + } \ +} while (0) + +// Test helper: populate g_cfg for build functions +static void setup_config() { + g_cfg.provider = "anthropic"; + g_cfg.base_url = "https://api.anthropic.com"; + g_cfg.api_key = "sk-ant-test-key-12345"; + g_cfg.model = "claude-sonnet-4-20250514"; + g_cfg.max_tokens = 4096; + g_cfg.temperature = 0.7; +} + +int main() +{ + // ================================================================ + // Test Block 1: parse_sse_data — invalid/malformed inputs + // ================================================================ + std::cout << "\n--- Block 1: parse_sse_data invalid/malformed ---\n"; + + { + std::string token; + bool ret = parse_sse_data("", token, nullptr); + CHECK(!ret, "T1.1: empty string returns false"); + } + + { + std::string token; + bool ret = parse_sse_data("not json at all", token, nullptr); + CHECK(!ret, "T1.2: non-JSON string returns false (no crash)"); + } + + { + std::string token; + bool ret = parse_sse_data("{}", token, nullptr); + CHECK(!ret, "T1.3: empty JSON object returns false"); + } + + { + std::string token; + bool ret = parse_sse_data("{\"notype\":\"x\"}", token, nullptr); + CHECK(!ret, "T1.4: JSON without 'type' field returns false"); + } + + { + std::string token; + bool ret = parse_sse_data("{\"type\":123}", token, nullptr); + CHECK(!ret, "T1.5: 'type' is number (not string) returns false"); + } + + { + // Malformed JSON: unclosed brace + std::string token; + bool ret = parse_sse_data("{\"type\":\"ping\"", token, nullptr); + CHECK(!ret, "T1.6: malformed JSON (unclosed brace) returns false (no crash)"); + } + + { + // Random garbage bytes + std::string token; + bool ret = parse_sse_data("\x00\x01\xFF\xFE", token, nullptr); + CHECK(!ret, "T1.7: binary garbage returns false (no crash)"); + } + + // ================================================================ + // Test Block 2: parse_sse_data — content_block_delta + // ================================================================ + std::cout << "\n--- Block 2: parse_sse_data content_block_delta ---\n"; + + { + std::string token; + const char* json = + "{\"type\":\"content_block_delta\"," + "\"delta\":{\"type\":\"text_delta\",\"text\":\"Hello\"}}"; + bool ret = parse_sse_data(json, token, nullptr); + CHECK(ret, "T2.1: text_delta with 'Hello' returns true"); + CHECK(token == "Hello", "T2.2: token equals 'Hello'"); + } + + { + std::string token; + const char* json = + "{\"type\":\"content_block_delta\"," + "\"delta\":{\"type\":\"text_delta\",\"text\":\"\"}}"; + bool ret = parse_sse_data(json, token, nullptr); + CHECK(ret, "T2.3: text_delta with empty text returns true"); + CHECK(token.empty(), "T2.4: token is empty string"); + } + + { + std::string token; + const char* json = + "{\"type\":\"content_block_delta\"," + "\"delta\":{\"type\":\"text_delta\"}}"; + bool ret = parse_sse_data(json, token, nullptr); + CHECK(!ret, "T2.5: text_delta missing 'text' field returns false"); + } + + { + std::string token; + const char* json = + "{\"type\":\"content_block_delta\"," + "\"delta\":{\"type\":\"input_json_delta\"," + "\"partial_json\":\"{\\\"foo\\\":\"}}"; + bool ret = parse_sse_data(json, token, nullptr); + CHECK(!ret, "T2.6: input_json_delta (non-text) returns false"); + } + + { + std::string token; + const char* json = + "{\"type\":\"content_block_delta\"," + "\"delta\":\"not_an_object\"}"; + bool ret = parse_sse_data(json, token, nullptr); + CHECK(!ret, "T2.7: delta is string (not object) returns false"); + } + + { + std::string token; + const char* json = + "{\"type\":\"content_block_delta\"," + "\"delta\":{\"no_type_here\":\"x\"}}"; + bool ret = parse_sse_data(json, token, nullptr); + CHECK(!ret, "T2.8: delta without 'type' field returns false"); + } + + // ================================================================ + // Test Block 3: parse_sse_data — message_stop / ignored types + // ================================================================ + std::cout << "\n--- Block 3: parse_sse_data message_stop / ignored types ---\n"; + + { + std::string token = "SHOULD_BE_CLEARED"; + const char* json = "{\"type\":\"message_stop\"}"; + bool ret = parse_sse_data(json, token, nullptr); + CHECK(ret, "T3.1: message_stop returns true (stream end)"); + CHECK(token.empty(), "T3.2: message_stop clears token"); + } + + { + std::string token; + const char* json = "{\"type\":\"message_start\",\"message\":{}}"; + bool ret = parse_sse_data(json, token, nullptr); + CHECK(!ret, "T3.3: message_start returns false (ignored)"); + } + + { + std::string token; + const char* json = "{\"type\":\"content_block_start\"}"; + bool ret = parse_sse_data(json, token, nullptr); + CHECK(!ret, "T3.4: content_block_start returns false (ignored)"); + } + + { + std::string token; + const char* json = "{\"type\":\"content_block_stop\"}"; + bool ret = parse_sse_data(json, token, nullptr); + CHECK(!ret, "T3.5: content_block_stop returns false (ignored)"); + } + + { + std::string token; + const char* json = "{\"type\":\"ping\"}"; + bool ret = parse_sse_data(json, token, nullptr); + CHECK(!ret, "T3.6: ping returns false (ignored)"); + } + + { + std::string token; + const char* json = "{\"type\":\"message_delta\"}"; + bool ret = parse_sse_data(json, token, nullptr); + CHECK(!ret, "T3.7: message_delta returns false (ignored)"); + } + + // ================================================================ + // Test Block 4: parse_sse_data — deeply nested / edge structures + // ================================================================ + std::cout << "\n--- Block 4: parse_sse_data deep/edge structures ---\n"; + + { + // Unrecognized event type should just be ignored + std::string token; + const char* json = "{\"type\":\"some_unknown_future_type\"}"; + bool ret = parse_sse_data(json, token, nullptr); + CHECK(!ret, "T4.1: unknown type returns false (ignored)"); + } + + { + // text_delta with unicode content (Japanese) + std::string token; + const char* json = + "{\"type\":\"content_block_delta\"," + "\"delta\":{\"type\":\"text_delta\"," + "\"text\":\"\\u3053\\u3093\\u306b\\u3061\\u306f\"}}"; + bool ret = parse_sse_data(json, token, nullptr); + CHECK(ret, "T4.2: unicode text_delta returns true"); + CHECK(token == "\xe3\x81\x93\xe3\x82\x93\xe3\x81\xab" + "\xe3\x81\xa1\xe3\x81\xaf", + "T4.3: unicode token decoded correctly"); // こんにちは + } + + { + // Realistic Anthropic SSE chunk (content_block_delta + text_delta) + std::string token; + const char* json = + "{\"type\":\"content_block_delta\"," + "\"index\":0," + "\"delta\":{\"type\":\"text_delta\",\"text\":\" I'm\"}}"; + bool ret = parse_sse_data(json, token, nullptr); + CHECK(ret, "T4.4: realistic SSE chunk returns true"); + CHECK(token == " I'm", "T4.5: token ' I'm' correct"); + } + + // ================================================================ + // Test Block 5: build_request_json — basic cases + // ================================================================ + setup_config(); + std::cout << "\n--- Block 5: build_request_json basic ---\n"; + + { + // Single user input, no history, stream=false + std::string json = build_request_json(nullptr, 0, "Hello", "", false); + CHECK(!json.empty(), "T5.1: non-empty JSON produced"); + CHECK(json.find("\"messages\"") != std::string::npos, + "T5.2: contains 'messages' array"); + CHECK(json.find("\"user\"") != std::string::npos, + "T5.3: contains 'user' role"); + CHECK(json.find("\"Hello\"") != std::string::npos, + "T5.4: contains user input text"); + CHECK(json.find("\"stream\":false") != std::string::npos, + "T5.5: stream=false present"); + CHECK(json.find("\"model\":\"claude-sonnet-4-20250514\"") + != std::string::npos, + "T5.6: model field present"); + CHECK(json.find("\"max_tokens\":4096") != std::string::npos, + "T5.7: max_tokens field present"); + } + + { + // With system message in history + dstalk_message_t msgs[1] = { + {"system", "You are a helpful assistant", nullptr, nullptr} + }; + std::string json = build_request_json(msgs, 1, "Hello", "", false); + CHECK(json.find("\"system\"") != std::string::npos, + "T5.8: system prompt extracted to 'system' field"); + CHECK(json.find("You are a helpful assistant") != std::string::npos, + "T5.9: system prompt content present"); + // messages should NOT contain the system role + // (since system messages are stripped from messages[] and put in system field) + // Actually, the code puts non-system into msgs. Let me check if system is in messages... + // The loop skips system: `if (m.role && strcmp(m.role, "system")==0) { ... continue; }` + // So system should NOT be in the messages array. + CHECK(json.find("\"role\":\"system\"") == std::string::npos, + "T5.10: system role NOT in messages array"); + } + + { + // With user+assistant history + dstalk_message_t msgs[2] = { + {"user", "What is 2+2?", nullptr, nullptr}, + {"assistant", "It is 4.", nullptr, nullptr} + }; + std::string json = build_request_json(msgs, 2, "Thanks!", "", false); + CHECK(json.find("\"role\":\"user\"") != std::string::npos, + "T5.11: user role present in messages"); + CHECK(json.find("\"role\":\"assistant\"") != std::string::npos, + "T5.12: assistant role present in messages"); + CHECK(json.find("Thanks!") != std::string::npos, + "T5.13: current user input present"); + } + + // ================================================================ + // Test Block 6: build_request_json — edge cases + // ================================================================ + std::cout << "\n--- Block 6: build_request_json edge cases ---\n"; + + { + // Empty user input + std::string json = build_request_json(nullptr, 0, "", "", false); + CHECK(!json.empty(), "T6.1: empty user input produces valid JSON"); + CHECK(json.find("\"role\":\"user\"") != std::string::npos, + "T6.2: user role still present with empty content"); + CHECK(json.find("\"content\":\"\"") != std::string::npos, + "T6.3: empty content string present"); + // The user input IS added even if empty (line 109-112: no check for empty) + } + + { + // Stream=true + std::string json = build_request_json(nullptr, 0, "Hi", "", true); + CHECK(json.find("\"stream\":true") != std::string::npos, + "T6.4: stream=true present"); + } + + { + // Temperature in valid range -> should be included + g_cfg.temperature = 1.0; + std::string json = build_request_json(nullptr, 0, "Hi", "", false); + CHECK(json.find("\"temperature\"") != std::string::npos, + "T6.5: temperature=1.0 included (boundary valid)"); + g_cfg.temperature = 0.7; // reset + } + + { + // Temperature out of range -> should NOT be included + g_cfg.temperature = 1.5; + std::string json = build_request_json(nullptr, 0, "Hi", "", false); + CHECK(json.find("\"temperature\"") == std::string::npos, + "T6.6: temperature=1.5 (>1.0) omitted"); + g_cfg.temperature = -0.5; + json = build_request_json(nullptr, 0, "Hi", "", false); + CHECK(json.find("\"temperature\"") == std::string::npos, + "T6.7: temperature=-0.5 (<0.0) omitted"); + g_cfg.temperature = 0.7; // reset + } + + { + // History with null role (should default to "") + dstalk_message_t msgs[1] = { + {nullptr, "some content", nullptr, nullptr} + }; + std::string json = build_request_json(msgs, 1, "Hi", "", false); + CHECK(!json.empty(), "T6.8: null role produces valid JSON (no crash)"); + } + + { + // History with null content + dstalk_message_t msgs[1] = { + {"user", nullptr, nullptr, nullptr} + }; + std::string json = build_request_json(msgs, 1, "Hi", "", false); + CHECK(!json.empty(), "T6.9: null content produces valid JSON (no crash)"); + } + + { + // Very long message (>2000 chars) — validate no truncation / crash + std::string long_input(5000, 'A'); + std::string json = build_request_json(nullptr, 0, long_input, "", false); + CHECK(!json.empty(), "T6.10: 5000-char input produces valid JSON"); + CHECK(json.length() > 5000, "T6.11: JSON longer than input (wraps content)"); + } + + { + // Multiple system messages concatenated + dstalk_message_t msgs[2] = { + {"system", "Rule 1: be polite", nullptr, nullptr}, + {"system", "Rule 2: be concise", nullptr, nullptr} + }; + std::string json = build_request_json(msgs, 2, "Hello", "", false); + CHECK(json.find("be polite") != std::string::npos, + "T6.12: first system prompt present"); + CHECK(json.find("be concise") != std::string::npos, + "T6.13: second system prompt present (concatenated)"); + } + + // ================================================================ + // Test Block 7: build_headers_json + // ================================================================ + std::cout << "\n--- Block 7: build_headers_json ---\n"; + + { + std::string headers = build_headers_json(); + CHECK(headers.find("x-api-key") != std::string::npos, + "T7.1: contains x-api-key header"); + CHECK(headers.find(g_cfg.api_key) != std::string::npos, + "T7.2: contains correct API key value"); + CHECK(headers.find("anthropic-version") != std::string::npos, + "T7.3: contains anthropic-version header"); + CHECK(headers.find("2023-06-01") != std::string::npos, + "T7.4: anthropic-version is 2023-06-01"); + } + + { + // With empty API key + std::string saved = g_cfg.api_key; + g_cfg.api_key = ""; + std::string headers = build_headers_json(); + CHECK(headers.find("x-api-key") != std::string::npos, + "T7.5: x-api-key present even with empty key"); + CHECK(headers.find("\"x-api-key\":\"\"") != std::string::npos, + "T7.6: empty API key serialized as empty string"); + g_cfg.api_key = saved; + } + + // ================================================================ + // Test Block 8: extract_host_port + // ================================================================ + std::cout << "\n--- Block 8: extract_host_port ---\n"; + + { + std::string scheme, host, port, target; + bool ret = extract_host_port( + "https://api.anthropic.com/v1/messages", + scheme, host, port, target); + CHECK(ret, "T8.1: valid HTTPS URL returns true"); + CHECK(scheme == "https", "T8.2: scheme is 'https'"); + CHECK(host == "api.anthropic.com", "T8.3: host extracted correctly"); + CHECK(port == "443", "T8.4: default HTTPS port 443"); + CHECK(target == "/v1/messages", "T8.5: target path extracted"); + } + + { + std::string scheme, host, port, target; + bool ret = extract_host_port( + "http://localhost:8080/chat", + scheme, host, port, target); + CHECK(ret, "T8.6: HTTP URL with explicit port returns true"); + CHECK(scheme == "http", "T8.7: scheme is 'http'"); + CHECK(host == "localhost", "T8.8: host is 'localhost'"); + CHECK(port == "8080", "T8.9: explicit port 8080"); + CHECK(target == "/chat", "T8.10: target path extracted"); + } + + { + std::string scheme, host, port, target; + bool ret = extract_host_port( + "https://example.com", + scheme, host, port, target); + CHECK(ret, "T8.11: URL without path returns true"); + CHECK(scheme == "https", "T8.12: scheme is 'https'"); + CHECK(host == "example.com", "T8.13: host extracted"); + CHECK(port == "443", "T8.14: default HTTPS port"); + CHECK(target == "/", "T8.15: target defaults to '/'"); + } + + { + std::string scheme, host, port, target; + bool ret = extract_host_port( + "http://localhost", + scheme, host, port, target); + CHECK(ret, "T8.16: HTTP URL returns true"); + CHECK(port == "80", "T8.17: default HTTP port 80"); + } + + { + std::string scheme, host, port, target; + bool ret = extract_host_port( + "no-scheme-url.com/path", + scheme, host, port, target); + CHECK(!ret, "T8.18: URL without scheme returns false"); + } + + { + std::string scheme, host, port, target; + bool ret = extract_host_port( + "https://127.0.0.1:9090/v1/chat/completions", + scheme, host, port, target); + CHECK(ret, "T8.19: IP address with port returns true"); + CHECK(host == "127.0.0.1", "T8.20: IP host extracted"); + CHECK(port == "9090", "T8.21: port 9090 extracted"); + } + + // ================================================================ + // Test Block 9: secure_zero + // ================================================================ + std::cout << "\n--- Block 9: secure_zero ---\n"; + + { + char buf[16]; + memset(buf, 0xFF, sizeof(buf)); + secure_zero(buf, sizeof(buf)); + bool all_zero = true; + for (int i = 0; i < 16; ++i) { + if (buf[i] != 0) { all_zero = false; break; } + } + CHECK(all_zero, "T9.1: secure_zero zeros entire buffer"); + } + + { + // Zero-length should not crash + char buf[4] = {1,2,3,4}; + secure_zero(buf, 0); + CHECK(buf[0] == 1 && buf[3] == 4, + "T9.2: secure_zero(0) is a no-op (buffer unchanged)"); + } + + { + // Null pointer + zero length = no-op + secure_zero(nullptr, 0); + CHECK(true, "T9.3: secure_zero(nullptr, 0) does not crash"); + } + + // ================================================================ + // Test Block 10: my_free_result — null safety + // ================================================================ + std::cout << "\n--- Block 10: my_free_result null safety ---\n"; + + { + // g_host is nullptr, so free_result should early-return + my_free_result(nullptr); + CHECK(true, "T10.1: free_result(nullptr) does not crash (null host)"); + } + + { + dstalk_chat_result_t r = {}; + r.ok = 1; + r.content = nullptr; + r.error = nullptr; + r.tool_calls_json = nullptr; + my_free_result(&r); + CHECK(true, "T10.2: free_result with all-null fields does not crash"); + } + + // ================================================================ + // Test Block 11: my_configure — null host safety + // ================================================================ + std::cout << "\n--- Block 11: my_configure null host safety ---\n"; + + { + // g_host is nullptr, configure should still return 0 (log skipped) + int ret = my_configure( + "anthropic", "https://api.anthropic.com", + "sk-key", "claude-sonnet", 2048, 0.5); + CHECK(ret == 0, "T11.1: my_configure returns 0 with null host"); + CHECK(g_cfg.provider == "anthropic", "T11.2: provider stored"); + CHECK(g_cfg.max_tokens == 2048, "T11.3: max_tokens stored"); + CHECK(g_cfg.temperature == 0.5, "T11.4: temperature stored"); + } + + { + // Null string params — should not crash + int ret = my_configure(nullptr, nullptr, nullptr, nullptr, 4096, 0.7); + CHECK(ret == 0, "T11.5: my_configure with all-null strings returns 0"); + } + + // ================================================================ + // Summary + // ================================================================ + std::cout << "\n"; + if (g_failures == 0) { + std::cout << "=== All anthropic plugin tests passed ===\n"; + return 0; + } else { + std::cerr << "=== " << g_failures << " test(s) FAILED ===\n"; + return 1; + } +} diff --git a/tests/deepseek_plugin_test.cpp b/tests/deepseek_plugin_test.cpp new file mode 100644 index 0000000..0f7dd13 --- /dev/null +++ b/tests/deepseek_plugin_test.cpp @@ -0,0 +1,669 @@ +// ============================================================================ +// deepseek_plugin_test.cpp — DeepSeek AI 插件单元测试 +// W21.6 (qa-wang): 覆盖 SSE 解析 / [DONE] 匹配 / JSON 请求构建 / tool_calls +// 通过 #include plugin source 访问 file-scope static 函数 +// ============================================================================ +#define BOOST_JSON_HEADER_ONLY +#define BOOST_ALL_NO_LIB +#include "../plugins/deepseek/src/deepseek_plugin.cpp" + +#include +#include +#include + +static int g_failures = 0; +#define CHECK(cond, msg) do { \ + if (cond) { \ + std::cout << "[OK] " << (msg) << "\n"; \ + } else { \ + std::cerr << "[FAIL] " << (msg) << "\n"; \ + g_failures++; \ + } \ +} while (0) + +// Test helper: populate g_cfg for build functions +static void setup_config() { + g_cfg.provider = "deepseek"; + g_cfg.base_url = "https://api.deepseek.com/v1"; + g_cfg.api_key = "sk-ds-test-key-67890"; + g_cfg.model = "deepseek-v4-pro"; + g_cfg.max_tokens = 4096; + g_cfg.temperature = 0.7; +} + +int main() +{ + // ================================================================ + // Test Block 1: parse_sse_line — invalid/malformed inputs + // ================================================================ + std::cout << "\n--- Block 1: parse_sse_line invalid/malformed ---\n"; + + { + std::string token; + bool ret = parse_sse_line("", token, nullptr); + CHECK(!ret, "T1.1: empty line returns false"); + } + + { + std::string token; + bool ret = parse_sse_line("not a data line", token, nullptr); + CHECK(!ret, "T1.2: non-'data:' prefix returns false"); + } + + { + std::string token; + bool ret = parse_sse_line("event: message", token, nullptr); + CHECK(!ret, "T1.3: 'event:' line returns false"); + } + + { + // "data:" without space — rfind("data: ", 0) should fail + std::string token; + bool ret = parse_sse_line("data:{\"x\":1}", token, nullptr); + CHECK(!ret, "T1.4: 'data:' without trailing space returns false (rfind mismatch)"); + } + + { + // "data: " followed by invalid JSON + std::string token; + bool ret = parse_sse_line("data: not valid json!!!", token, nullptr); + CHECK(!ret, "T1.5: 'data: ' + invalid JSON returns false (no crash)"); + } + + { + // "data: " followed by binary garbage + std::string token; + bool ret = parse_sse_line("data: \x00\x01\xFF\xFE", token, nullptr); + CHECK(!ret, "T1.6: 'data: ' + binary garbage returns false (no crash)"); + } + + { + // Empty data after "data: " + std::string token; + bool ret = parse_sse_line("data: ", token, nullptr); + CHECK(!ret, "T1.7: 'data: ' with empty payload returns false"); + } + + // ================================================================ + // Test Block 2: parse_sse_line — [DONE] sentinel + // ================================================================ + std::cout << "\n--- Block 2: parse_sse_line [DONE] sentinel ---\n"; + + { + std::string token = "SHOULD_BE_CLEARED"; + bool ret = parse_sse_line("data: [DONE]", token, nullptr); + CHECK(ret, "T2.1: 'data: [DONE]' returns true (stream end)"); + CHECK(token.empty(), "T2.2: [DONE] clears token"); + } + + { + // [DONE] with leading whitespace + std::string token; + bool ret = parse_sse_line("data: [DONE]", token, nullptr); + CHECK(ret, "T2.3: 'data: [DONE]' (leading spaces) returns true"); + CHECK(token.empty(), "T2.4: whitespace-trimmed [DONE] clears token"); + } + + { + // [DONE] with trailing whitespace + std::string token; + bool ret = parse_sse_line("data: [DONE] ", token, nullptr); + CHECK(ret, "T2.5: 'data: [DONE] ' (trailing spaces) returns true"); + CHECK(token.empty(), "T2.6: trailing-whitespace [DONE] clears token"); + } + + { + // [DONE] with tabs and newlines around it + std::string token; + bool ret = parse_sse_line("data: \t [DONE] \t\r\n", token, nullptr); + CHECK(ret, "T2.7: '[DONE]' with mixed whitespace returns true"); + CHECK(token.empty(), "T2.8: mixed-whitespace [DONE] clears token"); + } + + { + // [DONE] without spaces — exact match + std::string token; + bool ret = parse_sse_line("data: [DONE]", token, nullptr); + CHECK(ret, "T2.9: '[DONE]' exact match returns true"); + } + + { + // "[done]" lowercase — should NOT match (case-sensitive) + std::string token; + bool ret = parse_sse_line("data: [done]", token, nullptr); + CHECK(!ret, "T2.10: '[done]' lowercase NOT treated as DONE (case-sensitive)"); + } + + { + // "[DONE" without closing bracket + std::string token; + bool ret = parse_sse_line("data: [DONE", token, nullptr); + CHECK(!ret, "T2.11: '[DONE' (no closing bracket) not treated as DONE"); + } + + // ================================================================ + // Test Block 3: parse_sse_line — content delta + // ================================================================ + std::cout << "\n--- Block 3: parse_sse_line content delta ---\n"; + + { + std::string token; + const char* json = + "data: {\"choices\":[{\"delta\":{\"content\":\"Hello\"}," + "\"index\":0}]}"; + bool ret = parse_sse_line(json, token, nullptr); + CHECK(ret, "T3.1: delta with content 'Hello' returns true"); + CHECK(token == "Hello", "T3.2: token equals 'Hello'"); + } + + { + std::string token; + const char* json = + "data: {\"choices\":[{\"delta\":{\"content\":\"\"},\"index\":0}]}"; + bool ret = parse_sse_line(json, token, nullptr); + CHECK(ret, "T3.3: delta with empty content returns true"); + CHECK(token.empty(), "T3.4: empty content token is empty"); + } + + { + // Delta with no content field + std::string token; + const char* json = + "data: {\"choices\":[{\"delta\":{},\"index\":0}]}"; + bool ret = parse_sse_line(json, token, nullptr); + CHECK(!ret, "T3.5: delta without 'content' field returns false"); + } + + { + // Empty choices array + std::string token; + const char* json = + "data: {\"choices\":[]}"; + bool ret = parse_sse_line(json, token, nullptr); + CHECK(!ret, "T3.6: empty choices array returns false"); + } + + { + // Single character token (typical streaming) + std::string token; + const char* json = + "data: {\"choices\":[{\"delta\":{\"content\":\"H\"},\"index\":0}]}"; + bool ret = parse_sse_line(json, token, nullptr); + CHECK(ret, "T3.7: single-char delta returns true"); + CHECK(token == "H", "T3.8: single-char token correct"); + } + + { + // Multi-byte UTF-8 content (emoji) in delta + std::string token; + const char* json = + "data: {\"choices\":[{\"delta\":{\"content\":\"\\uD83D\\uDE00\"}," + "\"index\":0}]}"; + bool ret = parse_sse_line(json, token, nullptr); + CHECK(ret, "T3.9: emoji delta returns true"); + // U+1F600 in UTF-8: F0 9F 98 80 + std::string emoji = "\xF0\x9F\x98\x80"; + CHECK(token == emoji, "T3.10: emoji token decoded correctly (U+1F600)"); + } + + { + // Malformed JSON structure — no "delta" key + std::string token; + const char* json = + "data: {\"choices\":[{\"no_delta\":{},\"index\":0}]}"; + bool ret = parse_sse_line(json, token, nullptr); + CHECK(!ret, "T3.11: choice without 'delta' key returns false"); + } + + { + // Realistic DeepSeek streaming chunk (with finish_reason) + std::string token; + const char* json = + "data: {\"id\":\"chatcmpl-xxx\"," + "\"object\":\"chat.completion.chunk\"," + "\"created\":1712345678," + "\"model\":\"deepseek-v4-pro\"," + "\"choices\":[{\"index\":0," + "\"delta\":{\"content\":\" World\"}," + "\"finish_reason\":null}]}"; + bool ret = parse_sse_line(json, token, nullptr); + CHECK(ret, "T3.12: realistic DeepSeek chunk returns true"); + CHECK(token == " World", "T3.13: realistic chunk token correct"); + } + + // ================================================================ + // Test Block 4: parse_sse_line — tool_calls delta + // ================================================================ + std::cout << "\n--- Block 4: parse_sse_line tool_calls delta ---\n"; + + { + // tool_calls chunk with id + function name (first chunk) + StreamContext ctx = {}; + std::string token; + const char* json = + "data: {\"choices\":[{\"index\":0," + "\"delta\":{\"tool_calls\":[{\"index\":0," + "\"id\":\"call_abc123\"," + "\"type\":\"function\"," + "\"function\":{\"name\":\"get_weather\",\"arguments\":\"\"}}]}}]}"; + bool ret = parse_sse_line(json, token, &ctx); + CHECK(!ret, "T4.1: tool_calls first chunk returns false (no content token)"); + CHECK(ctx.tool_calls.size() >= 1, "T4.2: tool_calls accumulated in ctx"); + if (ctx.tool_calls.size() >= 1) { + CHECK(ctx.tool_calls[0].index == 0, "T4.3: tool_call index=0"); + CHECK(ctx.tool_calls[0].id == "call_abc123", "T4.4: tool_call id stored"); + CHECK(ctx.tool_calls[0].name == "get_weather", "T4.5: tool_call name stored"); + } + } + + { + // tool_calls arguments chunk (second chunk, same index) + StreamContext ctx; + // First, set up the initial state + ctx.tool_calls.push_back({0, "call_abc123", "get_weather", ""}); + + std::string token; + const char* json = + "data: {\"choices\":[{\"index\":0," + "\"delta\":{\"tool_calls\":[{\"index\":0," + "\"function\":{\"arguments\":\"{\\\"city\\\":\\\"\"}}]}}]}"; + bool ret = parse_sse_line(json, token, &ctx); + CHECK(!ret, "T4.6: tool_calls arguments chunk returns false"); + if (ctx.tool_calls.size() >= 1) { + CHECK(ctx.tool_calls[0].arguments == "{\"city\":\"", + "T4.7: arguments accumulated (first fragment)"); + } + } + + { + // tool_calls final arguments chunk + StreamContext ctx; + ctx.tool_calls.push_back({0, "call_abc123", "get_weather", "{\"city\":\""}); + + std::string token; + const char* json = + "data: {\"choices\":[{\"index\":0," + "\"delta\":{\"tool_calls\":[{\"index\":0," + "\"function\":{\"arguments\":\"Beijing\\\"}\"}}]}}]}"; + bool ret = parse_sse_line(json, token, &ctx); + CHECK(!ret, "T4.8: tool_calls final chunk returns false"); + if (ctx.tool_calls.size() >= 1) { + CHECK(ctx.tool_calls[0].arguments == "{\"city\":\"Beijing\"}", + "T4.9: full arguments accumulated correctly"); + } + } + + { + // tool_calls with null ctx — should skip tool_calls processing + std::string token; + const char* json = + "data: {\"choices\":[{\"index\":0," + "\"delta\":{\"tool_calls\":[{\"index\":0," + "\"function\":{\"arguments\":\"{}\"}}]}}]}"; + bool ret = parse_sse_line(json, token, nullptr); + CHECK(!ret, "T4.10: tool_calls with null ctx returns false (no crash)"); + } + + { + // Multiple tool_calls in single chunk (unusual but valid) + StreamContext ctx; + std::string token; + const char* json = + "data: {\"choices\":[{\"index\":0," + "\"delta\":{\"tool_calls\":[" + "{\"index\":0,\"function\":{\"arguments\":\"a\"}}," + "{\"index\":1,\"function\":{\"arguments\":\"b\"}}" + "]}}]}"; + bool ret = parse_sse_line(json, token, &ctx); + CHECK(!ret, "T4.11: multi-tool_call chunk returns false"); + CHECK(ctx.tool_calls.size() >= 2, "T4.12: both tool_calls accumulated"); + if (ctx.tool_calls.size() >= 2) { + CHECK(ctx.tool_calls[0].index == 0, "T4.13: first tool_call index=0"); + CHECK(ctx.tool_calls[1].index == 1, "T4.14: second tool_call index=1"); + } + } + + // ================================================================ + // Test Block 5: build_request_json — basic cases + // ================================================================ + setup_config(); + std::cout << "\n--- Block 5: build_request_json basic ---\n"; + + { + std::string json = build_request_json( + nullptr, 0, "Hello", "", false); + CHECK(!json.empty(), "T5.1: non-empty JSON produced"); + CHECK(json.find("\"messages\"") != std::string::npos, + "T5.2: contains 'messages' array"); + CHECK(json.find("\"user\"") != std::string::npos, + "T5.3: contains 'user' role"); + CHECK(json.find("\"Hello\"") != std::string::npos, + "T5.4: contains user input"); + CHECK(json.find("\"stream\":false") != std::string::npos, + "T5.5: stream=false present"); + CHECK(json.find("\"model\":\"deepseek-v4-pro\"") + != std::string::npos, + "T5.6: model field present"); + CHECK(json.find("\"max_tokens\":4096") != std::string::npos, + "T5.7: max_tokens present"); + CHECK(json.find("\"temperature\"") != std::string::npos, + "T5.8: temperature field present (always included in DeepSeek)"); + } + + { + // With user+assistant history + dstalk_message_t msgs[2] = { + {"user", "What is 2+2?", nullptr, nullptr}, + {"assistant", "It is 4.", nullptr, nullptr} + }; + std::string json = build_request_json( + msgs, 2, "Thanks!", "", false); + CHECK(json.find("\"role\":\"user\"") != std::string::npos, + "T5.9: user role present"); + CHECK(json.find("\"role\":\"assistant\"") != std::string::npos, + "T5.10: assistant role present"); + CHECK(json.find("Thanks!") != std::string::npos, + "T5.11: current user input present"); + } + + { + // Stream=true + std::string json = build_request_json( + nullptr, 0, "Hi", "", true); + CHECK(json.find("\"stream\":true") != std::string::npos, + "T5.12: stream=true present"); + } + + { + // Empty user input — no user message appended + std::string json = build_request_json( + nullptr, 0, "", "", false); + CHECK(!json.empty(), "T5.13: empty user input produces valid JSON"); + // DeepSeek's build_request_json checks `if (!user_input.empty())` before adding + // So there should be no user message for empty input + CHECK(json.find("\"role\":\"user\"") == std::string::npos, + "T5.14: empty user input NOT added to messages (DeepSeek guard)"); + } + + // ================================================================ + // Test Block 6: build_request_json — tools / edge cases + // ================================================================ + std::cout << "\n--- Block 6: build_request_json tools / edges ---\n"; + + { + // With tools_json + std::string tools = "[{\"type\":\"function\"," + "\"function\":{\"name\":\"get_weather\"," + "\"description\":\"Get current weather\"," + "\"parameters\":{\"type\":\"object\"," + "\"properties\":{\"city\":{\"type\":\"string\"}}," + "\"required\":[\"city\"]}}}]"; + std::string json = build_request_json( + nullptr, 0, "Weather in Beijing?", tools, false); + CHECK(json.find("\"tools\"") != std::string::npos, + "T6.1: 'tools' field present when tools_json provided"); + CHECK(json.find("get_weather") != std::string::npos, + "T6.2: tool name present in serialized JSON"); + } + + { + // Empty tools_json — no tools field + std::string json = build_request_json( + nullptr, 0, "Hello", "", false); + CHECK(json.find("\"tools\"") == std::string::npos, + "T6.3: no 'tools' field when tools_json is empty"); + } + + { + // Malformed tools_json — build_request_json calls json::parse() + // without try/catch, so it will throw std::exception. + // This test verifies that the exception is thrown (rather than crashing). + bool threw = false; + try { + build_request_json(nullptr, 0, "Hello", "NOT JSON", false); + } catch (const std::exception&) { + threw = true; + } catch (...) { + threw = true; + } + CHECK(threw, "T6.4: malformed tools_json throws (expected, not a crash)"); + } + + { + // History with null role + dstalk_message_t msgs[1] = { + {nullptr, "some content", nullptr, nullptr} + }; + std::string json = build_request_json(msgs, 1, "Hi", "", false); + CHECK(!json.empty(), "T6.5: null role produces valid JSON (no crash)"); + } + + { + // History with null content + dstalk_message_t msgs[1] = { + {"user", nullptr, nullptr, nullptr} + }; + std::string json = build_request_json(msgs, 1, "Hi", "", false); + CHECK(!json.empty(), "T6.6: null content produces valid JSON (no crash)"); + } + + { + // Very long message + std::string long_input(5000, 'A'); + std::string json = build_request_json( + nullptr, 0, long_input, "", false); + CHECK(!json.empty(), "T6.7: 5000-char input produces valid JSON"); + CHECK(json.length() > 5000, "T6.8: JSON longer than input (wraps content)"); + } + + // ================================================================ + // Test Block 7: build_headers_json + // ================================================================ + std::cout << "\n--- Block 7: build_headers_json ---\n"; + + { + std::string headers = build_headers_json("sk-test-123"); + CHECK(headers.find("Authorization") != std::string::npos, + "T7.1: contains Authorization header"); + CHECK(headers.find("Bearer sk-test-123") != std::string::npos, + "T7.2: contains 'Bearer sk-test-123'"); + } + + { + // Empty API key + std::string headers = build_headers_json(""); + CHECK(headers.find("Authorization") != std::string::npos, + "T7.3: Authorization header present with empty key"); + CHECK(headers.find("Bearer ") != std::string::npos, + "T7.4: 'Bearer ' prefix present even with empty key"); + } + + // ================================================================ + // Test Block 8: extract_host_port (same logic as anthropic) + // ================================================================ + std::cout << "\n--- Block 8: extract_host_port ---\n"; + + { + std::string scheme, host, port, target; + bool ret = extract_host_port( + "https://api.deepseek.com/v1/chat/completions", + scheme, host, port, target); + CHECK(ret, "T8.1: valid HTTPS URL returns true"); + CHECK(scheme == "https", "T8.2: scheme is 'https'"); + CHECK(host == "api.deepseek.com", "T8.3: host extracted"); + CHECK(port == "443", "T8.4: default HTTPS port 443"); + CHECK(target == "/v1/chat/completions", "T8.5: target path extracted"); + } + + { + std::string scheme, host, port, target; + bool ret = extract_host_port( + "http://localhost:11434/api/generate", + scheme, host, port, target); + CHECK(ret, "T8.6: HTTP URL with port returns true"); + CHECK(scheme == "http", "T8.7: scheme is 'http'"); + CHECK(host == "localhost", "T8.8: host is 'localhost'"); + CHECK(port == "11434", "T8.9: explicit port 11434"); + } + + { + std::string scheme, host, port, target; + bool ret = extract_host_port( + "no-scheme-url.com", + scheme, host, port, target); + CHECK(!ret, "T8.10: URL without scheme returns false"); + } + + { + std::string scheme, host, port, target; + bool ret = extract_host_port( + "https://api.deepseek.com", + scheme, host, port, target); + CHECK(ret, "T8.11: URL without path returns true"); + CHECK(target == "/", "T8.12: target defaults to '/'"); + } + + // ================================================================ + // Test Block 9: secure_zero + // ================================================================ + std::cout << "\n--- Block 9: secure_zero ---\n"; + + { + char buf[16]; + memset(buf, 0xFF, sizeof(buf)); + secure_zero(buf, sizeof(buf)); + bool all_zero = true; + for (int i = 0; i < 16; ++i) { + if (buf[i] != 0) { all_zero = false; break; } + } + CHECK(all_zero, "T9.1: secure_zero zeros entire buffer"); + } + + { + secure_zero(nullptr, 0); + CHECK(true, "T9.2: secure_zero(nullptr, 0) does not crash"); + } + + // ================================================================ + // Test Block 10: append_history + // ================================================================ + std::cout << "\n--- Block 10: append_history ---\n"; + + { + json::array msgs; + dstalk_message_t m = {"user", "Hello", nullptr, nullptr}; + append_history(msgs, &m, 1); + CHECK(msgs.size() == 1, "T10.1: one message appended"); + CHECK(msgs[0].as_object()["role"].as_string() == "user", + "T10.2: role preserved"); + CHECK(msgs[0].as_object()["content"].as_string() == "Hello", + "T10.3: content preserved"); + } + + { + // Tool message (should include tool_call_id) + json::array msgs; + dstalk_message_t m = {"tool", "result data", "call_xyz", nullptr}; + append_history(msgs, &m, 1); + CHECK(msgs.size() == 1, "T10.4: tool message appended"); + auto obj = msgs[0].as_object(); + CHECK(obj["role"].as_string() == "tool", "T10.5: tool role preserved"); + CHECK(obj["tool_call_id"].as_string() == "call_xyz", + "T10.6: tool_call_id preserved"); + CHECK(obj["content"].as_string() == "result data", + "T10.7: tool message content preserved"); + } + + { + // Assistant with tool_calls_json + json::array msgs; + const char* tc_json = "[{\"id\":\"call_1\",\"type\":\"function\"," + "\"function\":{\"name\":\"get_weather\",\"arguments\":\"{}\"}}]"; + dstalk_message_t m = {"assistant", "Let me check", nullptr, tc_json}; + append_history(msgs, &m, 1); + CHECK(msgs.size() == 1, "T10.8: assistant with tool_calls appended"); + auto obj = msgs[0].as_object(); + CHECK(obj["role"].as_string() == "assistant", "T10.9: assistant role"); + CHECK(obj["content"].as_string() == "Let me check", "T10.10: content"); + CHECK(obj.if_contains("tool_calls") != nullptr, "T10.11: tool_calls present"); + } + + { + // Empty history (0 messages) + json::array msgs; + append_history(msgs, nullptr, 0); + CHECK(msgs.size() == 0, "T10.12: empty history produces empty array"); + } + + { + // Multiple messages + json::array msgs; + dstalk_message_t ms[2] = { + {"user", "Q1", nullptr, nullptr}, + {"assistant", "A1", nullptr, nullptr} + }; + append_history(msgs, ms, 2); + CHECK(msgs.size() == 2, "T10.13: two messages appended"); + } + + { + // Null role and null content — default to empty strings + json::array msgs; + dstalk_message_t m = {nullptr, nullptr, nullptr, nullptr}; + append_history(msgs, &m, 1); + CHECK(msgs.size() == 1, "T10.14: null fields produce valid JSON (no crash)"); + auto obj = msgs[0].as_object(); + CHECK(obj["role"].as_string() == "", "T10.15: null role → empty string"); + CHECK(obj["content"].as_string() == "", "T10.16: null content → empty string"); + } + + // ================================================================ + // Test Block 11: my_free_result — null safety + // ================================================================ + std::cout << "\n--- Block 11: my_free_result null safety ---\n"; + + { + // g_host is nullptr, so free_result should early-return + my_free_result(nullptr); + CHECK(true, "T11.1: free_result(nullptr) does not crash (null host)"); + } + + { + dstalk_chat_result_t r = {}; + r.ok = 1; + my_free_result(&r); + CHECK(true, "T11.2: free_result with zeroed fields does not crash"); + } + + // ================================================================ + // Test Block 12: my_configure — null host safety + // ================================================================ + std::cout << "\n--- Block 12: my_configure null host safety ---\n"; + + { + int ret = my_configure( + "deepseek", "https://api.deepseek.com/v1", + "sk-key", "deepseek-v4", 2048, 0.5); + CHECK(ret == 0, "T12.1: my_configure returns 0 with null host"); + CHECK(g_cfg.provider == "deepseek", "T12.2: provider stored"); + CHECK(g_cfg.max_tokens == 2048, "T12.3: max_tokens stored"); + CHECK(g_cfg.temperature == 0.5, "T12.4: temperature stored"); + } + + { + int ret = my_configure(nullptr, nullptr, nullptr, nullptr, 4096, 1.0); + CHECK(ret == 0, "T12.5: my_configure with all-null strings returns 0"); + } + + // ================================================================ + // Summary + // ================================================================ + std::cout << "\n"; + if (g_failures == 0) { + std::cout << "=== All deepseek plugin tests passed ===\n"; + return 0; + } else { + std::cerr << "=== " << g_failures << " test(s) FAILED ===\n"; + return 1; + } +} diff --git a/tests/smoke_test.cpp b/tests/smoke_test.cpp index 3a3a3e7..544d08e 100644 --- a/tests/smoke_test.cpp +++ b/tests/smoke_test.cpp @@ -24,6 +24,13 @@ static int g_regression_failures = 0; } \ } while (0) +// ---- W21.5 mock tool handler (qa-xu) ---- +static int g_mock_tool_called = 0; +static char* mock_tool_handler(const char* /*args_json*/) { + g_mock_tool_called++; + return dstalk_strdup("{\"mock_result\":\"ok\"}"); +} + int main() { const auto dir = std::filesystem::temp_directory_path() / "dstalk-smoke-test"; @@ -560,6 +567,103 @@ int main() } } + // ======================================================================== + // W21.5 Tool Calls 边界测试 (qa-xu 徐磊) + // 覆盖: null tool_calls_json / 空数组 "[]" / 有效 tool_calls mock 验证 + // ======================================================================== + std::cout << "\n--- Tool Calls Boundary Tests (W21.5) ---\n"; + + if (tools && session) { + // ---- W21.5-1: null tool_calls_json → 正常处理(不崩溃)---- + { + int before = 0; + session->history(&before); + + dstalk_message_t msg_null_tc = { + "assistant", "content with null tool_calls_json", nullptr, nullptr + }; + session->add(&msg_null_tc); + + int after = 0; + const dstalk_message_t* hist = session->history(&after); + REGCHECK(after == before + 1, + "W21.5-1a: null tool_calls_json add — count +1 (no crash)"); + if (hist && after > 0) { + REGCHECK(hist[after - 1].tool_calls_json == nullptr, + "W21.5-1b: null tool_calls_json — retrieved field is nullptr"); + } + } + + // ---- W21.5-2: 空 JSON 数组 "[]" → 正常处理(不崩溃)---- + { + int before = 0; + session->history(&before); + + dstalk_message_t msg_empty = { + "assistant", "content with empty [] tool_calls", nullptr, "[]" + }; + session->add(&msg_empty); + + int after = 0; + const dstalk_message_t* hist = session->history(&after); + REGCHECK(after == before + 1, + "W21.5-2a: empty [] tool_calls_json add — count +1 (no crash)"); + if (hist && after > 0) { + REGCHECK(hist[after - 1].tool_calls_json != nullptr && + std::strcmp(hist[after - 1].tool_calls_json, "[]") == 0, + "W21.5-2b: empty [] tool_calls_json — retrieved as \"[]\""); + } + } + + // ---- W21.5-3: 有效 tool_calls JSON → 验证 execute 被调用 (mock) ---- + { + g_mock_tool_called = 0; + int reg = tools->register_tool( + "__w21_5_mock", + "W21.5 mock tool for boundary test", + "{}", + mock_tool_handler + ); + REGCHECK(reg == 0, "W21.5-3a: register mock tool ok"); + + char* result = tools->execute("__w21_5_mock", "{}"); + REGCHECK(g_mock_tool_called == 1, + "W21.5-3b: mock tool handler was called (execute works)"); + if (result) { + REGCHECK(std::strstr(result, "mock_result") != nullptr, + "W21.5-3c: execute returned mock result json"); + dstalk_free(result); + } + + tools->unregister_tool("__w21_5_mock"); + + // 验证已注销的工具返回 error 而非崩溃 + char* err_result = tools->execute("__w21_5_mock", "{}"); + REGCHECK(err_result && std::strstr(err_result, "error") != nullptr, + "W21.5-3d: unregistered tool returns error (not crash)"); + if (err_result) dstalk_free(err_result); + } + + // ---- W21.5-4: save/load 往返保留 tool_calls_json ---- + if (file_io) { + const auto rtt_path = dir / "w21_5_tc_rtt.jsonl"; + int ret = session->save(rtt_path.string().c_str()); + REGCHECK(ret == 0, "W21.5-4a: session save with tool_calls msgs ok"); + + session->clear(); + ret = session->load(rtt_path.string().c_str()); + REGCHECK(ret == 0, "W21.5-4b: session load round-trip ok"); + + int count = 0; + session->history(&count); + REGCHECK(count > 0, "W21.5-4c: history non-empty after load round-trip"); + } + + session->clear(); + } else { + std::cerr << "[WARN] W21.5: tools or session service not available\n"; + } + // 清理 dstalk_shutdown(); std::cout << "[OK] dstalk_shutdown succeeded\n";