From d3bc23d19432487818e25c517380c9c94496f064 Mon Sep 17 00:00:00 2001 From: Elias Bachaalany Date: Mon, 19 Jan 2026 20:52:05 -0800 Subject: [PATCH] Add make_tool API and BYOK environment variable support Features: - make_tool(): Type-safe tool creation from lambdas with automatic JSON schema generation and std::optional parameter support - BYOK (Bring Your Own Key): Load API credentials from environment variables (COPILOT_SDK_BYOK_API_KEY, etc.) via auto_byok_from_env Test improvements: - Add verbose test descriptions for all 38 E2E tests - Add default_session_config() and default_resume_config() helpers - Skip resume-with-tools tests when BYOK is active (not supported) - Add standalone repro for ResumeSessionWithTools BYOK issue Bug fixes: - Add missing include for std::getenv - Handle std::optional params correctly in make_tool schema generation --- .github/workflows/ci.yml | 37 ++++ .gitignore | 2 + README.md | 100 +++++++++++ include/copilot/tool_builder.hpp | 210 ++++++++++++++++++++-- include/copilot/types.hpp | 64 +++++++ src/client.cpp | 29 +++ tests/repro/.gitignore | 4 + tests/repro/CMakeLists.txt | 57 ++++++ tests/repro/README.md | 49 ++++++ tests/repro/byok.env.example | 7 + tests/repro/repro_resume_with_tools.cpp | 177 +++++++++++++++++++ tests/test_e2e.cpp | 223 +++++++++++++++++++++--- tests/test_tool_builder.cpp | 110 ++++++++++++ 13 files changed, 1026 insertions(+), 43 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 tests/repro/.gitignore create mode 100644 tests/repro/CMakeLists.txt create mode 100644 tests/repro/README.md create mode 100644 tests/repro/byok.env.example create mode 100644 tests/repro/repro_resume_with_tools.cpp diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..091f131 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,37 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + build: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + include: + - os: ubuntu-latest + cmake_generator: "Unix Makefiles" + - os: windows-latest + cmake_generator: "Visual Studio 17 2022" + - os: macos-latest + cmake_generator: "Unix Makefiles" + + runs-on: ${{ matrix.os }} + + steps: + - uses: actions/checkout@v4 + + - name: Configure CMake + run: cmake -B build -G "${{ matrix.cmake_generator }}" -DCMAKE_BUILD_TYPE=Release -DCOPILOT_BUILD_TESTS=ON + + - name: Build + run: cmake --build build --config Release + + - name: Run Unit Tests + run: ctest --test-dir build -C Release -E "^E2ETest\." --output-on-failure + env: + COPILOT_SDK_CPP_SKIP_E2E: 1 diff --git a/.gitignore b/.gitignore index 92a1435..7254447 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +tests/byok.env +tests/logs/ build*/ cmake-build*/ out*/ diff --git a/README.md b/README.md index 4da3ad6..f8df3a7 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,106 @@ auto session = client.resume_session(session_id, resume_config).get(); See `examples/tools.cpp` and `examples/resume_with_tools.cpp` for complete examples. +### Fluent Tool Builder + +Use `make_tool` to create tools with automatic schema generation from lambda signatures: + +```cpp +#include + +// Single parameter - schema auto-generated +auto echo_tool = copilot::make_tool( + "echo", "Echo a message", + [](std::string message) { return message; }, + {"message"} // Parameter names +); + +// Multiple parameters +auto calc_tool = copilot::make_tool( + "add", "Add two numbers", + [](double a, double b) { return std::to_string(a + b); }, + {"first", "second"} +); + +// Optional parameters (not added to "required" in schema) +auto greet_tool = copilot::make_tool( + "greet", "Greet someone", + [](std::string name, std::optional title) { + if (title) + return "Hello, " + *title + " " + name + "!"; + return "Hello, " + name + "!"; + }, + {"name", "title"} +); + +// Use in session config +copilot::SessionConfig config; +config.tools = {echo_tool, calc_tool, greet_tool}; +``` + +## BYOK (Bring Your Own Key) + +Use your own API key instead of GitHub Copilot authentication. + +### Method 1: Explicit Configuration + +```cpp +copilot::ProviderConfig provider; +provider.api_key = "sk-your-api-key"; +provider.base_url = "https://api.openai.com/v1"; +provider.type = "openai"; + +copilot::SessionConfig config; +config.provider = provider; +config.model = "gpt-4"; +auto session = client.create_session(config).get(); +``` + +### Method 2: Environment Variables + +Set environment variables: + +```bash +export COPILOT_SDK_BYOK_API_KEY=sk-your-api-key +export COPILOT_SDK_BYOK_BASE_URL=https://api.openai.com/v1 # Optional, defaults to OpenAI +export COPILOT_SDK_BYOK_PROVIDER_TYPE=openai # Optional, defaults to "openai" +export COPILOT_SDK_BYOK_MODEL=gpt-4 # Optional +``` + +Then enable auto-loading in your code: + +```cpp +copilot::SessionConfig config; +config.auto_byok_from_env = true; // Load from COPILOT_SDK_BYOK_* env vars +auto session = client.create_session(config).get(); +``` + +**Precedence** (for each field): +1. Explicit value in `SessionConfig` (highest priority) +2. Environment variable (if `auto_byok_from_env = true`) +3. Default Copilot behavior (lowest priority) + +**Note:** `auto_byok_from_env` defaults to `false` for backwards compatibility. Existing code will not be affected by setting these environment variables. + +### Checking Environment Configuration + +```cpp +// Check if BYOK env vars are configured +if (copilot::ProviderConfig::is_env_configured()) { + // COPILOT_SDK_BYOK_API_KEY is set +} + +// Load provider config from env (returns nullopt if not configured) +if (auto provider = copilot::ProviderConfig::from_env()) { + // Use *provider +} + +// Load model from env +if (auto model = copilot::ProviderConfig::model_from_env()) { + // Use *model +} +``` + ## Install / Package ```sh diff --git a/include/copilot/tool_builder.hpp b/include/copilot/tool_builder.hpp index 3614418..81d1f55 100644 --- a/include/copilot/tool_builder.hpp +++ b/include/copilot/tool_builder.hpp @@ -141,6 +141,20 @@ struct schema_type> static json schema() { return schema_type::schema(); } }; +// Type trait to detect std::optional +template +struct is_optional : std::false_type +{ +}; + +template +struct is_optional> : std::true_type +{ +}; + +template +inline constexpr bool is_optional_v = is_optional::value; + /// Convert value to string for tool result template std::string to_result_string(const T& value) @@ -170,7 +184,16 @@ std::string to_result_string(const T& value) template T extract_arg(const json& args, const std::string& name) { - if constexpr (std::is_same_v, std::string>) + if constexpr (is_optional_v>) + { + using value_type = typename std::decay_t::value_type; + if (!args.contains(name) || args.at(name).is_null()) + { + return std::nullopt; + } + return extract_arg(args, name); + } + else if constexpr (std::is_same_v, std::string>) { return args.at(name).get(); } @@ -191,20 +214,6 @@ T extract_arg_or(const json& args, const std::string& name, const T& default_val return default_val; } -// Type trait to detect std::optional -template -struct is_optional : std::false_type -{ -}; - -template -struct is_optional> : std::true_type -{ -}; - -template -inline constexpr bool is_optional_v = is_optional::value; - } // namespace detail // ============================================================================= @@ -528,4 +537,175 @@ inline ToolBuilder tool(std::string name, std::string description) return ToolBuilder(std::move(name), std::move(description)); } +// ============================================================================= +// Function Traits for make_tool +// ============================================================================= + +namespace detail +{ + +/// Remove cv-ref qualifiers +template +using remove_cvref_t = std::remove_cv_t>; + +/// Function traits primary template +template +struct function_traits : function_traits +{ +}; + +/// Specialization for function pointers +template +struct function_traits +{ + using return_type = R; + static constexpr size_t arity = sizeof...(Args); + + template + using arg_type = std::tuple_element_t>; +}; + +/// Specialization for member function pointers (const) +template +struct function_traits +{ + using return_type = R; + static constexpr size_t arity = sizeof...(Args); + + template + using arg_type = std::tuple_element_t>; +}; + +/// Specialization for member function pointers (non-const) +template +struct function_traits +{ + using return_type = R; + static constexpr size_t arity = sizeof...(Args); + + template + using arg_type = std::tuple_element_t>; +}; + +/// Helper to invoke function with JSON args +template +auto invoke_with_json_impl(Func&& func, const json& args, + const std::vector& names, std::index_sequence) +{ + using traits = function_traits>; + return func( + extract_arg>>(args, names[Is])...); +} + +template +auto invoke_with_json(Func&& func, const json& args, const std::vector& names) +{ + using traits = function_traits>; + return invoke_with_json_impl(std::forward(func), args, names, + std::make_index_sequence{}); +} + +template +void add_required_if(json& required, const std::string& name) +{ + if constexpr (!is_optional_v) + { + required.push_back(name); + } +} + +/// Generate schema from function signature +template +json generate_schema_impl(const std::vector& names, std::index_sequence) +{ + using traits = function_traits>; + json schema = {{"type", "object"}, {"properties", json::object()}, {"required", json::array()}}; + + ((schema["properties"][names[Is]] = + schema_type>>::schema(), + add_required_if>>( + schema["required"], names[Is])), + ...); + + return schema; +} + +template +json generate_schema(const std::vector& names) +{ + using traits = function_traits>; + return generate_schema_impl(names, std::make_index_sequence{}); +} + +} // namespace detail + +// ============================================================================= +// make_tool - Claude SDK compatible API +// ============================================================================= + +/// Create a tool from a function with custom parameter names +/// Similar to claude::mcp::make_tool for API consistency +/// +/// Example: +/// @code +/// auto tool = copilot::make_tool("dbg_exec", "Execute debugger command", +/// [](std::string command) { return execute(command); }, +/// {"command"}); +/// @endcode +template +Tool make_tool(std::string name, std::string description, Func&& func, + std::vector param_names) +{ + using traits = detail::function_traits>; + + if (param_names.size() != traits::arity) + { + throw std::invalid_argument("Parameter name count mismatch for tool '" + name + + "': expected " + std::to_string(traits::arity) + ", got " + + std::to_string(param_names.size())); + } + + Tool tool; + tool.name = std::move(name); + tool.description = std::move(description); + tool.parameters_schema = detail::generate_schema(param_names); + + // Create handler that extracts args and invokes function + tool.handler = [f = std::forward(func), + names = std::move(param_names)](const ToolInvocation& inv) -> ToolResultObject + { + ToolResultObject result; + try + { + json args = inv.arguments.value_or(json::object()); + auto output = detail::invoke_with_json(f, args, names); + result.text_result_for_llm = detail::to_result_string(output); + } + catch (const std::exception& e) + { + result.result_type = "error"; + result.error = e.what(); + } + return result; + }; + + return tool; +} + +/// Create a tool with a single string parameter (common case) +/// @code +/// auto tool = copilot::make_tool("echo", "Echo message", +/// [](std::string msg) { return msg; }); // Auto-names param "arg0" +/// @endcode +template +Tool make_tool(std::string name, std::string description, Func&& func) +{ + using traits = detail::function_traits>; + std::vector names; + for (size_t i = 0; i < traits::arity; ++i) + names.push_back("arg" + std::to_string(i)); + return make_tool(std::move(name), std::move(description), std::forward(func), + std::move(names)); +} + } // namespace copilot diff --git a/include/copilot/types.hpp b/include/copilot/types.hpp index 60f3e95..ddbf186 100644 --- a/include/copilot/types.hpp +++ b/include/copilot/types.hpp @@ -4,6 +4,7 @@ #pragma once #include +#include #include #include #include @@ -270,6 +271,61 @@ struct ProviderConfig std::optional api_key; std::optional bearer_token; std::optional azure; + + // ───────────────────────────────────────────────────────────────────────── + // Environment Variable Support + // ───────────────────────────────────────────────────────────────────────── + + /// Environment variable names for BYOK configuration + static constexpr const char* ENV_API_KEY = "COPILOT_SDK_BYOK_API_KEY"; + static constexpr const char* ENV_BASE_URL = "COPILOT_SDK_BYOK_BASE_URL"; + static constexpr const char* ENV_PROVIDER_TYPE = "COPILOT_SDK_BYOK_PROVIDER_TYPE"; + static constexpr const char* ENV_MODEL = "COPILOT_SDK_BYOK_MODEL"; + + /// Check if BYOK environment variables are configured + /// @return true if COPILOT_SDK_BYOK_API_KEY is set and non-empty + static bool is_env_configured() + { + const char* key = std::getenv(ENV_API_KEY); + return key != nullptr && key[0] != '\0'; + } + + /// Load ProviderConfig from COPILOT_SDK_BYOK_* environment variables + /// @return ProviderConfig if API key is set, nullopt otherwise + static std::optional from_env() + { + if (!is_env_configured()) + return std::nullopt; + + ProviderConfig config; + + // Required: API key + config.api_key = std::getenv(ENV_API_KEY); + + // Optional: Base URL (default to OpenAI) + if (const char* url = std::getenv(ENV_BASE_URL)) + config.base_url = url; + else + config.base_url = "https://api.openai.com/v1"; + + // Optional: Provider type (default to openai) + if (const char* ptype = std::getenv(ENV_PROVIDER_TYPE)) + config.type = ptype; + else + config.type = "openai"; + + return config; + } + + /// Load model from COPILOT_SDK_BYOK_MODEL environment variable + /// @return Model string if set, nullopt otherwise + static std::optional model_from_env() + { + const char* model = std::getenv(ENV_MODEL); + if (model != nullptr && model[0] != '\0') + return std::string(model); + return std::nullopt; + } }; inline void to_json(json& j, const ProviderConfig& c) @@ -493,6 +549,10 @@ struct SessionConfig bool streaming = false; std::optional> mcp_servers; std::optional> custom_agents; + + /// If true and provider/model not explicitly set, load from COPILOT_SDK_BYOK_* env vars. + /// Default: false (explicit configuration preferred over environment variables) + bool auto_byok_from_env = false; }; /// Configuration for resuming an existing session @@ -504,6 +564,10 @@ struct ResumeSessionConfig bool streaming = false; std::optional> mcp_servers; std::optional> custom_agents; + + /// If true and provider not explicitly set, load from COPILOT_SDK_BYOK_* env vars. + /// Default: false (explicit configuration preferred over environment variables) + bool auto_byok_from_env = false; }; /// Options for sending a message diff --git a/src/client.cpp b/src/client.cpp index c6e3247..0a80664 100644 --- a/src/client.cpp +++ b/src/client.cpp @@ -18,8 +18,17 @@ json build_session_create_request(const SessionConfig& config) { json request; + // Model: explicit > env (if auto_byok_from_env) > none if (config.model.has_value()) + { request["model"] = *config.model; + } + else if (config.auto_byok_from_env) + { + if (auto env_model = ProviderConfig::model_from_env()) + request["model"] = *env_model; + } + if (config.session_id.has_value()) request["sessionId"] = *config.session_id; if (config.on_permission_request.has_value()) @@ -57,8 +66,18 @@ json build_session_create_request(const SessionConfig& config) request["excludedTools"] = *config.excluded_tools; if (config.streaming) request["streaming"] = config.streaming; + + // Provider: explicit > env (if auto_byok_from_env) > none if (config.provider.has_value()) + { request["provider"] = *config.provider; + } + else if (config.auto_byok_from_env) + { + if (auto env_provider = ProviderConfig::from_env()) + request["provider"] = *env_provider; + } + if (config.mcp_servers.has_value()) request["mcpServers"] = *config.mcp_servers; if (config.custom_agents.has_value()) @@ -96,8 +115,18 @@ json build_session_resume_request(const std::string& session_id, const ResumeSes } if (config.streaming) request["streaming"] = config.streaming; + + // Provider: explicit > env (if auto_byok_from_env) > none if (config.provider.has_value()) + { request["provider"] = *config.provider; + } + else if (config.auto_byok_from_env) + { + if (auto env_provider = ProviderConfig::from_env()) + request["provider"] = *env_provider; + } + if (config.mcp_servers.has_value()) request["mcpServers"] = *config.mcp_servers; if (config.custom_agents.has_value()) diff --git a/tests/repro/.gitignore b/tests/repro/.gitignore new file mode 100644 index 0000000..1b12b3f --- /dev/null +++ b/tests/repro/.gitignore @@ -0,0 +1,4 @@ +byok.env +build/ +*.exe +*.obj diff --git a/tests/repro/CMakeLists.txt b/tests/repro/CMakeLists.txt new file mode 100644 index 0000000..ead25f2 --- /dev/null +++ b/tests/repro/CMakeLists.txt @@ -0,0 +1,57 @@ +# Standalone repro for ResumeSessionWithTools issue +# This is a completely independent project - does NOT include the SDK as subdirectory +# +# Prerequisites: +# Build the SDK first: +# cd ../.. +# cmake -B build -G "Visual Studio 17 2022" -A x64 +# cmake --build build --config Release +# +# Build this repro: +# cd tests/repro +# cmake -B build -G "Visual Studio 17 2022" -A x64 +# cmake --build build --config Release +# +# Run: +# build\Release\repro_resume_with_tools.exe + +cmake_minimum_required(VERSION 3.20) +project(repro_resume_with_tools) + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +# Paths to pre-built SDK +set(SDK_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/../..") +set(SDK_INCLUDE "${SDK_ROOT}/include") +set(SDK_LIB_DIR "${SDK_ROOT}/build/Release") +set(SDK_BUILD_DIR "${SDK_ROOT}/build") + +# Check SDK was built +if(NOT EXISTS "${SDK_LIB_DIR}/copilot_sdk_cpp.lib") + message(FATAL_ERROR + "SDK not built. Please build the SDK first:\n" + " cd ${SDK_ROOT}\n" + " cmake -B build && cmake --build build --config Release") +endif() + +# Fetch nlohmann/json (same as SDK does) +include(FetchContent) +FetchContent_Declare( + nlohmann_json + GIT_REPOSITORY https://github.com/nlohmann/json.git + GIT_TAG v3.11.3 +) +set(JSON_BuildTests OFF CACHE INTERNAL "") +FetchContent_MakeAvailable(nlohmann_json) + +add_executable(repro_resume_with_tools repro_resume_with_tools.cpp) + +target_include_directories(repro_resume_with_tools PRIVATE "${SDK_INCLUDE}") +target_link_libraries(repro_resume_with_tools PRIVATE nlohmann_json::nlohmann_json) +target_link_libraries(repro_resume_with_tools PRIVATE "${SDK_LIB_DIR}/copilot_sdk_cpp.lib") + +# Windows socket library +if(WIN32) + target_link_libraries(repro_resume_with_tools PRIVATE ws2_32) +endif() diff --git a/tests/repro/README.md b/tests/repro/README.md new file mode 100644 index 0000000..214ce02 --- /dev/null +++ b/tests/repro/README.md @@ -0,0 +1,49 @@ +# Repro: ResumeSessionWithTools BYOK Issue + +Standalone reproduction of the issue where resuming a session with new tools +fails when using BYOK (Bring Your Own Key) with OpenAI, but works with Copilot auth. + +## Issue + +When using BYOK with OpenAI: +- Resume session with new tool registration +- Ask AI to use the new tool +- **Tool is never invoked** + +With Copilot auth: **PASS** (20s) +With BYOK/OpenAI: **FAIL** (tool never called) + +## Build + +```cmd +cd tests/repro +cmake -B build -G "Visual Studio 17 2022" -A x64 +cmake --build build --config Release +``` + +## Run + +Without BYOK (uses Copilot auth): +```cmd +build\Release\repro_resume_with_tools.exe +``` + +With BYOK: +```cmd +copy byok.env.example byok.env +# Edit byok.env with your OpenAI credentials +build\Release\repro_resume_with_tools.exe +``` + +## Expected Output + +With Copilot: +``` +[!] Tool invoked with key: ALPHA +[PASS] Tool was invoked correctly! +``` + +With BYOK/OpenAI: +``` +[FAIL] Tool was NOT invoked - this is the BYOK/OpenAI limitation +``` diff --git a/tests/repro/byok.env.example b/tests/repro/byok.env.example new file mode 100644 index 0000000..de5fd28 --- /dev/null +++ b/tests/repro/byok.env.example @@ -0,0 +1,7 @@ +# Copy this file to byok.env and fill in your API credentials +# This file is gitignored for security + +COPILOT_SDK_BYOK_API_KEY=sk-your-api-key-here +COPILOT_SDK_BYOK_BASE_URL=https://api.openai.com/v1 +COPILOT_SDK_BYOK_PROVIDER_TYPE=openai +COPILOT_SDK_BYOK_MODEL=gpt-4.1 diff --git a/tests/repro/repro_resume_with_tools.cpp b/tests/repro/repro_resume_with_tools.cpp new file mode 100644 index 0000000..6ac197c --- /dev/null +++ b/tests/repro/repro_resume_with_tools.cpp @@ -0,0 +1,177 @@ +// Repro: ResumeSessionWithTools fails with BYOK (OpenAI) but passes with Copilot +// +// Issue: When using BYOK with OpenAI, resuming a session with new tools doesn't work. +// The AI never invokes the tool registered during resume. +// +// With Copilot auth: PASS (20s) +// With BYOK/OpenAI: FAIL (tool never called) +// +// Build: +// cl /EHsc /std:c++20 /I../../include repro_resume_with_tools.cpp /link /LIBPATH:../../build/Release copilot_sdk_cpp.lib +// +// Or use CMake (see CMakeLists.txt) + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace copilot; + +// Load byok.env if present +void load_byok_env() +{ + std::filesystem::path env_file = std::filesystem::path(__FILE__).parent_path() / "byok.env"; + if (!std::filesystem::exists(env_file)) + { + std::cout << "[REPRO] No byok.env found, using Copilot auth\n"; + return; + } + + std::ifstream file(env_file); + std::string line; + while (std::getline(file, line)) + { + if (line.empty() || line[0] == '#') + continue; + auto pos = line.find('='); + if (pos == std::string::npos) + continue; + std::string key = line.substr(0, pos); + std::string value = line.substr(pos + 1); +#ifdef _WIN32 + _putenv_s(key.c_str(), value.c_str()); +#else + setenv(key.c_str(), value.c_str(), 1); +#endif + } + std::cout << "[REPRO] Loaded BYOK config from byok.env\n"; +} + +int main() +{ + load_byok_env(); + + std::cout << "\n=== Repro: ResumeSessionWithTools ===\n\n"; + + // Create client + ClientOptions opts; + auto client = std::make_unique(opts); + client->start().get(); + std::cout << "[1] Client started\n"; + + // Create session config with BYOK if available + SessionConfig config; + config.auto_byok_from_env = true; + + // Create initial session (no tools) + auto session1 = client->create_session(config).get(); + std::string session_id = session1->session_id(); + std::cout << "[2] Created session: " << session_id << "\n"; + + // Wait for first message + std::atomic idle{false}; + std::mutex mtx; + std::condition_variable cv; + + session1->on([&](const SessionEvent& event) { + if (event.type == SessionEventType::SessionIdle) + { + idle = true; + cv.notify_one(); + } + }); + + MessageOptions msg_opts; + msg_opts.prompt = "Say hello"; + session1->send(msg_opts).get(); + std::cout << "[3] Sent initial message\n"; + + { + std::unique_lock lock(mtx); + cv.wait_for(lock, std::chrono::seconds(30), [&] { return idle.load(); }); + } + std::cout << "[4] Session idle after first message\n"; + + // Stop client (don't destroy session) + client->stop().get(); + std::cout << "[5] Client stopped\n"; + + // Restart client + client = std::make_unique(opts); + client->start().get(); + std::cout << "[6] Client restarted\n"; + + // Define tool for resume + std::atomic tool_called{false}; + std::string received_key; + + Tool secret_tool; + secret_tool.name = "get_secret"; + secret_tool.description = "Returns a secret value"; + secret_tool.parameters_schema = json{ + {"type", "object"}, + {"properties", {{"key", {{"type", "string"}}}}}, + {"required", {"key"}}}; + secret_tool.handler = [&](const ToolInvocation& inv) -> ToolResultObject { + tool_called = true; + received_key = inv.arguments.value()["key"].get(); + std::cout << "[!] Tool invoked with key: " << received_key << "\n"; + + ToolResultObject result; + result.text_result_for_llm = "SECRET_VALUE_12345"; + result.result_type = "success"; + return result; + }; + + // Resume session WITH the new tool + ResumeSessionConfig resume_config; + resume_config.auto_byok_from_env = true; + resume_config.tools = {secret_tool}; + + auto session2 = client->resume_session(session_id, resume_config).get(); + std::cout << "[7] Resumed session with tool\n"; + + // Reset for second wait + idle = false; + + session2->on([&](const SessionEvent& event) { + if (event.type == SessionEventType::SessionIdle) + { + idle = true; + cv.notify_one(); + } + }); + + // Ask AI to use the tool + msg_opts.prompt = "Use the get_secret tool with key 'ALPHA' and tell me the result."; + session2->send(msg_opts).get(); + std::cout << "[8] Sent message asking to use tool\n"; + + { + std::unique_lock lock(mtx); + cv.wait_for(lock, std::chrono::seconds(60), [&] { return idle.load(); }); + } + + // Check results + std::cout << "\n=== Results ===\n"; + std::cout << "Tool called: " << (tool_called ? "YES" : "NO") << "\n"; + std::cout << "Received key: " << (received_key.empty() ? "(empty)" : received_key) << "\n"; + + if (tool_called && received_key == "ALPHA") + { + std::cout << "\n[PASS] Tool was invoked correctly!\n"; + return 0; + } + else + { + std::cout << "\n[FAIL] Tool was NOT invoked - this is the BYOK/OpenAI limitation\n"; + return 1; + } +} diff --git a/tests/test_e2e.cpp b/tests/test_e2e.cpp index a511a5d..4297b44 100644 --- a/tests/test_e2e.cpp +++ b/tests/test_e2e.cpp @@ -25,6 +25,94 @@ using namespace copilot; +// ============================================================================= +// BYOK Environment File Loader +// ============================================================================= + +/// Load environment variables from tests/byok.env if it exists. +/// File format: KEY=VALUE per line (no quotes needed, # comments supported) +static void load_byok_env_file() +{ + static std::once_flag once; + std::call_once( + once, + []() + { + // Get directory of this source file and look for byok.env + std::filesystem::path source_path(__FILE__); + std::filesystem::path env_file = source_path.parent_path() / "byok.env"; + + if (!std::filesystem::exists(env_file)) + { + std::cerr << "[E2E] No byok.env file found at: " << env_file << "\n"; + return; + } + + std::ifstream file(env_file); + if (!file.is_open()) + { + std::cerr << "[E2E] Failed to open byok.env file\n"; + return; + } + + std::cerr << "[E2E] Loading BYOK config from: " << env_file << "\n"; + + std::string line; + int count = 0; + while (std::getline(file, line)) + { + // Skip empty lines and comments + if (line.empty() || line[0] == '#') + continue; + + // Trim whitespace + size_t start = line.find_first_not_of(" \t"); + if (start == std::string::npos) + continue; + size_t end = line.find_last_not_of(" \t\r\n"); + line = line.substr(start, end - start + 1); + + // Find KEY=VALUE + size_t eq_pos = line.find('='); + if (eq_pos == std::string::npos) + continue; + + std::string key = line.substr(0, eq_pos); + std::string value = line.substr(eq_pos + 1); + + // Trim key and value + key.erase(0, key.find_first_not_of(" \t")); + key.erase(key.find_last_not_of(" \t") + 1); + value.erase(0, value.find_first_not_of(" \t")); + value.erase(value.find_last_not_of(" \t\r\n") + 1); + + // Set environment variable +#ifdef _WIN32 + _putenv_s(key.c_str(), value.c_str()); +#else + setenv(key.c_str(), value.c_str(), 1); +#endif + // Mask the value for logging (show only last 4 chars) + std::string masked = value.length() > 4 + ? std::string(value.length() - 4, '*') + value.substr(value.length() - 4) + : "****"; + std::cerr << "[E2E] " << key << "=" << masked << "\n"; + count++; + } + + std::cerr << "[E2E] Loaded " << count << " environment variables from byok.env\n"; + } + ); +} + +/// Check if BYOK is active (API key env var is set) +/// Used to skip tests that don't work with BYOK providers +static bool is_byok_active() +{ + const char* key = std::getenv("COPILOT_SDK_BYOK_API_KEY"); + return key != nullptr && key[0] != '\0'; +} + // ============================================================================= // Test Fixture // ============================================================================= @@ -34,6 +122,9 @@ class E2ETest : public ::testing::Test protected: void SetUp() override { + // Load BYOK environment variables from tests/byok.env if it exists + load_byok_env_file(); + // E2E tests run by default. CI can set COPILOT_SDK_CPP_SKIP_E2E=1 to disable. if (should_skip_e2e_tests()) GTEST_SKIP() << "E2E tests disabled via COPILOT_SDK_CPP_SKIP_E2E"; @@ -84,7 +175,11 @@ class E2ETest : public ::testing::Test Client client(opts); client.start().get(); - auto session = client.create_session().get(); + + // Use BYOK config if available (from tests/byok.env) + SessionConfig session_config; + session_config.auto_byok_from_env = true; + auto session = client.create_session(session_config).get(); std::mutex mtx; std::condition_variable cv; @@ -166,6 +261,30 @@ class E2ETest : public ::testing::Test return std::make_unique(opts); } + /// Create a default SessionConfig with BYOK env vars enabled. + /// If tests/byok.env exists, it will have been loaded and these vars will be used. + static SessionConfig default_session_config() + { + SessionConfig config; + config.auto_byok_from_env = true; + return config; + } + + /// Create a default ResumeSessionConfig with BYOK env vars enabled. + static ResumeSessionConfig default_resume_config() + { + ResumeSessionConfig config; + config.auto_byok_from_env = true; + return config; + } + + /// Print test description for verbose output + static void test_info(const char* description) + { + std::cerr << "\n[TEST] " << description << "\n"; + std::cerr << std::string(60, '-') << "\n"; + } + static inline std::atomic copilot_can_run_{true}; static inline std::string copilot_skip_reason_; }; @@ -176,6 +295,7 @@ class E2ETest : public ::testing::Test TEST_F(E2ETest, StartAndStop) { + test_info("Basic connection test: Start CLI process, verify connected, then stop cleanly."); auto client = create_client(); EXPECT_EQ(client->state(), ConnectionState::Disconnected); @@ -191,6 +311,7 @@ TEST_F(E2ETest, StartAndStop) TEST_F(E2ETest, Ping) { + test_info("Ping test: Send ping RPC to CLI and verify response with protocol version."); auto client = create_client(); client->start().get(); @@ -206,6 +327,7 @@ TEST_F(E2ETest, Ping) TEST_F(E2ETest, PingWithoutMessage) { + test_info("Ping without message: Verify ping works with null/empty message."); auto client = create_client(); client->start().get(); @@ -223,10 +345,11 @@ TEST_F(E2ETest, PingWithoutMessage) TEST_F(E2ETest, CreateSession) { + test_info("Create session: Start client, create session with BYOK config, verify session ID returned."); auto client = create_client(); client->start().get(); - SessionConfig config; + auto config = default_session_config(); auto session = client->create_session(config).get(); EXPECT_NE(session, nullptr); @@ -238,10 +361,11 @@ TEST_F(E2ETest, CreateSession) TEST_F(E2ETest, CreateSessionWithModel) { + test_info("Create session with explicit model: Test model override in SessionConfig."); auto client = create_client(); client->start().get(); - SessionConfig config; + auto config = default_session_config(); config.model = "gpt-4.1"; // Use a known model auto session = client->create_session(config).get(); @@ -255,6 +379,7 @@ TEST_F(E2ETest, CreateSessionWithModel) TEST_F(E2ETest, CreateSessionWithTools) { + test_info("Tool execution test: Register custom tool, ask AI to use it, verify tool called with correct args."); auto client = create_client(); client->start().get(); @@ -293,7 +418,7 @@ TEST_F(E2ETest, CreateSessionWithTools) }; // Create session with the tool - SessionConfig config; + auto config = default_session_config(); config.tools = {secret_tool}; config.on_permission_request = [](const PermissionRequest&) -> PermissionRequestResult { @@ -362,6 +487,7 @@ TEST_F(E2ETest, CreateSessionWithTools) TEST_F(E2ETest, ListSessions) { + test_info("List sessions: Create session, send message, verify session appears in history list."); auto client = create_client(); client->start().get(); @@ -402,6 +528,7 @@ TEST_F(E2ETest, ListSessions) TEST_F(E2ETest, GetLastSessionId) { + test_info("Get last session ID: Create session, destroy it, verify get_last_session_id() works."); auto client = create_client(); client->start().get(); @@ -424,10 +551,11 @@ TEST_F(E2ETest, GetLastSessionId) TEST_F(E2ETest, SendMessage) { + test_info("Send message: Create session, send prompt, wait for SessionIdle, verify events received."); auto client = create_client(); client->start().get(); - auto session = client->create_session().get(); + auto session = client->create_session(default_session_config()).get(); // Track events std::mutex mtx; @@ -473,10 +601,11 @@ TEST_F(E2ETest, SendMessage) TEST_F(E2ETest, StreamingResponse) { + test_info("Streaming response: Enable streaming, send prompt, verify multiple AssistantMessageDelta events."); auto client = create_client(); client->start().get(); - SessionConfig config; + auto config = default_session_config(); config.streaming = true; auto session = client->create_session(config).get(); @@ -529,6 +658,7 @@ TEST_F(E2ETest, StreamingResponse) TEST_F(E2ETest, AbortMessage) { + test_info("Abort message: Send long prompt, call abort() mid-stream, verify session becomes idle."); auto client = create_client(); client->start().get(); @@ -578,6 +708,7 @@ TEST_F(E2ETest, AbortMessage) TEST_F(E2ETest, GetMessages) { + test_info("Get messages: Send message, wait for response, call get_messages() to retrieve history."); auto client = create_client(); client->start().get(); @@ -625,6 +756,7 @@ TEST_F(E2ETest, GetMessages) TEST_F(E2ETest, ResumeSession) { + test_info("Resume session: Create session, stop client, restart, resume by ID, verify same session."); auto client = create_client(); client->start().get(); @@ -664,7 +796,7 @@ TEST_F(E2ETest, ResumeSession) client = create_client(); client->start().get(); - ResumeSessionConfig resume_config; + auto resume_config = default_resume_config(); auto session2 = client->resume_session(session_id, resume_config).get(); EXPECT_EQ(session2->session_id(), session_id); @@ -676,6 +808,12 @@ TEST_F(E2ETest, ResumeSession) TEST_F(E2ETest, ResumeSessionWithTools) { + test_info("Resume with tools: Create session, stop, resume with new tool, invoke tool successfully."); + + // BYOK/OpenAI doesn't support resuming sessions with new tools + if (is_byok_active()) + GTEST_SKIP() << "Skipping: BYOK providers don't support resume_session with tools"; + auto client = create_client(); client->start().get(); @@ -749,7 +887,7 @@ TEST_F(E2ETest, ResumeSessionWithTools) client = create_client(); client->start().get(); - ResumeSessionConfig resume_config; + auto resume_config = default_resume_config(); resume_config.tools = {secret_tool}; auto session2 = client->resume_session(session_id, resume_config).get(); @@ -813,6 +951,7 @@ TEST_F(E2ETest, ResumeSessionWithTools) TEST_F(E2ETest, EventSubscription) { + test_info("Event subscription: Subscribe to session events, send message, verify events received."); auto client = create_client(); client->start().get(); @@ -866,6 +1005,7 @@ TEST_F(E2ETest, EventSubscription) TEST_F(E2ETest, MultipleSubscriptions) { + test_info("Multiple subscriptions: Register two event handlers, verify both receive same events."); auto client = create_client(); client->start().get(); @@ -917,11 +1057,12 @@ TEST_F(E2ETest, MultipleSubscriptions) TEST_F(E2ETest, InvalidSessionId) { + test_info("Invalid session ID: Attempt to resume non-existent session, expect exception."); auto client = create_client(); client->start().get(); // Try to resume non-existent session - ResumeSessionConfig config; + auto config = default_resume_config(); EXPECT_THROW( { client->resume_session("non-existent-session-id-12345", config).get(); }, std::exception @@ -932,6 +1073,7 @@ TEST_F(E2ETest, InvalidSessionId) TEST_F(E2ETest, ForceStop) { + test_info("Force stop: Create session, call force_stop(), verify client disconnects immediately."); auto client = create_client(); client->start().get(); @@ -949,6 +1091,7 @@ TEST_F(E2ETest, ForceStop) TEST_F(E2ETest, ConcurrentPings) { + test_info("Concurrent pings: Send 5 ping requests simultaneously, verify all return correctly."); auto client = create_client(); client->start().get(); @@ -975,6 +1118,7 @@ TEST_F(E2ETest, ConcurrentPings) TEST_F(E2ETest, AcceptMcpServerConfigOnSessionCreate) { + test_info("MCP on create: Create session with MCP server config, verify session created."); auto client = create_client(); client->start().get(); @@ -988,7 +1132,7 @@ TEST_F(E2ETest, AcceptMcpServerConfigOnSessionCreate) std::map mcp_servers; mcp_servers["test-server"] = mcp_config; - SessionConfig config; + auto config = default_session_config(); config.mcp_servers = mcp_servers; auto session = client->create_session(config).get(); @@ -1002,6 +1146,7 @@ TEST_F(E2ETest, AcceptMcpServerConfigOnSessionCreate) TEST_F(E2ETest, AcceptMcpServerConfigOnSessionResume) { + test_info("MCP on resume: Create session, stop, resume with MCP config, verify session works."); auto client = create_client(); client->start().get(); @@ -1044,7 +1189,7 @@ TEST_F(E2ETest, AcceptMcpServerConfigOnSessionResume) std::map mcp_servers; mcp_servers["test-server"] = mcp_config; - ResumeSessionConfig resume_config; + auto resume_config = default_resume_config(); resume_config.mcp_servers = mcp_servers; auto session2 = client->resume_session(session_id, resume_config).get(); @@ -1057,6 +1202,7 @@ TEST_F(E2ETest, AcceptMcpServerConfigOnSessionResume) TEST_F(E2ETest, HandleMultipleMcpServers) { + test_info("Multiple MCP servers: Create session with two MCP servers, verify session created."); auto client = create_client(); client->start().get(); @@ -1076,7 +1222,7 @@ TEST_F(E2ETest, HandleMultipleMcpServers) mcp_servers["server1"] = config1; mcp_servers["server2"] = config2; - SessionConfig config; + auto config = default_session_config(); config.mcp_servers = mcp_servers; auto session = client->create_session(config).get(); @@ -1094,6 +1240,7 @@ TEST_F(E2ETest, HandleMultipleMcpServers) TEST_F(E2ETest, AcceptCustomAgentConfigOnSessionCreate) { + test_info("Custom agent on create: Create session with custom agent, send message, verify works."); auto client = create_client(); client->start().get(); @@ -1104,7 +1251,7 @@ TEST_F(E2ETest, AcceptCustomAgentConfigOnSessionCreate) agent.prompt = "You are a helpful test agent."; agent.infer = true; - SessionConfig config; + auto config = default_session_config(); config.custom_agents = std::vector{agent}; auto session = client->create_session(config).get(); @@ -1143,6 +1290,7 @@ TEST_F(E2ETest, AcceptCustomAgentConfigOnSessionCreate) TEST_F(E2ETest, HandleCustomAgentWithTools) { + test_info("Custom agent with tools: Create agent with specific tool restrictions, verify session."); auto client = create_client(); client->start().get(); @@ -1154,7 +1302,7 @@ TEST_F(E2ETest, HandleCustomAgentWithTools) agent.tools = std::vector{"bash", "edit"}; agent.infer = true; - SessionConfig config; + auto config = default_session_config(); config.custom_agents = std::vector{agent}; auto session = client->create_session(config).get(); @@ -1168,6 +1316,7 @@ TEST_F(E2ETest, HandleCustomAgentWithTools) TEST_F(E2ETest, HandleCustomAgentWithMcpServers) { + test_info("Custom agent with MCP: Create agent with its own MCP server, verify session."); auto client = create_client(); client->start().get(); @@ -1187,7 +1336,7 @@ TEST_F(E2ETest, HandleCustomAgentWithMcpServers) agent.prompt = "You are an agent with MCP servers."; agent.mcp_servers = agent_mcp_servers; - SessionConfig config; + auto config = default_session_config(); config.custom_agents = std::vector{agent}; auto session = client->create_session(config).get(); @@ -1201,6 +1350,7 @@ TEST_F(E2ETest, HandleCustomAgentWithMcpServers) TEST_F(E2ETest, HandleMultipleCustomAgents) { + test_info("Multiple custom agents: Create session with two custom agents, verify session."); auto client = create_client(); client->start().get(); @@ -1217,7 +1367,7 @@ TEST_F(E2ETest, HandleMultipleCustomAgents) agent2.prompt = "You are agent two."; agent2.infer = false; - SessionConfig config; + auto config = default_session_config(); config.custom_agents = std::vector{agent1, agent2}; auto session = client->create_session(config).get(); @@ -1231,6 +1381,7 @@ TEST_F(E2ETest, HandleMultipleCustomAgents) TEST_F(E2ETest, AcceptBothMcpServersAndCustomAgents) { + test_info("MCP + custom agents: Create session with both MCP servers and agents, send message."); auto client = create_client(); client->start().get(); @@ -1251,7 +1402,7 @@ TEST_F(E2ETest, AcceptBothMcpServersAndCustomAgents) agent.description = "An agent using shared MCP servers"; agent.prompt = "You are a combined test agent."; - SessionConfig config; + auto config = default_session_config(); config.mcp_servers = mcp_servers; config.custom_agents = std::vector{agent}; @@ -1295,6 +1446,7 @@ TEST_F(E2ETest, AcceptBothMcpServersAndCustomAgents) TEST_F(E2ETest, PermissionCallbackIsCalled) { + test_info("Permission callback: Register callback, send message, verify callback invoked on tool use."); auto client = create_client(); client->start().get(); @@ -1302,7 +1454,7 @@ TEST_F(E2ETest, PermissionCallbackIsCalled) std::vector requested_tools; std::mutex tool_mtx; - SessionConfig config; + auto config = default_session_config(); config.on_permission_request = [&](const PermissionRequest& request) -> PermissionRequestResult { permission_call_count++; @@ -1363,12 +1515,13 @@ TEST_F(E2ETest, PermissionCallbackIsCalled) TEST_F(E2ETest, PermissionCallbackCanDeny) { + test_info("Permission denial: Callback denies bash tools, verify session completes gracefully."); auto client = create_client(); client->start().get(); std::atomic denied_something{false}; - SessionConfig config; + auto config = default_session_config(); config.on_permission_request = [&](const PermissionRequest& request) -> PermissionRequestResult { PermissionRequestResult result; @@ -1430,10 +1583,11 @@ TEST_F(E2ETest, PermissionCallbackCanDeny) TEST_F(E2ETest, SystemMessageAppendMode) { + test_info("System message append: Set append mode system message, verify AI follows instruction."); auto client = create_client(); client->start().get(); - SessionConfig config; + auto config = default_session_config(); SystemMessageConfig sys_msg; sys_msg.mode = SystemMessageMode::Append; sys_msg.content = "Always end your responses with 'APPENDED_MARKER_12345'."; @@ -1484,10 +1638,11 @@ TEST_F(E2ETest, SystemMessageAppendMode) TEST_F(E2ETest, SystemMessageReplaceMode) { + test_info("System message replace: Replace system prompt entirely, verify AI behaves differently."); auto client = create_client(); client->start().get(); - SessionConfig config; + auto config = default_session_config(); SystemMessageConfig sys_msg; sys_msg.mode = SystemMessageMode::Replace; sys_msg.content = "You are a calculator. Only respond with numbers, no words."; @@ -1539,10 +1694,11 @@ TEST_F(E2ETest, SystemMessageReplaceMode) TEST_F(E2ETest, MessageWithFileAttachment) { + test_info("File attachment: Attach temp file to message, verify AI reads file content."); auto client = create_client(); client->start().get(); - SessionConfig config; + auto config = default_session_config(); config.on_permission_request = [](const PermissionRequest&) -> PermissionRequestResult { PermissionRequestResult r; @@ -1609,10 +1765,11 @@ TEST_F(E2ETest, MessageWithFileAttachment) TEST_F(E2ETest, MessageWithMultipleAttachments) { + test_info("Multiple attachments: Attach two files, verify AI references content from both."); auto client = create_client(); client->start().get(); - SessionConfig config; + auto config = default_session_config(); config.on_permission_request = [](const PermissionRequest&) -> PermissionRequestResult { PermissionRequestResult r; @@ -1691,6 +1848,7 @@ TEST_F(E2ETest, MessageWithMultipleAttachments) TEST_F(E2ETest, ToolCallIdIsPropagated) { + test_info("Tool call ID propagation: Verify tool_call_id is passed to handler and matches events."); auto client = create_client(); client->start().get(); @@ -1721,7 +1879,7 @@ TEST_F(E2ETest, ToolCallIdIsPropagated) return result; }; - SessionConfig config; + auto config = default_session_config(); config.tools = {test_tool}; config.on_permission_request = [](const PermissionRequest&) -> PermissionRequestResult { @@ -1789,6 +1947,7 @@ TEST_F(E2ETest, ToolCallIdIsPropagated) TEST_F(E2ETest, ResumeSessionWithPermissionCallback) { + test_info("Resume with permissions: Create session, resume with permission callback, verify works."); auto client = create_client(); client->start().get(); @@ -1831,7 +1990,7 @@ TEST_F(E2ETest, ResumeSessionWithPermissionCallback) client = create_client(); client->start().get(); - ResumeSessionConfig resume_config; + auto resume_config = default_resume_config(); resume_config.on_permission_request = [&](const PermissionRequest& request) -> PermissionRequestResult { permission_call_count++; @@ -1876,6 +2035,12 @@ TEST_F(E2ETest, ResumeSessionWithPermissionCallback) TEST_F(E2ETest, ResumeSessionWithToolsAndPermissions) { + test_info("Resume with tools+perms: Resume with both tools and permission callback, invoke tool."); + + // BYOK/OpenAI doesn't support resuming sessions with new tools + if (is_byok_active()) + GTEST_SKIP() << "Skipping: BYOK providers don't support resume_session with tools"; + auto client = create_client(); client->start().get(); @@ -1933,7 +2098,7 @@ TEST_F(E2ETest, ResumeSessionWithToolsAndPermissions) client = create_client(); client->start().get(); - ResumeSessionConfig resume_config; + auto resume_config = default_resume_config(); resume_config.tools = {resume_tool}; resume_config.on_permission_request = [&](const PermissionRequest&) -> PermissionRequestResult { @@ -1986,13 +2151,14 @@ TEST_F(E2ETest, ResumeSessionWithToolsAndPermissions) TEST_F(E2ETest, PermissionDenialWithMessage) { + test_info("Permission denial message: Deny all tools with reason, verify session handles gracefully."); auto client = create_client(); client->start().get(); std::atomic denial_triggered{false}; std::string denial_reason; - SessionConfig config; + auto config = default_session_config(); config.on_permission_request = [&](const PermissionRequest& request) -> PermissionRequestResult { PermissionRequestResult result; @@ -2062,6 +2228,7 @@ TEST_F(E2ETest, PermissionDenialWithMessage) TEST_F(E2ETest, FluentToolBuilderIntegration) { + test_info("Fluent ToolBuilder: Use ToolBuilder API for calc+echo tools, verify both work."); auto client = create_client(); client->start().get(); @@ -2113,7 +2280,7 @@ TEST_F(E2ETest, FluentToolBuilderIntegration) }); // Create session with fluent-built tools - SessionConfig config; + auto config = default_session_config(); config.tools = {calculator, echo}; config.on_permission_request = [](const PermissionRequest&) -> PermissionRequestResult { diff --git a/tests/test_tool_builder.cpp b/tests/test_tool_builder.cpp index 2940d12..b979f2d 100644 --- a/tests/test_tool_builder.cpp +++ b/tests/test_tool_builder.cpp @@ -330,3 +330,113 @@ TEST(ToolBuilderTest, BackwardCompatibility) auto result = old_tool.handler(inv); EXPECT_EQ(result.text_result_for_llm, "old style"); } + +// ============================================================================= +// make_tool Function Tests (Claude SDK compatible API) +// ============================================================================= + +TEST(MakeToolTest, SingleParam) +{ + auto tool = copilot::make_tool( + "echo", "Echo message", [](std::string msg) { return msg; }, {"message"}); + + EXPECT_EQ(tool.name, "echo"); + EXPECT_EQ(tool.description, "Echo message"); + + // Check schema + EXPECT_EQ(tool.parameters_schema["type"], "object"); + EXPECT_TRUE(tool.parameters_schema["properties"].contains("message")); + EXPECT_EQ(tool.parameters_schema["properties"]["message"]["type"], "string"); + + // Test invocation + ToolInvocation inv; + inv.arguments = json{{"message", "Hello"}}; + auto result = tool.handler(inv); + EXPECT_EQ(result.text_result_for_llm, "Hello"); +} + +TEST(MakeToolTest, MultipleParams) +{ + auto tool = copilot::make_tool( + "calc", "Calculator", + [](double a, double b) { return std::to_string(a + b); }, + {"first", "second"}); + + EXPECT_EQ(tool.name, "calc"); + + // Check schema + auto& props = tool.parameters_schema["properties"]; + EXPECT_TRUE(props.contains("first")); + EXPECT_TRUE(props.contains("second")); + EXPECT_EQ(props["first"]["type"], "number"); + EXPECT_EQ(props["second"]["type"], "number"); + + // Test invocation + ToolInvocation inv; + inv.arguments = json{{"first", 10.0}, {"second", 32.0}}; + auto result = tool.handler(inv); + EXPECT_EQ(result.text_result_for_llm, "42.000000"); +} + +TEST(MakeToolTest, AutoParamNames) +{ + // Use make_tool without param names - should get arg0, arg1, etc. + auto tool = copilot::make_tool( + "greet", "Greet", + [](std::string name, int count) { return name + ":" + std::to_string(count); }); + + auto& props = tool.parameters_schema["properties"]; + EXPECT_TRUE(props.contains("arg0")); + EXPECT_TRUE(props.contains("arg1")); + + // Test invocation + ToolInvocation inv; + inv.arguments = json{{"arg0", "test"}, {"arg1", 5}}; + auto result = tool.handler(inv); + EXPECT_EQ(result.text_result_for_llm, "test:5"); +} + +TEST(MakeToolTest, ParamCountMismatch) +{ + EXPECT_THROW( + copilot::make_tool( + "bad", "Bad", + [](std::string a, std::string b) { return a + b; }, + {"only_one"} // Mismatch: 2 params, 1 name + ), + std::invalid_argument); +} + +TEST(MakeToolTest, ErrorHandling) +{ + auto tool = copilot::make_tool( + "fail", "Always fails", + [](std::string) -> std::string { throw std::runtime_error("boom"); }, + {"input"}); + + ToolInvocation inv; + inv.arguments = json{{"input", "test"}}; + auto result = tool.handler(inv); + EXPECT_EQ(result.result_type, "error"); + EXPECT_TRUE(result.error.has_value()); + EXPECT_EQ(*result.error, "boom"); +} + +TEST(MakeToolTest, IntAndBoolParams) +{ + auto tool = copilot::make_tool( + "config", "Config tool", + [](int port, bool enabled) { + return "port=" + std::to_string(port) + ",enabled=" + (enabled ? "true" : "false"); + }, + {"port", "enabled"}); + + auto& props = tool.parameters_schema["properties"]; + EXPECT_EQ(props["port"]["type"], "integer"); + EXPECT_EQ(props["enabled"]["type"], "boolean"); + + ToolInvocation inv; + inv.arguments = json{{"port", 8080}, {"enabled", true}}; + auto result = tool.handler(inv); + EXPECT_EQ(result.text_result_for_llm, "port=8080,enabled=true"); +}