diff --git a/include/copilot/client.hpp b/include/copilot/client.hpp index a3c5674..5b1cd6a 100644 --- a/include/copilot/client.hpp +++ b/include/copilot/client.hpp @@ -143,6 +143,19 @@ class Client /// @return Future that resolves to ping response std::future ping(std::optional message = std::nullopt); + /// Get CLI status including version and protocol information + /// @return Future that resolves to status response + std::future get_status(); + + /// Get current authentication status + /// @return Future that resolves to auth status response + std::future get_auth_status(); + + /// List available models with their metadata + /// @return Future that resolves to list of model info + /// @throws Error if not authenticated + std::future> list_models(); + // ========================================================================= // Internal API (used by Session) // ========================================================================= diff --git a/include/copilot/session.hpp b/include/copilot/session.hpp index 1dfde2d..b9421c8 100644 --- a/include/copilot/session.hpp +++ b/include/copilot/session.hpp @@ -106,7 +106,8 @@ class Session : public std::enable_shared_from_this using PermissionHandler = std::function; /// Create a session (called by Client) - Session(const std::string& session_id, Client* client); + Session(const std::string& session_id, Client* client, + const std::optional& workspace_path = std::nullopt); ~Session(); @@ -124,6 +125,15 @@ class Session : public std::enable_shared_from_this return session_id_; } + /// Get the workspace path for infinite sessions. + /// + /// Contains checkpoints/, plan.md, and files/ subdirectories. + /// Returns nullopt if infinite sessions are disabled. + const std::optional& workspace_path() const + { + return workspace_path_; + } + // ========================================================================= // Messaging // ========================================================================= @@ -191,6 +201,7 @@ class Session : public std::enable_shared_from_this private: std::string session_id_; Client* client_; + std::optional workspace_path_; // Event handlers mutable std::mutex handlers_mutex_; diff --git a/include/copilot/types.hpp b/include/copilot/types.hpp index 7313a15..cec8f07 100644 --- a/include/copilot/types.hpp +++ b/include/copilot/types.hpp @@ -531,6 +531,51 @@ struct Tool ToolHandler handler; }; +// ============================================================================= +// Infinite Session Configuration +// ============================================================================= + +/// Configuration for infinite sessions with automatic context compaction. +/// +/// When enabled, sessions automatically manage context window limits through +/// background compaction and persist state to a workspace directory. +struct InfiniteSessionConfig +{ + /// Whether infinite sessions are enabled (default: true when config is provided) + std::optional enabled; + + /// Context utilization threshold (0.0-1.0) at which background compaction starts. + /// Compaction runs asynchronously, allowing the session to continue processing. + /// Default: 0.80 + std::optional background_compaction_threshold; + + /// Context utilization threshold (0.0-1.0) at which the session blocks until + /// compaction completes. This prevents context overflow when compaction hasn't + /// finished in time. Default: 0.95 + std::optional buffer_exhaustion_threshold; +}; + +inline void to_json(json& j, const InfiniteSessionConfig& c) +{ + j = json::object(); + if (c.enabled) + j["enabled"] = *c.enabled; + if (c.background_compaction_threshold) + j["backgroundCompactionThreshold"] = *c.background_compaction_threshold; + if (c.buffer_exhaustion_threshold) + j["bufferExhaustionThreshold"] = *c.buffer_exhaustion_threshold; +} + +inline void from_json(const json& j, InfiniteSessionConfig& c) +{ + if (j.contains("enabled")) + c.enabled = j.at("enabled").get(); + if (j.contains("backgroundCompactionThreshold")) + c.background_compaction_threshold = j.at("backgroundCompactionThreshold").get(); + if (j.contains("bufferExhaustionThreshold")) + c.buffer_exhaustion_threshold = j.at("bufferExhaustionThreshold").get(); +} + // ============================================================================= // Session Configuration // ============================================================================= @@ -550,6 +595,16 @@ struct SessionConfig std::optional> mcp_servers; std::optional> custom_agents; + /// Directories to load skills from. + std::optional> skill_directories; + + /// List of skill names to disable. + std::optional> disabled_skills; + + /// Infinite session configuration for persistent workspaces and automatic compaction. + /// When enabled (default), sessions automatically manage context limits and persist state. + std::optional infinite_sessions; + /// 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; @@ -565,6 +620,12 @@ struct ResumeSessionConfig std::optional> mcp_servers; std::optional> custom_agents; + /// Directories to load skills from. + std::optional> skill_directories; + + /// List of skill names to disable. + std::optional> disabled_skills; + /// 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; @@ -784,4 +845,120 @@ inline void from_json(const json& j, PingResponse& r) r.protocol_version = j.at("protocolVersion").get(); } +/// Response from status.get request +struct GetStatusResponse +{ + std::string version; + int protocol_version; +}; + +inline void from_json(const json& j, GetStatusResponse& r) +{ + j.at("version").get_to(r.version); + j.at("protocolVersion").get_to(r.protocol_version); +} + +/// Response from auth.getStatus request +struct GetAuthStatusResponse +{ + bool is_authenticated; + std::optional auth_type; + std::optional host; + std::optional login; + std::optional status_message; +}; + +inline void from_json(const json& j, GetAuthStatusResponse& r) +{ + j.at("isAuthenticated").get_to(r.is_authenticated); + if (j.contains("authType") && !j["authType"].is_null()) + r.auth_type = j["authType"].get(); + if (j.contains("host") && !j["host"].is_null()) + r.host = j["host"].get(); + if (j.contains("login") && !j["login"].is_null()) + r.login = j["login"].get(); + if (j.contains("statusMessage") && !j["statusMessage"].is_null()) + r.status_message = j["statusMessage"].get(); +} + +/// Model capabilities - what the model supports +struct ModelCapabilities +{ + struct Supports + { + bool vision = false; + }; + struct Limits + { + std::optional max_prompt_tokens; + int max_context_window_tokens = 0; + }; + Supports supports; + Limits limits; +}; + +inline void from_json(const json& j, ModelCapabilities& c) +{ + if (j.contains("supports")) + { + if (j["supports"].contains("vision")) + j["supports"]["vision"].get_to(c.supports.vision); + } + if (j.contains("limits")) + { + if (j["limits"].contains("max_prompt_tokens") && !j["limits"]["max_prompt_tokens"].is_null()) + c.limits.max_prompt_tokens = j["limits"]["max_prompt_tokens"].get(); + if (j["limits"].contains("max_context_window_tokens")) + j["limits"]["max_context_window_tokens"].get_to(c.limits.max_context_window_tokens); + } +} + +/// Model policy state +struct ModelPolicy +{ + std::string state; + std::string terms; +}; + +inline void from_json(const json& j, ModelPolicy& p) +{ + j.at("state").get_to(p.state); + if (j.contains("terms")) + j.at("terms").get_to(p.terms); +} + +/// Model billing information +struct ModelBilling +{ + double multiplier = 1.0; +}; + +inline void from_json(const json& j, ModelBilling& b) +{ + if (j.contains("multiplier")) + j.at("multiplier").get_to(b.multiplier); +} + +/// Information about an available model +struct ModelInfo +{ + std::string id; + std::string name; + ModelCapabilities capabilities; + std::optional policy; + std::optional billing; +}; + +inline void from_json(const json& j, ModelInfo& m) +{ + j.at("id").get_to(m.id); + j.at("name").get_to(m.name); + if (j.contains("capabilities")) + j.at("capabilities").get_to(m.capabilities); + if (j.contains("policy") && !j["policy"].is_null()) + m.policy = j["policy"].get(); + if (j.contains("billing") && !j["billing"].is_null()) + m.billing = j["billing"].get(); +} + } // namespace copilot diff --git a/src/client.cpp b/src/client.cpp index 0a80664..d4deb5d 100644 --- a/src/client.cpp +++ b/src/client.cpp @@ -87,6 +87,12 @@ json build_session_create_request(const SessionConfig& config) agents.push_back(agent); request["customAgents"] = agents; } + if (config.skill_directories.has_value()) + request["skillDirectories"] = *config.skill_directories; + if (config.disabled_skills.has_value()) + request["disabledSkills"] = *config.disabled_skills; + if (config.infinite_sessions.has_value()) + request["infiniteSessions"] = *config.infinite_sessions; return request; } @@ -136,6 +142,10 @@ json build_session_resume_request(const std::string& session_id, const ResumeSes agents.push_back(agent); request["customAgents"] = agents; } + if (config.skill_directories.has_value()) + request["skillDirectories"] = *config.skill_directories; + if (config.disabled_skills.has_value()) + request["disabledSkills"] = *config.disabled_skills; return request; } @@ -543,7 +553,12 @@ std::future> Client::create_session(SessionConfig confi auto response = rpc_->invoke("session.create", request).get(); std::string session_id = response["sessionId"].get(); - auto session = std::make_shared(session_id, this); + // Capture workspace path for infinite sessions + std::optional workspace_path; + if (response.contains("workspacePath") && response["workspacePath"].is_string()) + workspace_path = response["workspacePath"].get(); + + auto session = std::make_shared(session_id, this, workspace_path); // Register tools locally for handling callbacks from the server for (const auto& tool : config.tools) @@ -582,7 +597,12 @@ Client::resume_session(const std::string& session_id, ResumeSessionConfig config auto response = rpc_->invoke("session.resume", request).get(); std::string returned_session_id = response["sessionId"].get(); - auto session = std::make_shared(returned_session_id, this); + // Capture workspace_path if present (for infinite sessions) + std::optional workspace_path; + if (response.contains("workspacePath") && response["workspacePath"].is_string()) + workspace_path = response["workspacePath"].get(); + + auto session = std::make_shared(returned_session_id, this, workspace_path); // Register tools locally for handling callbacks from the server for (const auto& tool : config.tools) @@ -714,6 +734,72 @@ std::future Client::ping(std::optional message) ); } +std::future Client::get_status() +{ + return std::async( + std::launch::async, + [this]() + { + if (state_ != ConnectionState::Connected) + { + if (options_.auto_start) + start().get(); + else + throw std::runtime_error("Client not connected. Call start() first."); + } + + auto response = rpc_->invoke("status.get", json::object()).get(); + return response.get(); + } + ); +} + +std::future Client::get_auth_status() +{ + return std::async( + std::launch::async, + [this]() + { + if (state_ != ConnectionState::Connected) + { + if (options_.auto_start) + start().get(); + else + throw std::runtime_error("Client not connected. Call start() first."); + } + + auto response = rpc_->invoke("auth.getStatus", json::object()).get(); + return response.get(); + } + ); +} + +std::future> Client::list_models() +{ + return std::async( + std::launch::async, + [this]() + { + if (state_ != ConnectionState::Connected) + { + if (options_.auto_start) + start().get(); + else + throw std::runtime_error("Client not connected. Call start() first."); + } + + auto response = rpc_->invoke("models.list", json::object()).get(); + std::vector models; + if (response.contains("models") && response["models"].is_array()) + { + for (const auto& m : response["models"]) + models.push_back(m.get()); + } + return models; + } + ); +} + std::shared_ptr Client::get_session(const std::string& session_id) { std::lock_guard lock(mutex_); diff --git a/src/session.cpp b/src/session.cpp index 0d1046a..27190ab 100644 --- a/src/session.cpp +++ b/src/session.cpp @@ -11,8 +11,9 @@ namespace copilot // Constructor / Destructor // ============================================================================= -Session::Session(const std::string& session_id, Client* client) - : session_id_(session_id), client_(client) +Session::Session(const std::string& session_id, Client* client, + const std::optional& workspace_path) + : session_id_(session_id), client_(client), workspace_path_(workspace_path) { } diff --git a/tests/test_e2e.cpp b/tests/test_e2e.cpp index 4297b44..58ae0a0 100644 --- a/tests/test_e2e.cpp +++ b/tests/test_e2e.cpp @@ -2365,3 +2365,183 @@ TEST_F(E2ETest, FluentToolBuilderIntegration) session->destroy().get(); client->force_stop(); } + +// ============================================================================= +// Infinite Sessions Tests +// ============================================================================= + +TEST_F(E2ETest, InfiniteSessionConfig) +{ + test_info("Infinite session config: Create session with infinite sessions enabled, verify workspace path."); + auto client = create_client(); + client->start().get(); + + // Create session with infinite sessions enabled + auto config = default_session_config(); + config.infinite_sessions = InfiniteSessionConfig{ + .enabled = true, + .background_compaction_threshold = std::nullopt, + .buffer_exhaustion_threshold = std::nullopt + }; + + auto session = client->create_session(config).get(); + + EXPECT_NE(session, nullptr); + EXPECT_FALSE(session->session_id().empty()); + + // Check if workspace_path is provided (depends on server support) + if (session->workspace_path().has_value()) + { + std::cout << "Infinite session workspace path: " << *session->workspace_path() << "\n"; + EXPECT_FALSE(session->workspace_path()->empty()) << "Workspace path should not be empty"; + } + else + { + std::cout << "No workspace_path returned (infinite sessions may not be fully enabled on server)\n"; + } + + // Session should still work normally + std::atomic idle{false}; + std::mutex mtx; + std::condition_variable cv; + + auto sub = session->on( + [&](const SessionEvent& event) + { + if (event.type == SessionEventType::SessionIdle) + { + idle = true; + cv.notify_one(); + } + } + ); + + MessageOptions opts; + opts.prompt = "Say 'hi'."; + session->send(opts).get(); + + { + std::unique_lock lock(mtx); + cv.wait_for(lock, std::chrono::seconds(30), [&]() { return idle.load(); }); + } + + EXPECT_TRUE(idle.load()) << "Session should complete successfully"; + + session->destroy().get(); + client->force_stop(); +} + +TEST_F(E2ETest, InfiniteSessionWithCustomThresholds) +{ + test_info("Infinite session custom thresholds: Create session with custom compaction thresholds."); + auto client = create_client(); + client->start().get(); + + // Create session with custom compaction thresholds + auto config = default_session_config(); + config.infinite_sessions = InfiniteSessionConfig{ + .enabled = true, + .background_compaction_threshold = 0.7, + .buffer_exhaustion_threshold = 0.9 + }; + + auto session = client->create_session(config).get(); + + EXPECT_NE(session, nullptr); + EXPECT_FALSE(session->session_id().empty()); + + // Session should work normally + std::atomic idle{false}; + std::mutex mtx; + std::condition_variable cv; + + auto sub = session->on( + [&](const SessionEvent& event) + { + if (event.type == SessionEventType::SessionIdle) + { + idle = true; + cv.notify_one(); + } + } + ); + + MessageOptions opts; + opts.prompt = "What is 2+2?"; + session->send(opts).get(); + + { + std::unique_lock lock(mtx); + cv.wait_for(lock, std::chrono::seconds(30), [&]() { return idle.load(); }); + } + + EXPECT_TRUE(idle.load()) << "Session should complete successfully"; + + session->destroy().get(); + client->force_stop(); +} + +// ============================================================================= +// Client Status Methods Tests +// ============================================================================= + +TEST_F(E2ETest, GetStatus) +{ + test_info("GetStatus: Get CLI version and protocol information."); + auto client = create_client(); + client->start().get(); + + auto status = client->get_status().get(); + + EXPECT_FALSE(status.version.empty()) << "Version should not be empty"; + EXPECT_GE(status.protocol_version, 1) << "Protocol version should be >= 1"; + + std::cout << "CLI version: " << status.version + << ", protocol: " << status.protocol_version << "\n"; + + client->force_stop(); +} + +TEST_F(E2ETest, GetAuthStatus) +{ + test_info("GetAuthStatus: Get current authentication status."); + auto client = create_client(); + client->start().get(); + + auto auth_status = client->get_auth_status().get(); + + // Auth status should at least have is_authenticated field + std::cout << "Auth status: is_authenticated=" << auth_status.is_authenticated; + if (auth_status.auth_type.has_value()) + std::cout << ", auth_type=" << *auth_status.auth_type; + std::cout << "\n"; + + client->force_stop(); +} + +TEST_F(E2ETest, ListModels) +{ + test_info("ListModels: List available models (requires authentication)."); + auto client = create_client(); + client->start().get(); + + // Check if authenticated first + auto auth_status = client->get_auth_status().get(); + + if (!auth_status.is_authenticated) + { + std::cout << "Skipping ListModels test - not authenticated\n"; + client->force_stop(); + return; + } + + auto models = client->list_models().get(); + + std::cout << "Found " << models.size() << " models:\n"; + for (const auto& model : models) + { + std::cout << " - " << model.name << " (" << model.id << ")\n"; + } + + client->force_stop(); +}