From 63440693e9429133c1d6fed424b926698e1f1174 Mon Sep 17 00:00:00 2001 From: Elias Bachaalany Date: Sat, 16 May 2026 08:52:12 -0700 Subject: [PATCH 01/15] feat(protocol): bump SDK_PROTOCOL_VERSION 2 -> 3, add range check Aligns with upstream nodejs SDK (reference/copilot-sdk d0eb531e) which moved SDK_PROTOCOL_VERSION to 3. Introduces: - kMinProtocolVersion = 2 (mirror of nodejs/Rust port's range support) - verify_protocol_version() accepts servers in [kMinProtocolVersion .. kSdkProtocolVersion] - new Client::negotiated_protocol_version() accessor (std::optional) populated after successful start() Test updates: - TypesTest.PingResponse uses protocolVersion 3 - TypesTest.ProtocolVersion asserts kSdkProtocolVersion == 3 and the min<=max invariant All 297 non-E2E ctest cases pass. Part of sync/upstream-1.0.49-1 (Phase 2 bucket 1 of full-parity sync). --- include/copilot/client.hpp | 8 ++++++++ include/copilot/types.hpp | 9 +++++++-- src/client.cpp | 19 +++++++++++++++---- tests/test_types.cpp | 8 +++++--- 4 files changed, 35 insertions(+), 9 deletions(-) diff --git a/include/copilot/client.hpp b/include/copilot/client.hpp index ec893b4..9e1c8eb 100644 --- a/include/copilot/client.hpp +++ b/include/copilot/client.hpp @@ -157,6 +157,10 @@ class Client /// @throws Error if not authenticated std::future> list_models(); + /// Get the negotiated protocol version (set after successful start()). + /// Returns std::nullopt before connection is established. + std::optional negotiated_protocol_version() const; + // ========================================================================= // Lifecycle Events // ========================================================================= @@ -244,6 +248,10 @@ class Client // Lifecycle handlers mutable std::mutex lifecycle_mutex_; std::vector lifecycle_handlers_; + + // Protocol version negotiation result (set after verify_protocol_version()). + mutable std::mutex protocol_version_mutex_; + std::optional negotiated_protocol_version_; }; } // namespace copilot diff --git a/include/copilot/types.hpp b/include/copilot/types.hpp index 2bac8e3..28fee73 100644 --- a/include/copilot/types.hpp +++ b/include/copilot/types.hpp @@ -32,8 +32,13 @@ struct SessionEvent; // Protocol Version // ============================================================================= -/// SDK protocol version - must match copilot-agent-runtime server -inline constexpr int kSdkProtocolVersion = 2; +/// Maximum SDK protocol version supported (matches copilot-agent-runtime server). +/// Upstream nodejs SDK_PROTOCOL_VERSION = 3 since v0.1.24-series. +inline constexpr int kSdkProtocolVersion = 3; + +/// Minimum SDK protocol version this SDK can communicate with. +/// Older servers (reporting < kMinProtocolVersion) are rejected. +inline constexpr int kMinProtocolVersion = 2; // ============================================================================= // Enums diff --git a/src/client.cpp b/src/client.cpp index 166cc08..b1b29eb 100644 --- a/src/client.cpp +++ b/src/client.cpp @@ -619,14 +619,25 @@ void Client::verify_protocol_version() } int server_version = response["protocolVersion"].get(); - if (server_version != kSdkProtocolVersion) + if (server_version < kMinProtocolVersion || server_version > kSdkProtocolVersion) { throw std::runtime_error( - "SDK protocol version mismatch: SDK expects version " + - std::to_string(kSdkProtocolVersion) + ", but server reports version " + - std::to_string(server_version) + "SDK protocol version mismatch: SDK supports versions [" + + std::to_string(kMinProtocolVersion) + ".." + std::to_string(kSdkProtocolVersion) + + "], but server reports version " + std::to_string(server_version) ); } + + { + std::lock_guard lock(protocol_version_mutex_); + negotiated_protocol_version_ = server_version; + } +} + +std::optional Client::negotiated_protocol_version() const +{ + std::lock_guard lock(protocol_version_mutex_); + return negotiated_protocol_version_; } // ============================================================================= diff --git a/tests/test_types.cpp b/tests/test_types.cpp index c1ae278..f601d28 100644 --- a/tests/test_types.cpp +++ b/tests/test_types.cpp @@ -153,17 +153,19 @@ TEST(TypesTest, MessageOptions) TEST(TypesTest, PingResponse) { - json input = {{"message", "pong"}, {"timestamp", 1234567890}, {"protocolVersion", 2}}; + json input = {{"message", "pong"}, {"timestamp", 1234567890}, {"protocolVersion", 3}}; auto resp = input.get(); EXPECT_EQ(resp.message, "pong"); EXPECT_EQ(resp.timestamp, 1234567890); - EXPECT_EQ(resp.protocol_version, 2); + EXPECT_EQ(resp.protocol_version, 3); } TEST(TypesTest, ProtocolVersion) { - EXPECT_EQ(kSdkProtocolVersion, 2); + EXPECT_EQ(kSdkProtocolVersion, 3); + EXPECT_EQ(kMinProtocolVersion, 2); + EXPECT_LE(kMinProtocolVersion, kSdkProtocolVersion); } TEST(TypesTest, SessionMetadataParsesIso8601Timestamps) From e823266392e10391a7f479f5bfcd58f7e136ffb8 Mon Sep 17 00:00:00 2001 From: Elias Bachaalany Date: Sat, 16 May 2026 08:59:58 -0700 Subject: [PATCH 02/15] feat(client): add ClientOptions parity for upstream v0.1.49 Adds the ClientOptions fields introduced upstream (nodejs SDK at reference/copilot-sdk d0eb531e) and wires them through to the spawned CLI server: - tcp_connection_token (std::optional) -> COPILOT_CONNECTION_TOKEN env var. Validated to be non-empty and rejected with use_stdio=true. Auto-generated as a UUID v4 when SDK spawns its own CLI in TCP mode and the caller did not set one (matches nodejs effectiveConnectionToken semantics so loopback listeners are safe by default). - copilot_home (std::optional) -> COPILOT_HOME env var (configurable data directory). - session_idle_timeout_seconds (std::optional) -> --session-idle-timeout CLI flag (only emitted when value > 0). - remote (bool) -> --remote CLI flag (Mission Control integration). Deprecates auto_restart: - Marked [[deprecated]] with no-op semantics. Default flipped from true to false (the field was never actually consulted by the C++ port, and upstream nodejs SDK marked it deprecated/no-op as well). - Tests opt-in to the deprecation warning suppression to keep building clean. ClientOptionsTest.DefaultValues extended with the new field defaults. All 297 non-E2E ctest cases continue to pass. Part of sync/upstream-1.0.49-1 (Phase 2 bucket 3 of full-parity sync). --- include/copilot/types.hpp | 35 +++++++++++++++++++- src/client.cpp | 62 +++++++++++++++++++++++++++++++++++ tests/test_client_session.cpp | 22 ++++++++++++- 3 files changed, 117 insertions(+), 2 deletions(-) diff --git a/include/copilot/types.hpp b/include/copilot/types.hpp index 28fee73..aa94505 100644 --- a/include/copilot/types.hpp +++ b/include/copilot/types.hpp @@ -1090,15 +1090,48 @@ struct ClientOptions std::optional cli_url; LogLevel log_level = LogLevel::Info; bool auto_start = true; - bool auto_restart = true; + + /// @deprecated This option has no effect and will be removed in a future release. + /// Retained for source compatibility with v0.1.23 callers; the SDK no longer + /// auto-restarts the CLI on exit (matches upstream nodejs SDK semantics). + [[deprecated("auto_restart has no effect; will be removed in a future release")]] + bool auto_restart = false; + std::optional> environment; /// GitHub token for authentication. Cannot be used with cli_url. + /// On the wire to the CLI, this is forwarded via the COPILOT_SDK_AUTH_TOKEN + /// environment variable plus the --auth-token-env CLI flag. std::optional github_token; /// Whether to use logged-in user for auth. Defaults to true when github_token is empty. /// Cannot be used with cli_url. std::optional use_logged_in_user; + + /// Connection token for the headless CLI server (TCP only). When the SDK + /// spawns its own CLI in TCP mode and this is omitted, a UUID is generated + /// automatically so the loopback listener is safe by default. Rejected with + /// `use_stdio = true` (stdio is pre-authenticated by transport). + /// Forwarded to the CLI via the COPILOT_CONNECTION_TOKEN environment variable. + std::optional tcp_connection_token; + + /// Custom data directory for the Copilot CLI ($COPILOT_HOME). When omitted, + /// the CLI uses its default location (typically ~/.copilot). + std::optional copilot_home; + + /// Server-wide idle timeout for sessions in seconds. + /// Sessions without activity for this duration are automatically cleaned up. + /// Set to 0 or omit to disable (sessions live indefinitely). + /// Only used when the SDK spawns the CLI process; ignored when connecting to + /// an external server via {@link cli_url}. + std::optional session_idle_timeout_seconds; + + /// Enable remote session support (Mission Control integration). + /// When true, sessions in a GitHub repository working directory are accessible + /// from GitHub web and mobile. + /// Only used when the SDK spawns the CLI process; ignored when connecting to + /// an external server via {@link cli_url}. + bool remote = false; }; // ============================================================================= diff --git a/src/client.cpp b/src/client.cpp index b1b29eb..d965199 100644 --- a/src/client.cpp +++ b/src/client.cpp @@ -4,6 +4,8 @@ #include #include #include +#include +#include #include #include @@ -217,10 +219,48 @@ Client::Client(ClientOptions options) : options_(std::move(options)) "(external server manages its own auth)"); } + // Validate tcp_connection_token usage (matches upstream nodejs SDK v0.1.49): + // token requires TCP transport (stdio is pre-authenticated by pipes). + if (options_.tcp_connection_token.has_value()) + { + if (options_.tcp_connection_token->empty()) + throw std::invalid_argument("tcp_connection_token must be a non-empty string"); + if (options_.use_stdio) + throw std::invalid_argument( + "tcp_connection_token cannot be used with use_stdio = true"); + } + // Smart default for use_logged_in_user (only when managing our own server) if (!options_.cli_url.has_value() && !options_.use_logged_in_user.has_value()) options_.use_logged_in_user = !options_.github_token.has_value(); + // Auto-generate a UUID for the TCP connection token when the SDK spawns its + // own CLI in TCP mode and no token was provided. Mirrors nodejs effective- + // ConnectionToken logic (so loopback listeners are safe by default). + if (!options_.cli_url.has_value() && !options_.use_stdio && + !options_.tcp_connection_token.has_value()) + { + // Simple UUID v4 generator (RFC 4122, 122 random bits). + std::random_device rd; + std::mt19937_64 gen(rd()); + std::uniform_int_distribution dist; + uint64_t lo = dist(gen); + uint64_t hi = dist(gen); + // Set version (4) and variant (10xx) bits per RFC 4122. + hi = (hi & 0xFFFFFFFFFFFF0FFFULL) | 0x0000000000004000ULL; + lo = (lo & 0x3FFFFFFFFFFFFFFFULL) | 0x8000000000000000ULL; + char buf[37]; + std::snprintf( + buf, sizeof(buf), "%08x-%04x-%04x-%04x-%012llx", + static_cast((hi >> 32) & 0xFFFFFFFFULL), + static_cast((hi >> 16) & 0xFFFFULL), + static_cast(hi & 0xFFFFULL), + static_cast((lo >> 48) & 0xFFFFULL), + static_cast(lo & 0xFFFFFFFFFFFFULL) + ); + options_.tcp_connection_token = std::string(buf); + } + // Parse CLI URL if provided if (options_.cli_url.has_value()) parse_cli_url(*options_.cli_url); @@ -481,6 +521,20 @@ void Client::start_cli_server() args.push_back(std::to_string(options_.port)); } + // Session idle timeout (forwarded as CLI flag; ignored by server when 0/absent). + if (options_.session_idle_timeout_seconds.has_value() && + *options_.session_idle_timeout_seconds > 0) + { + args.push_back("--session-idle-timeout"); + args.push_back(std::to_string(*options_.session_idle_timeout_seconds)); + } + + // Remote session support (Mission Control integration). + if (options_.remote) + { + args.push_back("--remote"); + } + // Resolve command auto [executable, full_args] = resolve_cli_command(cli_path, args); @@ -507,6 +561,14 @@ void Client::start_cli_server() if (options_.github_token.has_value()) proc_opts.environment["COPILOT_SDK_AUTH_TOKEN"] = *options_.github_token; + // Forward TCP connection token (auto-generated UUID in TCP+spawn mode if caller did not set one). + if (options_.tcp_connection_token.has_value()) + proc_opts.environment["COPILOT_CONNECTION_TOKEN"] = *options_.tcp_connection_token; + + // Configurable Copilot data directory. + if (options_.copilot_home.has_value()) + proc_opts.environment["COPILOT_HOME"] = *options_.copilot_home; + // Spawn process process_ = std::make_unique(); process_->spawn(executable, full_args, proc_opts); diff --git a/tests/test_client_session.cpp b/tests/test_client_session.cpp index b931f04..2a80c82 100644 --- a/tests/test_client_session.cpp +++ b/tests/test_client_session.cpp @@ -216,8 +216,28 @@ TEST(ClientOptionsTest, DefaultValues) EXPECT_FALSE(opts.cli_url.has_value()); EXPECT_EQ(opts.log_level, LogLevel::Info); EXPECT_TRUE(opts.auto_start); - EXPECT_TRUE(opts.auto_restart); + // auto_restart is deprecated and now defaults to false (was true; never actually + // used in the C++ port and upstream nodejs SDK marks it deprecated/no-op). +#if defined(__GNUC__) || defined(__clang__) +# pragma GCC diagnostic push +# pragma GCC diagnostic ignored "-Wdeprecated-declarations" +#elif defined(_MSC_VER) +# pragma warning(push) +# pragma warning(disable : 4996) +#endif + EXPECT_FALSE(opts.auto_restart); +#if defined(__GNUC__) || defined(__clang__) +# pragma GCC diagnostic pop +#elif defined(_MSC_VER) +# pragma warning(pop) +#endif EXPECT_FALSE(opts.environment.has_value()); + + // New v0.1.49 fields - defaults + EXPECT_FALSE(opts.tcp_connection_token.has_value()); + EXPECT_FALSE(opts.copilot_home.has_value()); + EXPECT_FALSE(opts.session_idle_timeout_seconds.has_value()); + EXPECT_FALSE(opts.remote); } // ============================================================================= From dc9fafe137beb5cc9dbe8d838da3fe66b542ed2e Mon Sep 17 00:00:00 2001 From: Elias Bachaalany Date: Sat, 16 May 2026 09:05:10 -0700 Subject: [PATCH 03/15] feat(lifecycle): SessionListFilter, SessionContext, getSessionMetadata Adds the session lifecycle API additions introduced upstream (nodejs SDK at reference/copilot-sdk d0eb531e): Types - SessionContext: cwd / git_root / repository / branch (working directory context from session creation). - SessionListFilter: optional filter (cwd / git_root / repository / branch) for narrowing list_sessions results. - SessionMetadata gains an optional 'context' field (parsed from the 'context' JSON object when present). Client API - Client::list_sessions(SessionListFilter) overload mirrors upstream CopilotClient.listSessions(filter). The no-arg list_sessions() now delegates to the filtered overload with an empty filter, and both use the SessionMetadata from_json (so startTime, modifiedTime, isRemote, summary, and context are all populated -- previously only sessionId and summary were read). - Client::get_session_metadata(session_id) calls 'session.getMetadata' and returns std::optional (nullopt when the server returns no session). Tests (test_types.cpp, +5 cases, total 302/302 ctest pass) - SessionListFilterTest.EmptyFilterSerializesToEmptyObject - SessionListFilterTest.AllFieldsSerialize - SessionContextTest.RoundTrip - SessionMetadataTest.ParsesContextField - SessionMetadataTest.ContextAbsentIsNullopt Part of sync/upstream-1.0.49-1 (Phase 2 bucket 4 of full-parity sync). --- include/copilot/client.hpp | 11 +++++ include/copilot/types.hpp | 58 ++++++++++++++++++++++++ src/client.cpp | 52 +++++++++++++++++---- tests/test_types.cpp | 93 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 206 insertions(+), 8 deletions(-) diff --git a/include/copilot/client.hpp b/include/copilot/client.hpp index 9e1c8eb..d522cd6 100644 --- a/include/copilot/client.hpp +++ b/include/copilot/client.hpp @@ -126,6 +126,17 @@ class Client /// @return Future that resolves to list of session metadata std::future> list_sessions(); + /// List sessions matching a filter (matches upstream nodejs SDK). + /// @param filter Filter criteria (cwd / git_root / repository / branch) + /// @return Future that resolves to list of matching session metadata + std::future> list_sessions(SessionListFilter filter); + + /// Get metadata for a specific session by ID (O(1) lookup). + /// @param session_id ID of the session + /// @return Future that resolves to metadata, or nullopt if not found + std::future> + get_session_metadata(const std::string& session_id); + /// Delete a session /// @param session_id ID of the session to delete /// @return Future that completes when deleted diff --git a/include/copilot/types.hpp b/include/copilot/types.hpp index aa94505..a71be79 100644 --- a/include/copilot/types.hpp +++ b/include/copilot/types.hpp @@ -1138,6 +1138,61 @@ struct ClientOptions // Response Types // ============================================================================= +/// Working directory context (cwd, git info) from session creation +struct SessionContext +{ + std::string cwd; + std::optional git_root; + std::optional repository; ///< owner/repo + std::optional branch; +}; + +inline void from_json(const json& j, SessionContext& c) +{ + if (j.contains("cwd") && !j.at("cwd").is_null()) + j.at("cwd").get_to(c.cwd); + if (j.contains("gitRoot") && !j.at("gitRoot").is_null()) + c.git_root = j.at("gitRoot").get(); + if (j.contains("repository") && !j.at("repository").is_null()) + c.repository = j.at("repository").get(); + if (j.contains("branch") && !j.at("branch").is_null()) + c.branch = j.at("branch").get(); +} + +inline void to_json(json& j, const SessionContext& c) +{ + j = json{{"cwd", c.cwd}}; + if (c.git_root) + j["gitRoot"] = *c.git_root; + if (c.repository) + j["repository"] = *c.repository; + if (c.branch) + j["branch"] = *c.branch; +} + +/// Filter for Client::list_sessions(). All fields are optional; only matching +/// sessions are returned. Matches upstream nodejs SessionListFilter. +struct SessionListFilter +{ + std::optional cwd; ///< exact cwd match + std::optional git_root; ///< exact git root match + std::optional repository; ///< owner/repo + std::optional branch; +}; + +inline void to_json(json& j, const SessionListFilter& f) +{ + j = json::object(); + if (f.cwd) + j["cwd"] = *f.cwd; + if (f.git_root) + j["gitRoot"] = *f.git_root; + if (f.repository) + j["repository"] = *f.repository; + if (f.branch) + j["branch"] = *f.branch; +} + /// Metadata about a session struct SessionMetadata { @@ -1146,6 +1201,7 @@ struct SessionMetadata std::chrono::system_clock::time_point modified_time; std::optional summary; bool is_remote = false; + std::optional context; }; namespace detail @@ -1294,6 +1350,8 @@ inline void from_json(const json& j, SessionMetadata& m) m.summary = j.at("summary").get(); if (j.contains("isRemote")) j.at("isRemote").get_to(m.is_remote); + if (j.contains("context") && !j.at("context").is_null()) + m.context = j.at("context").get(); } /// Error reported during client stop/cleanup diff --git a/src/client.cpp b/src/client.cpp index d965199..bb01a7c 100644 --- a/src/client.cpp +++ b/src/client.cpp @@ -810,10 +810,15 @@ Client::resume_session(const std::string& session_id, ResumeSessionConfig config } std::future> Client::list_sessions() +{ + return list_sessions(SessionListFilter{}); +} + +std::future> Client::list_sessions(SessionListFilter filter) { return std::async( std::launch::async, - [this]() + [this, filter = std::move(filter)]() { if (state_ != ConnectionState::Connected) { @@ -823,16 +828,21 @@ std::future> Client::list_sessions() throw std::runtime_error("Client not connected. Call start() first."); } - auto response = rpc_->invoke("session.list", json::object()).get(); + // session.list takes an optional 'filter' param (matches nodejs SDK shape). + json params = json::object(); + json filter_json = filter; // uses to_json(SessionListFilter) + if (!filter_json.empty()) + params["filter"] = std::move(filter_json); + + auto response = rpc_->invoke("session.list", params).get(); std::vector sessions; - for (const auto& item : response["sessions"]) + if (response.contains("sessions") && response["sessions"].is_array()) { - SessionMetadata meta; - meta.session_id = item["sessionId"].get(); - if (item.contains("summary") && !item["summary"].is_null()) - meta.summary = item["summary"].get(); - sessions.push_back(std::move(meta)); + for (const auto& item : response["sessions"]) + { + sessions.push_back(item.get()); + } } return sessions; @@ -840,6 +850,32 @@ std::future> Client::list_sessions() ); } +std::future> +Client::get_session_metadata(const std::string& session_id) +{ + return std::async( + std::launch::async, + [this, session_id]() -> std::optional + { + 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("session.getMetadata", json{{"sessionId", session_id}}).get(); + + if (!response.contains("session") || response["session"].is_null()) + return std::nullopt; + + return response["session"].get(); + } + ); +} + std::future Client::delete_session(const std::string& session_id) { return std::async( diff --git a/tests/test_types.cpp b/tests/test_types.cpp index f601d28..22e2044 100644 --- a/tests/test_types.cpp +++ b/tests/test_types.cpp @@ -1791,3 +1791,96 @@ TEST(Events, SessionUsageInfoRecognized) EXPECT_EQ(data.token_limit, 128000); EXPECT_EQ(data.current_tokens, 5000); } + + +// ============================================================================= +// SessionListFilter / SessionContext / SessionMetadata.context Tests (v0.1.49) +// ============================================================================= + +TEST(SessionListFilterTest, EmptyFilterSerializesToEmptyObject) +{ + SessionListFilter f; + json j = f; + EXPECT_TRUE(j.is_object()); + EXPECT_EQ(j.size(), 0u); +} + +TEST(SessionListFilterTest, AllFieldsSerialize) +{ + SessionListFilter f; + f.cwd = "/work/repo"; + f.git_root = "/work/repo"; + f.repository = "owner/repo"; + f.branch = "main"; + + json j = f; + EXPECT_EQ(j["cwd"], "/work/repo"); + EXPECT_EQ(j["gitRoot"], "/work/repo"); + EXPECT_EQ(j["repository"], "owner/repo"); + EXPECT_EQ(j["branch"], "main"); +} + +TEST(SessionContextTest, RoundTrip) +{ + json input = { + {"cwd", "/home/user/proj"}, + {"gitRoot", "/home/user/proj"}, + {"repository", "octo/proj"}, + {"branch", "feature/x"} + }; + + auto ctx = input.get(); + EXPECT_EQ(ctx.cwd, "/home/user/proj"); + ASSERT_TRUE(ctx.git_root.has_value()); + EXPECT_EQ(*ctx.git_root, "/home/user/proj"); + ASSERT_TRUE(ctx.repository.has_value()); + EXPECT_EQ(*ctx.repository, "octo/proj"); + ASSERT_TRUE(ctx.branch.has_value()); + EXPECT_EQ(*ctx.branch, "feature/x"); + + json out = ctx; + EXPECT_EQ(out["cwd"], "/home/user/proj"); + EXPECT_EQ(out["gitRoot"], "/home/user/proj"); + EXPECT_EQ(out["repository"], "octo/proj"); + EXPECT_EQ(out["branch"], "feature/x"); +} + +TEST(SessionMetadataTest, ParsesContextField) +{ + json input = { + {"sessionId", "sess-1"}, + {"startTime", "2025-01-15T10:30:00.000Z"}, + {"modifiedTime", "2025-01-15T10:35:00.000Z"}, + {"summary", "hi"}, + {"isRemote", true}, + {"context", { + {"cwd", "/w"}, + {"gitRoot", "/w"}, + {"repository", "o/r"}, + {"branch", "main"} + }} + }; + + auto m = input.get(); + EXPECT_EQ(m.session_id, "sess-1"); + ASSERT_TRUE(m.summary.has_value()); + EXPECT_EQ(*m.summary, "hi"); + EXPECT_TRUE(m.is_remote); + ASSERT_TRUE(m.context.has_value()); + EXPECT_EQ(m.context->cwd, "/w"); + ASSERT_TRUE(m.context->repository.has_value()); + EXPECT_EQ(*m.context->repository, "o/r"); +} + +TEST(SessionMetadataTest, ContextAbsentIsNullopt) +{ + json input = { + {"sessionId", "sess-2"}, + {"startTime", "2025-01-15T10:30:00.000Z"}, + {"modifiedTime", "2025-01-15T10:35:00.000Z"}, + {"isRemote", false} + }; + + auto m = input.get(); + EXPECT_FALSE(m.context.has_value()); +} From af070f66e5118c5245f9016b58230425c7b3afbe Mon Sep 17 00:00:00 2001 From: Elias Bachaalany Date: Sat, 16 May 2026 09:08:46 -0700 Subject: [PATCH 04/15] feat(tools): add ToolResultType::Timeout + Tool.skip_permission/overrides_built_in_tool Adds the tool-handling additions from upstream nodejs SDK at d0eb531e: - ToolResultType::Timeout enum value (serializes as "timeout"). - Tool::overrides_built_in_tool bool default false; serialized as overridesBuiltInTool only when true. - Tool::skip_permission bool default false; serialized as skipPermission only when true. Both new flags wired into build_session_create_request and build_session_resume_request alongside existing name/description/parameters. Tests added: - ToolResultTypeTest.TimeoutEnumRoundTrip - ToolTest.DefaultFlagsFalse 304/304 ctest pass (E2E skipped). Part of sync/upstream-1.0.49-1 Phase 2 bucket 6 partial: tools/permissions core flags. Commands, elicitations, userInput callbacks remain TODO. --- include/copilot/types.hpp | 13 ++++++++++++- src/client.cpp | 8 ++++++++ tests/test_types.cpp | 19 +++++++++++++++++++ 3 files changed, 39 insertions(+), 1 deletion(-) diff --git a/include/copilot/types.hpp b/include/copilot/types.hpp index a71be79..9fff433 100644 --- a/include/copilot/types.hpp +++ b/include/copilot/types.hpp @@ -108,7 +108,8 @@ enum class ToolResultType Success, Failure, Rejected, - Denied + Denied, + Timeout, ///< Added upstream in v0.1.49 series. }; NLOHMANN_JSON_SERIALIZE_ENUM( @@ -118,6 +119,7 @@ NLOHMANN_JSON_SERIALIZE_ENUM( {ToolResultType::Failure, "failure"}, {ToolResultType::Rejected, "rejected"}, {ToolResultType::Denied, "denied"}, + {ToolResultType::Timeout, "timeout"}, } ) @@ -906,6 +908,15 @@ struct Tool std::string description; json parameters_schema; ToolHandler handler; + + /// When true, explicitly indicates this tool is intended to override a + /// built-in tool of the same name. If not set and the name clashes with + /// a built-in tool, the runtime returns an error. (Upstream v0.1.49+) + bool overrides_built_in_tool = false; + + /// When true, the tool can execute without a permission prompt. + /// (Upstream v0.1.49+) + bool skip_permission = false; }; // ============================================================================= diff --git a/src/client.cpp b/src/client.cpp index bb01a7c..10ab037 100644 --- a/src/client.cpp +++ b/src/client.cpp @@ -58,6 +58,10 @@ json build_session_create_request(const SessionConfig& config) def["description"] = tool.description; if (!tool.parameters_schema.is_null()) def["parameters"] = tool.parameters_schema; + if (tool.overrides_built_in_tool) + def["overridesBuiltInTool"] = true; + if (tool.skip_permission) + def["skipPermission"] = true; tool_defs.push_back(def); } request["tools"] = tool_defs; @@ -127,6 +131,10 @@ json build_session_resume_request(const std::string& session_id, const ResumeSes def["description"] = tool.description; if (!tool.parameters_schema.is_null()) def["parameters"] = tool.parameters_schema; + if (tool.overrides_built_in_tool) + def["overridesBuiltInTool"] = true; + if (tool.skip_permission) + def["skipPermission"] = true; tool_defs.push_back(def); } request["tools"] = tool_defs; diff --git a/tests/test_types.cpp b/tests/test_types.cpp index 22e2044..0be1f07 100644 --- a/tests/test_types.cpp +++ b/tests/test_types.cpp @@ -1884,3 +1884,22 @@ TEST(SessionMetadataTest, ContextAbsentIsNullopt) auto m = input.get(); EXPECT_FALSE(m.context.has_value()); } + + +// ============================================================================= +// ToolResultType / Tool flags (v0.1.49) +// ============================================================================= + +TEST(ToolResultTypeTest, TimeoutEnumRoundTrip) +{ + json j = ToolResultType::Timeout; + EXPECT_EQ(j, "timeout"); + EXPECT_EQ(j.get(), ToolResultType::Timeout); +} + +TEST(ToolTest, DefaultFlagsFalse) +{ + Tool t; + EXPECT_FALSE(t.overrides_built_in_tool); + EXPECT_FALSE(t.skip_permission); +} From 82dd2e825208e0ba9c72fff2dd8905c5fdf5b8a0 Mon Sep 17 00:00:00 2001 From: Elias Bachaalany Date: Sat, 16 May 2026 09:12:18 -0700 Subject: [PATCH 05/15] feat(byok): Client::set_on_list_models for custom model providers Adds the BYOK list_models handler from upstream nodejs SDK CopilotClientOptions.onListModels (PR #730, present at d0eb531e). Design note: in C++ this is exposed as a setter on Client rather than a field on ClientOptions, because std::function()> requires ModelInfo to be complete and ClientOptions is declared earlier in types.hpp than ModelInfo. - Client::set_on_list_models(handler) registers a custom callback used in place of the CLI models.list RPC. Passing nullptr reverts to RPC-based behavior. - Client::list_models() now: 1) Returns the cached models if present (both BYOK and RPC paths share the same cache). 2) If a BYOK handler is registered, calls it (does NOT require an active CLI connection), caches the result, returns it. 3) Otherwise falls through to the existing models.list RPC path. - set_on_list_models() invalidates the cache so the next list_models() observes the new source. 304/304 ctest pass (E2E skipped). Existing tests cover the RPC path. Part of sync/upstream-1.0.49-1 Phase 2 bucket 8 BYOK/models. --- include/copilot/client.hpp | 12 ++++++++++++ src/client.cpp | 38 +++++++++++++++++++++++++++++++------- 2 files changed, 43 insertions(+), 7 deletions(-) diff --git a/include/copilot/client.hpp b/include/copilot/client.hpp index d522cd6..b2b66f9 100644 --- a/include/copilot/client.hpp +++ b/include/copilot/client.hpp @@ -168,6 +168,14 @@ class Client /// @throws Error if not authenticated std::future> list_models(); + /// Provide a custom handler for listing available models (BYOK mode). + /// When set, Client::list_models() calls this handler instead of querying + /// the CLI server. Results are still cached after the first successful call; + /// pass nullptr to revert to default RPC-based behavior. Matches upstream + /// nodejs CopilotClientOptions.onListModels. + using ListModelsHandler = std::function()>; + void set_on_list_models(ListModelsHandler handler); + /// Get the negotiated protocol version (set after successful start()). /// Returns std::nullopt before connection is established. std::optional negotiated_protocol_version() const; @@ -256,6 +264,10 @@ class Client mutable std::mutex models_cache_mutex_; std::optional> models_cache_; + // BYOK: optional custom models handler (when set, takes precedence over RPC). + mutable std::mutex on_list_models_mutex_; + ListModelsHandler on_list_models_; + // Lifecycle handlers mutable std::mutex lifecycle_mutex_; std::vector lifecycle_handlers_; diff --git a/src/client.cpp b/src/client.cpp index 10ab037..4eef755 100644 --- a/src/client.cpp +++ b/src/client.cpp @@ -1013,6 +1013,28 @@ std::future> Client::list_models() std::launch::async, [this]() { + // Check cache first (applies to both BYOK and RPC paths). + { + std::lock_guard lock(models_cache_mutex_); + if (models_cache_.has_value()) + return std::vector(*models_cache_); + } + + // BYOK: if a custom handler is registered, use it instead of the CLI RPC. + ListModelsHandler handler_copy; + { + std::lock_guard lock(on_list_models_mutex_); + handler_copy = on_list_models_; + } + if (handler_copy) + { + auto models = handler_copy(); + std::lock_guard lock(models_cache_mutex_); + models_cache_ = models; + return models; + } + + // Default path: query the CLI server (requires a live connection). if (state_ != ConnectionState::Connected) { if (options_.auto_start) @@ -1021,13 +1043,6 @@ std::future> Client::list_models() throw std::runtime_error("Client not connected. Call start() first."); } - // Check cache - { - std::lock_guard lock(models_cache_mutex_); - if (models_cache_.has_value()) - return std::vector(*models_cache_); - } - auto response = rpc_->invoke("models.list", json::object()).get(); auto models_response = response.get(); @@ -1042,6 +1057,15 @@ std::future> Client::list_models() ); } +void Client::set_on_list_models(ListModelsHandler handler) +{ + std::lock_guard lock(on_list_models_mutex_); + on_list_models_ = std::move(handler); + // Invalidate the cache so the next list_models() call observes the new source. + std::lock_guard cache_lock(models_cache_mutex_); + models_cache_.reset(); +} + std::shared_ptr Client::get_session(const std::string& session_id) { std::lock_guard lock(mutex_); From 8735dc87745ca78b6acc6305aae5a2255417a46f Mon Sep 17 00:00:00 2001 From: Elias Bachaalany Date: Sat, 16 May 2026 09:16:02 -0700 Subject: [PATCH 06/15] feat(session): SessionConfig+ResumeSessionConfig v0.1.49 additive fields Adds the additive SessionConfig / ResumeSessionConfig fields landed upstream (nodejs SDK at reference/copilot-sdk d0eb531e): - client_name (#510, optional string) - enable_session_telemetry (#1224, optional bool) - include_sub_agent_streaming_events (#1108, optional bool) - enable_config_discovery (#1044, optional bool) - instruction_directories (#1190, optional vector) - remote_session (#1295, optional RemoteSessionMode) Types - New RemoteSessionMode enum class (Off / Export / On) with NLOHMANN_JSON_SERIALIZE_ENUM mapping (off/export/on) matching the upstream nodejs type union. Serialization (no behavior change when omitted) - build_session_create_request: emits clientName / enableSessionTelemetry / includeSubAgentStreamingEvents / enableConfigDiscovery / instructionDirectories / remoteSession only when the corresponding optional is set. - build_session_resume_request: same set of fields mirrored. Tests (test_types.cpp, +4 cases, total 308/308 ctest pass) - RemoteSessionModeTest.RoundTrip - SessionConfigTest.V0149FieldsOmittedByDefault - SessionConfigTest.V0149FieldsSerialize - ResumeSessionConfigTest.V0149FieldsSerialize Not yet covered in this commit (remain on the bucket TODO list): - sessionFs / SessionFsConfig - modelCapabilities override - per-agent skills + defaultAgent.excludedTools - CustomAgentConfig.model - MCP config refactor (MCPStdioServerConfig / MCPHTTPServerConfig) - systemMessage transform callbacks - session idle timeout (already covered by ClientOptions bucket) Part of sync/upstream-1.0.49-1 Phase 2 bucket 5 partial. --- include/copilot/types.hpp | 48 +++++++++++++++++++++++++++ src/client.cpp | 28 ++++++++++++++++ tests/test_types.cpp | 69 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 145 insertions(+) diff --git a/include/copilot/types.hpp b/include/copilot/types.hpp index 9fff433..ce37f4e 100644 --- a/include/copilot/types.hpp +++ b/include/copilot/types.hpp @@ -969,6 +969,24 @@ inline void from_json(const json& j, InfiniteSessionConfig& c) // ============================================================================= /// Configuration for creating a new session +/// Remote session mode (matches upstream nodejs RemoteSessionMode). +/// Controls how the session is exposed to remote consumers (Mission Control). +enum class RemoteSessionMode +{ + Off, + Export, + On, +}; + +NLOHMANN_JSON_SERIALIZE_ENUM( + RemoteSessionMode, + { + {RemoteSessionMode::Off, "off"}, + {RemoteSessionMode::Export, "export"}, + {RemoteSessionMode::On, "on"}, + } +) + struct SessionConfig { std::optional session_id; @@ -1012,6 +1030,27 @@ struct SessionConfig /// Working directory for the session. std::optional working_directory; + + // ===== v0.1.49 additions ===== + + /// Client identifier reported to the CLI (PR #510). + std::optional client_name; + + /// Enable per-session telemetry events (PR #1224). + std::optional enable_session_telemetry; + + /// Forward streaming events emitted by sub-agents (PR #1108). + std::optional include_sub_agent_streaming_events; + + /// Allow the CLI to discover and apply config files in the working directory + /// (and ancestors). Default behavior is server-side; this opts in/out (PR #1044). + std::optional enable_config_discovery; + + /// Per-session instruction directories merged with the global instruction set (PR #1190). + std::optional> instruction_directories; + + /// Remote-session mode for Mission Control integration (PR #1295). + std::optional remote_session; }; /// Configuration for resuming an existing session @@ -1067,6 +1106,15 @@ struct ResumeSessionConfig /// Hook handlers for session lifecycle events. std::optional hooks; + + // ===== v0.1.49 additions (mirror SessionConfig) ===== + + std::optional client_name; + std::optional enable_session_telemetry; + std::optional include_sub_agent_streaming_events; + std::optional enable_config_discovery; + std::optional> instruction_directories; + std::optional remote_session; }; /// Options for sending a message diff --git a/src/client.cpp b/src/client.cpp index 4eef755..2998f01 100644 --- a/src/client.cpp +++ b/src/client.cpp @@ -110,6 +110,20 @@ json build_session_create_request(const SessionConfig& config) if (config.working_directory.has_value()) request["workingDirectory"] = *config.working_directory; + // v0.1.49 additions + if (config.client_name.has_value()) + request["clientName"] = *config.client_name; + if (config.enable_session_telemetry.has_value()) + request["enableSessionTelemetry"] = *config.enable_session_telemetry; + if (config.include_sub_agent_streaming_events.has_value()) + request["includeSubAgentStreamingEvents"] = *config.include_sub_agent_streaming_events; + if (config.enable_config_discovery.has_value()) + request["enableConfigDiscovery"] = *config.enable_config_discovery; + if (config.instruction_directories.has_value()) + request["instructionDirectories"] = *config.instruction_directories; + if (config.remote_session.has_value()) + request["remoteSession"] = *config.remote_session; + return request; } @@ -201,6 +215,20 @@ json build_session_resume_request(const std::string& session_id, const ResumeSes if (config.hooks.has_value() && config.hooks->has_any()) request["hooks"] = true; + // v0.1.49 additions (mirror SessionConfig) + if (config.client_name.has_value()) + request["clientName"] = *config.client_name; + if (config.enable_session_telemetry.has_value()) + request["enableSessionTelemetry"] = *config.enable_session_telemetry; + if (config.include_sub_agent_streaming_events.has_value()) + request["includeSubAgentStreamingEvents"] = *config.include_sub_agent_streaming_events; + if (config.enable_config_discovery.has_value()) + request["enableConfigDiscovery"] = *config.enable_config_discovery; + if (config.instruction_directories.has_value()) + request["instructionDirectories"] = *config.instruction_directories; + if (config.remote_session.has_value()) + request["remoteSession"] = *config.remote_session; + return request; } diff --git a/tests/test_types.cpp b/tests/test_types.cpp index 0be1f07..ff95ded 100644 --- a/tests/test_types.cpp +++ b/tests/test_types.cpp @@ -1903,3 +1903,72 @@ TEST(ToolTest, DefaultFlagsFalse) EXPECT_FALSE(t.overrides_built_in_tool); EXPECT_FALSE(t.skip_permission); } + + +// ============================================================================= +// SessionConfig v0.1.49 additions (instruction_directories, remote_session, +// enable_session_telemetry, include_sub_agent_streaming_events, +// enable_config_discovery, client_name) +// ============================================================================= + +TEST(RemoteSessionModeTest, RoundTrip) +{ + json j = RemoteSessionMode::Off; + EXPECT_EQ(j, "off"); + j = RemoteSessionMode::Export; + EXPECT_EQ(j, "export"); + j = RemoteSessionMode::On; + EXPECT_EQ(j, "on"); + EXPECT_EQ(json("off").get(), RemoteSessionMode::Off); + EXPECT_EQ(json("export").get(), RemoteSessionMode::Export); + EXPECT_EQ(json("on").get(), RemoteSessionMode::On); +} + +TEST(SessionConfigTest, V0149FieldsOmittedByDefault) +{ + SessionConfig cfg; + json req = build_session_create_request(cfg); + + EXPECT_FALSE(req.contains("clientName")); + EXPECT_FALSE(req.contains("enableSessionTelemetry")); + EXPECT_FALSE(req.contains("includeSubAgentStreamingEvents")); + EXPECT_FALSE(req.contains("enableConfigDiscovery")); + EXPECT_FALSE(req.contains("instructionDirectories")); + EXPECT_FALSE(req.contains("remoteSession")); +} + +TEST(SessionConfigTest, V0149FieldsSerialize) +{ + SessionConfig cfg; + cfg.client_name = "my-app"; + cfg.enable_session_telemetry = true; + cfg.include_sub_agent_streaming_events = false; + cfg.enable_config_discovery = true; + cfg.instruction_directories = std::vector{"/a", "/b"}; + cfg.remote_session = RemoteSessionMode::Export; + + json req = build_session_create_request(cfg); + + EXPECT_EQ(req["clientName"], "my-app"); + EXPECT_TRUE(req["enableSessionTelemetry"].get()); + EXPECT_FALSE(req["includeSubAgentStreamingEvents"].get()); + EXPECT_TRUE(req["enableConfigDiscovery"].get()); + ASSERT_TRUE(req["instructionDirectories"].is_array()); + EXPECT_EQ(req["instructionDirectories"][0], "/a"); + EXPECT_EQ(req["instructionDirectories"][1], "/b"); + EXPECT_EQ(req["remoteSession"], "export"); +} + +TEST(ResumeSessionConfigTest, V0149FieldsSerialize) +{ + ResumeSessionConfig cfg; + cfg.client_name = "my-app"; + cfg.instruction_directories = std::vector{"/x"}; + cfg.remote_session = RemoteSessionMode::On; + + json req = build_session_resume_request("sess-1", cfg); + + EXPECT_EQ(req["clientName"], "my-app"); + EXPECT_EQ(req["instructionDirectories"][0], "/x"); + EXPECT_EQ(req["remoteSession"], "on"); +} From f633f8125c821814c06b036a7be8b9135a44fda4 Mon Sep 17 00:00:00 2001 From: Elias Bachaalany Date: Sat, 16 May 2026 09:26:26 -0700 Subject: [PATCH 07/15] feat(session): add Session::set_model + set_mode + getters (v3 RPC) Adds the v0.1.49 mode/model APIs that upstream exposed via the v3 RPC namespace (session.model.*, session.mode.*): - Session::set_model(model_id, SetModelOptions) calls session.model.switchTo with {sessionId, modelId, reasoningEffort?}. Mirrors upstream CopilotSession.setModel. - Session::SetModelOptions struct with optional reasoning_effort. - Session::get_current_model() calls session.model.getCurrent and returns std::optional (nullopt when server reports no modelId). - Session::Mode enum (Interactive / Plan / Autopilot) mirrors the upstream "interactive" | "plan" | "autopilot" SessionMode union. - Session::set_mode(mode) calls session.mode.set. - Session::get_mode() calls session.mode.get and parses the wire string; defaults to Interactive on unknown values to keep the SDK forward-compatible. Tests added (test_types.cpp, +2 cases): - SessionModeTest.EnumValuesExist - SessionSetModelOptionsTest.DefaultsEmpty 310/310 ctest pass (E2E skipped). Not yet covered in this commit (still on bucket TODO): - ModelCapabilitiesOverride passthrough on set_model - agent.select / agent.switch APIs - session compaction RPCs (session.compact / compact.start / compact.complete) beyond the existing compaction event hooks Part of sync/upstream-1.0.49-1 Phase 2 bucket 7 partial. --- include/copilot/session.hpp | 41 ++++++++++++++++ src/session.cpp | 93 +++++++++++++++++++++++++++++++++++++ tests/test_types.cpp | 22 +++++++++ 3 files changed, 156 insertions(+) diff --git a/include/copilot/session.hpp b/include/copilot/session.hpp index 9173a8f..0d62c65 100644 --- a/include/copilot/session.hpp +++ b/include/copilot/session.hpp @@ -232,6 +232,47 @@ class Session : public std::enable_shared_from_this /// @return Future that completes when destroyed std::future destroy(); + // ========================================================================= + // Model & Mode (v0.1.49 additions) + // ========================================================================= + + /// Options for set_model() mirroring upstream session.model.switchTo params. + struct SetModelOptions + { + std::optional reasoning_effort; + }; + + /// Switch the session to a different model mid-conversation. + /// Calls the v3 session.model.switchTo RPC. + /// @param model_id Identifier of the target model + /// @param options Optional reasoning effort override + /// @return Future that completes when the switch is acknowledged + std::future set_model(const std::string& model_id, SetModelOptions options = {}); + + /// Get the currently selected model for the session. + /// Calls the v3 session.model.getCurrent RPC. + /// @return Future resolving to the model identifier (or nullopt if none) + std::future> get_current_model(); + + /// Agent interaction mode. Matches upstream nodejs SessionMode union. + enum class Mode + { + Interactive, + Plan, + Autopilot, + }; + + /// Set the agent interaction mode. + /// Calls the v3 session.mode.set RPC. + /// @param mode New mode to apply + /// @return Future that completes when the mode change is acknowledged + std::future set_mode(Mode mode); + + /// Get the current agent interaction mode. + /// Calls the v3 session.mode.get RPC. + /// @return Future resolving to the current mode + std::future get_mode(); + private: std::string session_id_; Client* client_; diff --git a/src/session.cpp b/src/session.cpp index 7a09351..662972a 100644 --- a/src/session.cpp +++ b/src/session.cpp @@ -386,4 +386,97 @@ std::future Session::destroy() ); } +// ============================================================================= +// Model & Mode (v0.1.49 additions) +// ============================================================================= + +std::future Session::set_model(const std::string& model_id, SetModelOptions options) +{ + return std::async( + std::launch::async, + [this, model_id, options]() + { + json params; + params["sessionId"] = session_id_; + params["modelId"] = model_id; + if (options.reasoning_effort.has_value()) + params["reasoningEffort"] = *options.reasoning_effort; + + client_->rpc_client()->invoke("session.model.switchTo", params).get(); + } + ); +} + +std::future> Session::get_current_model() +{ + return std::async( + std::launch::async, + [this]() -> std::optional + { + json params; + params["sessionId"] = session_id_; + auto response = client_->rpc_client()->invoke("session.model.getCurrent", params).get(); + // Response: { modelId?: string } per nodejs CurrentModel shape. + if (response.contains("modelId") && !response["modelId"].is_null()) + return response["modelId"].get(); + return std::nullopt; + } + ); +} + +namespace +{ +const char* mode_to_wire(Session::Mode m) +{ + switch (m) + { + case Session::Mode::Interactive: return "interactive"; + case Session::Mode::Plan: return "plan"; + case Session::Mode::Autopilot: return "autopilot"; + } + return "interactive"; +} + +std::optional mode_from_wire(const std::string& s) +{ + if (s == "interactive") return Session::Mode::Interactive; + if (s == "plan") return Session::Mode::Plan; + if (s == "autopilot") return Session::Mode::Autopilot; + return std::nullopt; +} +} // namespace + +std::future Session::set_mode(Mode mode) +{ + return std::async( + std::launch::async, + [this, mode]() + { + json params; + params["sessionId"] = session_id_; + params["mode"] = mode_to_wire(mode); + client_->rpc_client()->invoke("session.mode.set", params).get(); + } + ); +} + +std::future Session::get_mode() +{ + return std::async( + std::launch::async, + [this]() -> Mode + { + json params; + params["sessionId"] = session_id_; + auto response = client_->rpc_client()->invoke("session.mode.get", params).get(); + // Response shape: { mode: "interactive" | "plan" | "autopilot" } + std::string wire = response.contains("mode") && response["mode"].is_string() + ? response["mode"].get() + : std::string{"interactive"}; + auto parsed = mode_from_wire(wire); + return parsed.value_or(Mode::Interactive); + } + ); +} + } // namespace copilot diff --git a/tests/test_types.cpp b/tests/test_types.cpp index ff95ded..37db58d 100644 --- a/tests/test_types.cpp +++ b/tests/test_types.cpp @@ -1972,3 +1972,25 @@ TEST(ResumeSessionConfigTest, V0149FieldsSerialize) EXPECT_EQ(req["instructionDirectories"][0], "/x"); EXPECT_EQ(req["remoteSession"], "on"); } + + +// ============================================================================= +// Session::Mode enum sanity (v0.1.49 mode handler APIs) +// ============================================================================= + +TEST(SessionModeTest, EnumValuesExist) +{ + // Compile-time check that the enum has the expected variants. + Session::Mode m = Session::Mode::Interactive; + EXPECT_EQ(static_cast(m), 0); + m = Session::Mode::Plan; + EXPECT_EQ(static_cast(m), 1); + m = Session::Mode::Autopilot; + EXPECT_EQ(static_cast(m), 2); +} + +TEST(SessionSetModelOptionsTest, DefaultsEmpty) +{ + Session::SetModelOptions opts; + EXPECT_FALSE(opts.reasoning_effort.has_value()); +} From 13f06e637d696bf68e17f8299fa10c55ca1a5aaa Mon Sep 17 00:00:00 2001 From: Elias Bachaalany Date: Sat, 16 May 2026 10:08:46 -0700 Subject: [PATCH 08/15] feat(rpc): mirror v3 generated RPC namespace and add typed structs Adds two new public headers that centralize the JSON-RPC method-name surface published by the upstream nodejs reference at reference/copilot-sdk/nodejs/src/generated/rpc.ts: - include/copilot/rpc_methods.hpp exposes one inline constexpr const char* per wire string under copilot::rpc::methods (e.g. kSessionModelSwitchTo, kSessionPlanRead, kSessionsFork, kSessionFsReadFile). Also ships RpcRequestDispatcher, a thin facade that demuxes JsonRpcClient::set_request_handler by method name and mirrors the shape of upstream registerClientSessionApiHandlers. - include/copilot/rpc_types.hpp provides nlohmann::json-friendly request/result structs for the most commonly used new families: session.name, session.mode, session.model.switchTo, session.plan.{read,update}, session.history.{compact,truncate}, sessions.fork, session.shell.{exec,kill}, session.commands.{list,invoke,handlePendingCommand}, session.permissions.setApproveAll and the full sessionFs.* server-to-client request set with a SessionFsHandlers bundle and register_session_fs_handlers helper. - Switches the existing 21 hard-coded method strings in src/client.cpp and src/session.cpp over to the new constants. - Adds tests/test_rpc_methods.cpp (37 cases) covering wire-string parity for every catalog entry, round-trip JSON for the new typed structs, and RpcRequestDispatcher routing. Does NOT touch event parsing (events.hpp/events.cpp) - that surface is owned by the parallel p2-cpp-events work. --- CMakeLists.txt | 2 + include/copilot/copilot.hpp | 2 + include/copilot/rpc_methods.hpp | 273 ++++++++++ include/copilot/rpc_types.hpp | 878 ++++++++++++++++++++++++++++++++ src/client.cpp | 27 +- src/session.cpp | 17 +- tests/CMakeLists.txt | 14 + tests/test_rpc_methods.cpp | 454 +++++++++++++++++ 8 files changed, 1646 insertions(+), 21 deletions(-) create mode 100644 include/copilot/rpc_methods.hpp create mode 100644 include/copilot/rpc_types.hpp create mode 100644 tests/test_rpc_methods.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index c275f7c..d51ff1b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -62,6 +62,8 @@ add_library(copilot_sdk_cpp include/copilot/process.hpp include/copilot/client.hpp include/copilot/session.hpp + include/copilot/rpc_methods.hpp + include/copilot/rpc_types.hpp # Sources src/types.cpp src/events.cpp diff --git a/include/copilot/copilot.hpp b/include/copilot/copilot.hpp index e425362..d6136dd 100644 --- a/include/copilot/copilot.hpp +++ b/include/copilot/copilot.hpp @@ -13,6 +13,8 @@ #include #include #include +#include +#include #include #include #include diff --git a/include/copilot/rpc_methods.hpp b/include/copilot/rpc_methods.hpp new file mode 100644 index 0000000..0548c88 --- /dev/null +++ b/include/copilot/rpc_methods.hpp @@ -0,0 +1,273 @@ +// Copyright (c) 2025 Elias Bachaalany +// SPDX-License-Identifier: MIT + +#pragma once + +/// @file rpc_methods.hpp +/// @brief Catalog of JSON-RPC method names used by the Copilot CLI SDK. +/// +/// Mirrors the canonical method-name surface generated by the upstream +/// nodejs reference at `nodejs/src/generated/rpc.ts`. Each constant is the +/// exact wire string the server expects. Use these symbols in `invoke()`, +/// `set_request_handler` dispatch, and tests instead of hard-coding strings, +/// so the catalog can be diffed against upstream when bumping versions. + +#include +#include +#include +#include +#include + +namespace copilot::rpc::methods +{ + +// ============================================================================= +// Top-level client API (no per-session scoping) +// ============================================================================= + +inline constexpr const char* kPing = "ping"; +inline constexpr const char* kConnect = "connect"; +inline constexpr const char* kModelsList = "models.list"; +inline constexpr const char* kToolsList = "tools.list"; +inline constexpr const char* kAccountGetQuota = "account.getQuota"; + +// MCP global config +inline constexpr const char* kMcpConfigList = "mcp.config.list"; +inline constexpr const char* kMcpConfigAdd = "mcp.config.add"; +inline constexpr const char* kMcpConfigUpdate = "mcp.config.update"; +inline constexpr const char* kMcpConfigRemove = "mcp.config.remove"; +inline constexpr const char* kMcpConfigEnable = "mcp.config.enable"; +inline constexpr const char* kMcpConfigDisable = "mcp.config.disable"; +inline constexpr const char* kMcpDiscover = "mcp.discover"; + +// Skills global config +inline constexpr const char* kSkillsConfigSetDisabledSkills = "skills.config.setDisabledSkills"; +inline constexpr const char* kSkillsDiscover = "skills.discover"; + +// Session filesystem provider (client-side fs registration) +inline constexpr const char* kSessionFsSetProvider = "sessionFs.setProvider"; + +// Session lifecycle (top-level) +inline constexpr const char* kSessionsFork = "sessions.fork"; +inline constexpr const char* kSessionsConnect = "sessions.connect"; + +// ============================================================================= +// Server-to-client requests (the runtime calls these on the SDK client) +// All take a `sessionId` to route to the registered handler set. +// ============================================================================= + +inline constexpr const char* kSessionFsReadFile = "sessionFs.readFile"; +inline constexpr const char* kSessionFsWriteFile = "sessionFs.writeFile"; +inline constexpr const char* kSessionFsAppendFile = "sessionFs.appendFile"; +inline constexpr const char* kSessionFsExists = "sessionFs.exists"; +inline constexpr const char* kSessionFsStat = "sessionFs.stat"; +inline constexpr const char* kSessionFsMkdir = "sessionFs.mkdir"; +inline constexpr const char* kSessionFsReaddir = "sessionFs.readdir"; +inline constexpr const char* kSessionFsReaddirWithTypes = "sessionFs.readdirWithTypes"; +inline constexpr const char* kSessionFsRm = "sessionFs.rm"; +inline constexpr const char* kSessionFsRename = "sessionFs.rename"; + +// ============================================================================= +// Session-scoped methods (per-session API; each carries a `sessionId`) +// ============================================================================= + +inline constexpr const char* kSessionSuspend = "session.suspend"; +inline constexpr const char* kSessionLog = "session.log"; + +inline constexpr const char* kSessionAuthGetStatus = "session.auth.getStatus"; + +inline constexpr const char* kSessionModelGetCurrent = "session.model.getCurrent"; +inline constexpr const char* kSessionModelSwitchTo = "session.model.switchTo"; + +inline constexpr const char* kSessionModeGet = "session.mode.get"; +inline constexpr const char* kSessionModeSet = "session.mode.set"; + +inline constexpr const char* kSessionNameGet = "session.name.get"; +inline constexpr const char* kSessionNameSet = "session.name.set"; + +inline constexpr const char* kSessionPlanRead = "session.plan.read"; +inline constexpr const char* kSessionPlanUpdate = "session.plan.update"; +inline constexpr const char* kSessionPlanDelete = "session.plan.delete"; + +inline constexpr const char* kSessionWorkspacesGetWorkspace = "session.workspaces.getWorkspace"; +inline constexpr const char* kSessionWorkspacesListFiles = "session.workspaces.listFiles"; +inline constexpr const char* kSessionWorkspacesReadFile = "session.workspaces.readFile"; +inline constexpr const char* kSessionWorkspacesCreateFile = "session.workspaces.createFile"; + +inline constexpr const char* kSessionInstructionsGetSources = "session.instructions.getSources"; + +inline constexpr const char* kSessionFleetStart = "session.fleet.start"; + +inline constexpr const char* kSessionAgentList = "session.agent.list"; +inline constexpr const char* kSessionAgentGetCurrent = "session.agent.getCurrent"; +inline constexpr const char* kSessionAgentSelect = "session.agent.select"; +inline constexpr const char* kSessionAgentDeselect = "session.agent.deselect"; +inline constexpr const char* kSessionAgentReload = "session.agent.reload"; + +inline constexpr const char* kSessionTasksStartAgent = "session.tasks.startAgent"; +inline constexpr const char* kSessionTasksList = "session.tasks.list"; +inline constexpr const char* kSessionTasksPromoteToBackground = "session.tasks.promoteToBackground"; +inline constexpr const char* kSessionTasksCancel = "session.tasks.cancel"; +inline constexpr const char* kSessionTasksRemove = "session.tasks.remove"; +inline constexpr const char* kSessionTasksSendMessage = "session.tasks.sendMessage"; + +inline constexpr const char* kSessionSkillsList = "session.skills.list"; +inline constexpr const char* kSessionSkillsEnable = "session.skills.enable"; +inline constexpr const char* kSessionSkillsDisable = "session.skills.disable"; +inline constexpr const char* kSessionSkillsReload = "session.skills.reload"; + +inline constexpr const char* kSessionMcpList = "session.mcp.list"; +inline constexpr const char* kSessionMcpEnable = "session.mcp.enable"; +inline constexpr const char* kSessionMcpDisable = "session.mcp.disable"; +inline constexpr const char* kSessionMcpReload = "session.mcp.reload"; +inline constexpr const char* kSessionMcpOauthLogin = "session.mcp.oauth.login"; + +inline constexpr const char* kSessionPluginsList = "session.plugins.list"; + +inline constexpr const char* kSessionExtensionsList = "session.extensions.list"; +inline constexpr const char* kSessionExtensionsEnable = "session.extensions.enable"; +inline constexpr const char* kSessionExtensionsDisable = "session.extensions.disable"; +inline constexpr const char* kSessionExtensionsReload = "session.extensions.reload"; + +inline constexpr const char* kSessionToolsHandlePendingToolCall = "session.tools.handlePendingToolCall"; + +inline constexpr const char* kSessionCommandsList = "session.commands.list"; +inline constexpr const char* kSessionCommandsInvoke = "session.commands.invoke"; +inline constexpr const char* kSessionCommandsHandlePendingCommand = "session.commands.handlePendingCommand"; +inline constexpr const char* kSessionCommandsRespondToQueuedCommand = "session.commands.respondToQueuedCommand"; + +inline constexpr const char* kSessionUiElicitation = "session.ui.elicitation"; +inline constexpr const char* kSessionUiHandlePendingElicitation = "session.ui.handlePendingElicitation"; + +inline constexpr const char* kSessionPermissionsHandlePendingPermissionRequest = "session.permissions.handlePendingPermissionRequest"; +inline constexpr const char* kSessionPermissionsSetApproveAll = "session.permissions.setApproveAll"; +inline constexpr const char* kSessionPermissionsResetSessionApprovals = "session.permissions.resetSessionApprovals"; + +inline constexpr const char* kSessionShellExec = "session.shell.exec"; +inline constexpr const char* kSessionShellKill = "session.shell.kill"; + +inline constexpr const char* kSessionHistoryCompact = "session.history.compact"; +inline constexpr const char* kSessionHistoryTruncate = "session.history.truncate"; + +inline constexpr const char* kSessionUsageGetMetrics = "session.usage.getMetrics"; + +inline constexpr const char* kSessionRemoteEnable = "session.remote.enable"; +inline constexpr const char* kSessionRemoteDisable = "session.remote.disable"; + +// ============================================================================= +// Legacy / pre-v3 helpers that the C++ port still invokes against the CLI. +// They are not in the generated rpc.ts namespace but remain on the wire for +// backward compatibility with the v2 protocol. Listed here so call sites can +// reference a single symbol. +// ============================================================================= + +inline constexpr const char* kAuthGetStatus = "auth.getStatus"; +inline constexpr const char* kStatusGet = "status.get"; + +inline constexpr const char* kSessionCreate = "session.create"; +inline constexpr const char* kSessionResume = "session.resume"; +inline constexpr const char* kSessionList = "session.list"; +inline constexpr const char* kSessionGetMetadata = "session.getMetadata"; +inline constexpr const char* kSessionDelete = "session.delete"; +inline constexpr const char* kSessionGetLastId = "session.getLastId"; +inline constexpr const char* kSessionDestroy = "session.destroy"; +inline constexpr const char* kSessionSend = "session.send"; +inline constexpr const char* kSessionAbort = "session.abort"; +inline constexpr const char* kSessionGetMessages = "session.getMessages"; +inline constexpr const char* kSessionGetForeground = "session.getForeground"; +inline constexpr const char* kSessionSetForeground = "session.setForeground"; + +} // namespace copilot::rpc::methods + +namespace copilot::rpc +{ + +/// Method-keyed request dispatcher. +/// +/// Thin facade over `JsonRpcClient::set_request_handler` that lets callers +/// register per-method handlers — mirroring the shape of the upstream +/// `registerClientSessionApiHandlers` helper in `generated/rpc.ts`. The +/// dispatcher owns a single demux callback installed on the client and +/// looks up the registered handler for each incoming request by method name. +/// +/// Thread-safety: registrations are serialized through an internal mutex. +/// Dispatch itself runs on the JSON-RPC read thread; handlers must be +/// reasonably quick or off-load work to another thread. +class RpcRequestDispatcher +{ + public: + using Handler = std::function; + + /// Construct, but do NOT install on a client yet. Use `attach()`. + RpcRequestDispatcher() = default; + + /// Construct and install on `client` immediately. + explicit RpcRequestDispatcher(JsonRpcClient& client) { attach(client); } + + /// Install this dispatcher as the request handler of `client`. + /// Any previously-installed handler is overwritten. + void attach(JsonRpcClient& client) + { + client.set_request_handler( + [this](const std::string& method, const json& params) -> json + { + return dispatch(method, params); + }); + } + + /// Register `handler` for `method`. Overwrites any previous registration. + void on(const std::string& method, Handler handler) + { + std::lock_guard lock(mu_); + handlers_[method] = std::move(handler); + } + + /// Remove the handler for `method` (no-op if not registered). + void off(const std::string& method) + { + std::lock_guard lock(mu_); + handlers_.erase(method); + } + + /// Check whether a handler is registered for `method`. + bool has(const std::string& method) const + { + std::lock_guard lock(mu_); + return handlers_.find(method) != handlers_.end(); + } + + /// Number of registered handlers (mostly useful for tests). + std::size_t size() const + { + std::lock_guard lock(mu_); + return handlers_.size(); + } + + /// Directly dispatch a request — exposed so tests can exercise routing + /// without spinning up a transport. Throws `JsonRpcError` (method + /// not found) if no handler is registered, matching the JSON-RPC 2.0 + /// behavior of the underlying client. + json dispatch(const std::string& method, const json& params) + { + Handler handler; + { + std::lock_guard lock(mu_); + auto it = handlers_.find(method); + if (it != handlers_.end()) + handler = it->second; + } + if (!handler) + { + throw JsonRpcError( + JsonRpcErrorCode::MethodNotFound, + "No handler registered for method: " + method); + } + return handler(params); + } + + private: + mutable std::mutex mu_; + std::unordered_map handlers_; +}; + +} // namespace copilot::rpc diff --git a/include/copilot/rpc_types.hpp b/include/copilot/rpc_types.hpp new file mode 100644 index 0000000..03df611 --- /dev/null +++ b/include/copilot/rpc_types.hpp @@ -0,0 +1,878 @@ +// Copyright (c) 2025 Elias Bachaalany +// SPDX-License-Identifier: MIT + +#pragma once + +/// @file rpc_types.hpp +/// @brief Typed parameter/result structs for the v3 generated RPC namespace. +/// +/// Companion to `rpc_methods.hpp`. Provides nlohmann::json-friendly structs +/// for the most commonly used new method families in the upstream nodejs +/// reference (`nodejs/src/generated/rpc.ts`): plan, name, mode, model, +/// session filesystem, fork, history (compact/truncate), shell, commands, +/// permissions, elicitation, user-input. +/// +/// Field names follow snake_case in C++; JSON wire names remain camelCase +/// to match upstream exactly. All `sessionId` fields are spelled +/// `session_id` in C++. + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace copilot::rpc +{ + +// ============================================================================= +// Small helpers +// ============================================================================= + +namespace detail +{ + +template +inline void set_opt(json& j, const char* key, const std::optional& v) +{ + if (v.has_value()) + j[key] = *v; +} + +template +inline void get_opt(const json& j, const char* key, std::optional& v) +{ + if (j.contains(key) && !j.at(key).is_null()) + v = j.at(key).get(); +} + +} // namespace detail + +// ============================================================================= +// session.name.* +// ============================================================================= + +struct NameGetResult +{ + std::optional name; ///< null on the wire when not yet set +}; + +inline void to_json(json& j, const NameGetResult& r) +{ + j = json::object(); + if (r.name.has_value()) + j["name"] = *r.name; + else + j["name"] = nullptr; +} + +inline void from_json(const json& j, NameGetResult& r) +{ + if (j.contains("name") && !j.at("name").is_null()) + r.name = j.at("name").get(); + else + r.name = std::nullopt; +} + +struct NameSetRequest +{ + std::string name; +}; + +inline void to_json(json& j, const NameSetRequest& r) { j = json{{"name", r.name}}; } +inline void from_json(const json& j, NameSetRequest& r) { j.at("name").get_to(r.name); } + +// ============================================================================= +// session.mode.* (mode is "interactive" | "plan" | "autopilot") +// ============================================================================= + +struct ModeSetRequest +{ + std::string mode; +}; + +inline void to_json(json& j, const ModeSetRequest& r) { j = json{{"mode", r.mode}}; } +inline void from_json(const json& j, ModeSetRequest& r) { j.at("mode").get_to(r.mode); } + +struct ModeGetResult +{ + std::string mode; +}; + +inline void to_json(json& j, const ModeGetResult& r) { j = json{{"mode", r.mode}}; } +inline void from_json(const json& j, ModeGetResult& r) { j.at("mode").get_to(r.mode); } + +// ============================================================================= +// session.model.* +// ============================================================================= + +struct ModelSwitchToRequest +{ + std::string model_id; + std::optional reasoning_effort; +}; + +inline void to_json(json& j, const ModelSwitchToRequest& r) +{ + j = json{{"modelId", r.model_id}}; + detail::set_opt(j, "reasoningEffort", r.reasoning_effort); +} + +inline void from_json(const json& j, ModelSwitchToRequest& r) +{ + j.at("modelId").get_to(r.model_id); + detail::get_opt(j, "reasoningEffort", r.reasoning_effort); +} + +struct ModelSwitchToResult +{ + std::optional model_id; +}; + +inline void to_json(json& j, const ModelSwitchToResult& r) +{ + j = json::object(); + detail::set_opt(j, "modelId", r.model_id); +} + +inline void from_json(const json& j, ModelSwitchToResult& r) +{ + detail::get_opt(j, "modelId", r.model_id); +} + +// ============================================================================= +// session.plan.* +// ============================================================================= + +struct PlanReadResult +{ + bool exists = false; + std::optional content; + std::optional path; +}; + +inline void to_json(json& j, const PlanReadResult& r) +{ + j = json{{"exists", r.exists}}; + j["content"] = r.content.has_value() ? json(*r.content) : json(nullptr); + j["path"] = r.path.has_value() ? json(*r.path) : json(nullptr); +} + +inline void from_json(const json& j, PlanReadResult& r) +{ + j.at("exists").get_to(r.exists); + if (j.contains("content") && !j.at("content").is_null()) + r.content = j.at("content").get(); + else + r.content = std::nullopt; + if (j.contains("path") && !j.at("path").is_null()) + r.path = j.at("path").get(); + else + r.path = std::nullopt; +} + +struct PlanUpdateRequest +{ + std::string content; +}; + +inline void to_json(json& j, const PlanUpdateRequest& r) { j = json{{"content", r.content}}; } +inline void from_json(const json& j, PlanUpdateRequest& r) { j.at("content").get_to(r.content); } + +// ============================================================================= +// session.history.* (compact / truncate) +// ============================================================================= + +struct HistoryCompactResult +{ + bool success = false; + int tokens_removed = 0; + int messages_removed = 0; +}; + +inline void to_json(json& j, const HistoryCompactResult& r) +{ + j = json{ + {"success", r.success}, + {"tokensRemoved", r.tokens_removed}, + {"messagesRemoved", r.messages_removed}, + }; +} + +inline void from_json(const json& j, HistoryCompactResult& r) +{ + j.at("success").get_to(r.success); + j.at("tokensRemoved").get_to(r.tokens_removed); + j.at("messagesRemoved").get_to(r.messages_removed); +} + +struct HistoryTruncateRequest +{ + std::string event_id; +}; + +inline void to_json(json& j, const HistoryTruncateRequest& r) +{ + j = json{{"eventId", r.event_id}}; +} + +inline void from_json(const json& j, HistoryTruncateRequest& r) +{ + j.at("eventId").get_to(r.event_id); +} + +struct HistoryTruncateResult +{ + int events_removed = 0; +}; + +inline void to_json(json& j, const HistoryTruncateResult& r) +{ + j = json{{"eventsRemoved", r.events_removed}}; +} + +inline void from_json(const json& j, HistoryTruncateResult& r) +{ + j.at("eventsRemoved").get_to(r.events_removed); +} + +// ============================================================================= +// sessions.fork +// ============================================================================= + +struct SessionsForkRequest +{ + std::string session_id; + std::optional to_event_id; + std::optional name; +}; + +inline void to_json(json& j, const SessionsForkRequest& r) +{ + j = json{{"sessionId", r.session_id}}; + detail::set_opt(j, "toEventId", r.to_event_id); + detail::set_opt(j, "name", r.name); +} + +inline void from_json(const json& j, SessionsForkRequest& r) +{ + j.at("sessionId").get_to(r.session_id); + detail::get_opt(j, "toEventId", r.to_event_id); + detail::get_opt(j, "name", r.name); +} + +struct SessionsForkResult +{ + std::string session_id; + std::optional name; +}; + +inline void to_json(json& j, const SessionsForkResult& r) +{ + j = json{{"sessionId", r.session_id}}; + detail::set_opt(j, "name", r.name); +} + +inline void from_json(const json& j, SessionsForkResult& r) +{ + j.at("sessionId").get_to(r.session_id); + detail::get_opt(j, "name", r.name); +} + +// ============================================================================= +// session.shell.* +// ============================================================================= + +struct ShellExecRequest +{ + std::string command; + std::optional cwd; + std::optional timeout_ms; +}; + +inline void to_json(json& j, const ShellExecRequest& r) +{ + j = json{{"command", r.command}}; + detail::set_opt(j, "cwd", r.cwd); + if (r.timeout_ms.has_value()) + j["timeout"] = *r.timeout_ms; +} + +inline void from_json(const json& j, ShellExecRequest& r) +{ + j.at("command").get_to(r.command); + detail::get_opt(j, "cwd", r.cwd); + if (j.contains("timeout") && !j.at("timeout").is_null()) + r.timeout_ms = j.at("timeout").get(); +} + +struct ShellExecResult +{ + std::string process_id; +}; + +inline void to_json(json& j, const ShellExecResult& r) { j = json{{"processId", r.process_id}}; } +inline void from_json(const json& j, ShellExecResult& r) { j.at("processId").get_to(r.process_id); } + +struct ShellKillRequest +{ + std::string process_id; + std::optional signal; +}; + +inline void to_json(json& j, const ShellKillRequest& r) +{ + j = json{{"processId", r.process_id}}; + detail::set_opt(j, "signal", r.signal); +} + +inline void from_json(const json& j, ShellKillRequest& r) +{ + j.at("processId").get_to(r.process_id); + detail::get_opt(j, "signal", r.signal); +} + +struct ShellKillResult +{ + bool killed = false; +}; + +inline void to_json(json& j, const ShellKillResult& r) { j = json{{"killed", r.killed}}; } +inline void from_json(const json& j, ShellKillResult& r) { j.at("killed").get_to(r.killed); } + +// ============================================================================= +// session.commands.* +// ============================================================================= + +struct CommandsListRequest +{ + std::optional include_builtins; + std::optional include_skills; + std::optional include_client_commands; +}; + +inline void to_json(json& j, const CommandsListRequest& r) +{ + j = json::object(); + detail::set_opt(j, "includeBuiltins", r.include_builtins); + detail::set_opt(j, "includeSkills", r.include_skills); + detail::set_opt(j, "includeClientCommands", r.include_client_commands); +} + +inline void from_json(const json& j, CommandsListRequest& r) +{ + detail::get_opt(j, "includeBuiltins", r.include_builtins); + detail::get_opt(j, "includeSkills", r.include_skills); + detail::get_opt(j, "includeClientCommands", r.include_client_commands); +} + +struct CommandsInvokeRequest +{ + std::string name; + std::optional input; +}; + +inline void to_json(json& j, const CommandsInvokeRequest& r) +{ + j = json{{"name", r.name}}; + detail::set_opt(j, "input", r.input); +} + +inline void from_json(const json& j, CommandsInvokeRequest& r) +{ + j.at("name").get_to(r.name); + detail::get_opt(j, "input", r.input); +} + +struct CommandsHandlePendingCommandRequest +{ + std::string request_id; + std::optional error; +}; + +inline void to_json(json& j, const CommandsHandlePendingCommandRequest& r) +{ + j = json{{"requestId", r.request_id}}; + detail::set_opt(j, "error", r.error); +} + +inline void from_json(const json& j, CommandsHandlePendingCommandRequest& r) +{ + j.at("requestId").get_to(r.request_id); + detail::get_opt(j, "error", r.error); +} + +struct CommandsHandlePendingCommandResult +{ + bool success = false; +}; + +inline void to_json(json& j, const CommandsHandlePendingCommandResult& r) +{ + j = json{{"success", r.success}}; +} + +inline void from_json(const json& j, CommandsHandlePendingCommandResult& r) +{ + j.at("success").get_to(r.success); +} + +// ============================================================================= +// session.permissions.* (set/reset; the handle-pending one already lives in +// types.hpp as PermissionRequestResult) +// ============================================================================= + +struct PermissionsSetApproveAllRequest +{ + bool enabled = false; +}; + +inline void to_json(json& j, const PermissionsSetApproveAllRequest& r) +{ + j = json{{"enabled", r.enabled}}; +} + +inline void from_json(const json& j, PermissionsSetApproveAllRequest& r) +{ + j.at("enabled").get_to(r.enabled); +} + +struct PermissionsSetApproveAllResult +{ + bool success = false; +}; + +inline void to_json(json& j, const PermissionsSetApproveAllResult& r) +{ + j = json{{"success", r.success}}; +} + +inline void from_json(const json& j, PermissionsSetApproveAllResult& r) +{ + j.at("success").get_to(r.success); +} + +// ============================================================================= +// sessionFs.* (server-to-client requests; SDK client implements these) +// ============================================================================= + +struct SessionFsError +{ + std::string code; ///< "ENOENT" | "UNKNOWN" + std::optional message; +}; + +inline void to_json(json& j, const SessionFsError& e) +{ + j = json{{"code", e.code}}; + detail::set_opt(j, "message", e.message); +} + +inline void from_json(const json& j, SessionFsError& e) +{ + j.at("code").get_to(e.code); + detail::get_opt(j, "message", e.message); +} + +struct SessionFsReadFileRequest +{ + std::string session_id; + std::string path; +}; + +inline void to_json(json& j, const SessionFsReadFileRequest& r) +{ + j = json{{"sessionId", r.session_id}, {"path", r.path}}; +} + +inline void from_json(const json& j, SessionFsReadFileRequest& r) +{ + j.at("sessionId").get_to(r.session_id); + j.at("path").get_to(r.path); +} + +struct SessionFsReadFileResult +{ + std::string content; + std::optional error; +}; + +inline void to_json(json& j, const SessionFsReadFileResult& r) +{ + j = json{{"content", r.content}}; + if (r.error.has_value()) + j["error"] = *r.error; +} + +inline void from_json(const json& j, SessionFsReadFileResult& r) +{ + j.at("content").get_to(r.content); + if (j.contains("error") && !j.at("error").is_null()) + r.error = j.at("error").get(); +} + +struct SessionFsWriteFileRequest +{ + std::string session_id; + std::string path; + std::string content; + std::optional mode; +}; + +inline void to_json(json& j, const SessionFsWriteFileRequest& r) +{ + j = json{ + {"sessionId", r.session_id}, + {"path", r.path}, + {"content", r.content}, + }; + if (r.mode.has_value()) + j["mode"] = *r.mode; +} + +inline void from_json(const json& j, SessionFsWriteFileRequest& r) +{ + j.at("sessionId").get_to(r.session_id); + j.at("path").get_to(r.path); + j.at("content").get_to(r.content); + if (j.contains("mode") && !j.at("mode").is_null()) + r.mode = j.at("mode").get(); +} + +struct SessionFsAppendFileRequest +{ + std::string session_id; + std::string path; + std::string content; + std::optional mode; +}; + +inline void to_json(json& j, const SessionFsAppendFileRequest& r) +{ + j = json{ + {"sessionId", r.session_id}, + {"path", r.path}, + {"content", r.content}, + }; + if (r.mode.has_value()) + j["mode"] = *r.mode; +} + +inline void from_json(const json& j, SessionFsAppendFileRequest& r) +{ + j.at("sessionId").get_to(r.session_id); + j.at("path").get_to(r.path); + j.at("content").get_to(r.content); + if (j.contains("mode") && !j.at("mode").is_null()) + r.mode = j.at("mode").get(); +} + +struct SessionFsExistsRequest +{ + std::string session_id; + std::string path; +}; + +inline void to_json(json& j, const SessionFsExistsRequest& r) +{ + j = json{{"sessionId", r.session_id}, {"path", r.path}}; +} + +inline void from_json(const json& j, SessionFsExistsRequest& r) +{ + j.at("sessionId").get_to(r.session_id); + j.at("path").get_to(r.path); +} + +struct SessionFsExistsResult +{ + bool exists = false; +}; + +inline void to_json(json& j, const SessionFsExistsResult& r) { j = json{{"exists", r.exists}}; } +inline void from_json(const json& j, SessionFsExistsResult& r) { j.at("exists").get_to(r.exists); } + +struct SessionFsStatRequest +{ + std::string session_id; + std::string path; +}; + +inline void to_json(json& j, const SessionFsStatRequest& r) +{ + j = json{{"sessionId", r.session_id}, {"path", r.path}}; +} + +inline void from_json(const json& j, SessionFsStatRequest& r) +{ + j.at("sessionId").get_to(r.session_id); + j.at("path").get_to(r.path); +} + +struct SessionFsStatResult +{ + bool is_file = false; + bool is_directory = false; + int64_t size = 0; + std::string mtime; ///< ISO 8601 + std::string birthtime; ///< ISO 8601 + std::optional error; +}; + +inline void to_json(json& j, const SessionFsStatResult& r) +{ + j = json{ + {"isFile", r.is_file}, + {"isDirectory", r.is_directory}, + {"size", r.size}, + {"mtime", r.mtime}, + {"birthtime", r.birthtime}, + }; + if (r.error.has_value()) + j["error"] = *r.error; +} + +inline void from_json(const json& j, SessionFsStatResult& r) +{ + j.at("isFile").get_to(r.is_file); + j.at("isDirectory").get_to(r.is_directory); + j.at("size").get_to(r.size); + j.at("mtime").get_to(r.mtime); + j.at("birthtime").get_to(r.birthtime); + if (j.contains("error") && !j.at("error").is_null()) + r.error = j.at("error").get(); +} + +struct SessionFsMkdirRequest +{ + std::string session_id; + std::string path; + std::optional recursive; + std::optional mode; +}; + +inline void to_json(json& j, const SessionFsMkdirRequest& r) +{ + j = json{{"sessionId", r.session_id}, {"path", r.path}}; + if (r.recursive.has_value()) + j["recursive"] = *r.recursive; + if (r.mode.has_value()) + j["mode"] = *r.mode; +} + +inline void from_json(const json& j, SessionFsMkdirRequest& r) +{ + j.at("sessionId").get_to(r.session_id); + j.at("path").get_to(r.path); + if (j.contains("recursive") && !j.at("recursive").is_null()) + r.recursive = j.at("recursive").get(); + if (j.contains("mode") && !j.at("mode").is_null()) + r.mode = j.at("mode").get(); +} + +struct SessionFsReaddirRequest +{ + std::string session_id; + std::string path; +}; + +inline void to_json(json& j, const SessionFsReaddirRequest& r) +{ + j = json{{"sessionId", r.session_id}, {"path", r.path}}; +} + +inline void from_json(const json& j, SessionFsReaddirRequest& r) +{ + j.at("sessionId").get_to(r.session_id); + j.at("path").get_to(r.path); +} + +struct SessionFsReaddirResult +{ + std::vector entries; + std::optional error; +}; + +inline void to_json(json& j, const SessionFsReaddirResult& r) +{ + j = json{{"entries", r.entries}}; + if (r.error.has_value()) + j["error"] = *r.error; +} + +inline void from_json(const json& j, SessionFsReaddirResult& r) +{ + j.at("entries").get_to(r.entries); + if (j.contains("error") && !j.at("error").is_null()) + r.error = j.at("error").get(); +} + +struct SessionFsRmRequest +{ + std::string session_id; + std::string path; + std::optional recursive; + std::optional force; +}; + +inline void to_json(json& j, const SessionFsRmRequest& r) +{ + j = json{{"sessionId", r.session_id}, {"path", r.path}}; + if (r.recursive.has_value()) + j["recursive"] = *r.recursive; + if (r.force.has_value()) + j["force"] = *r.force; +} + +inline void from_json(const json& j, SessionFsRmRequest& r) +{ + j.at("sessionId").get_to(r.session_id); + j.at("path").get_to(r.path); + if (j.contains("recursive") && !j.at("recursive").is_null()) + r.recursive = j.at("recursive").get(); + if (j.contains("force") && !j.at("force").is_null()) + r.force = j.at("force").get(); +} + +struct SessionFsRenameRequest +{ + std::string session_id; + std::string src; + std::string dest; +}; + +inline void to_json(json& j, const SessionFsRenameRequest& r) +{ + j = json{{"sessionId", r.session_id}, {"src", r.src}, {"dest", r.dest}}; +} + +inline void from_json(const json& j, SessionFsRenameRequest& r) +{ + j.at("sessionId").get_to(r.session_id); + j.at("src").get_to(r.src); + j.at("dest").get_to(r.dest); +} + +// ============================================================================= +// SessionFs handler registration facade. +// +// Mirrors `registerClientSessionApiHandlers` in `generated/rpc.ts`. Hand it a +// fully-populated `SessionFsHandlers` and an `RpcRequestDispatcher` (or a +// raw `JsonRpcClient` via `RpcRequestDispatcher::attach()`); each handler is +// only invoked when set, otherwise the dispatcher reports method-not-found. +// +// NOTE: this is the call-surface shape only. Wiring it into Client (so the +// SDK can act as a session filesystem provider) is the job of a later bucket; +// the bits here are usable today by clients that want to drive the dispatch. +// ============================================================================= + +struct SessionFsHandlers +{ + std::function read_file; + std::function(const SessionFsWriteFileRequest&)> write_file; + std::function(const SessionFsAppendFileRequest&)> append_file; + std::function exists; + std::function stat; + std::function(const SessionFsMkdirRequest&)> mkdir; + std::function readdir; + std::function(const SessionFsRmRequest&)> rm; + std::function(const SessionFsRenameRequest&)> rename; +}; + +namespace detail +{ + +template +inline auto wrap_fs_error_handler(Fn fn) +{ + return [fn = std::move(fn)](const json& params) -> json { + auto req = params.get(); + auto err = fn(req); + if (err.has_value()) + return json(*err); + // Upstream returns `undefined` on success; nlohmann maps that to null. + return json(nullptr); + }; +} + +template +inline auto wrap_value_handler(Fn fn) +{ + return [fn = std::move(fn)](const json& params) -> json { + auto req = params.get(); + Res res = fn(req); + return json(res); + }; +} + +} // namespace detail + +/// Register a SessionFs handler bundle on a `RpcRequestDispatcher`. +/// Only the methods whose `std::function` is populated are wired; the rest +/// remain unregistered so the runtime gets a clean method-not-found. +inline void register_session_fs_handlers( + RpcRequestDispatcher& dispatcher, const SessionFsHandlers& handlers) +{ + using namespace copilot::rpc::methods; + + if (handlers.read_file) + { + dispatcher.on( + kSessionFsReadFile, + detail::wrap_value_handler( + handlers.read_file)); + } + if (handlers.write_file) + { + dispatcher.on( + kSessionFsWriteFile, + detail::wrap_fs_error_handler(handlers.write_file)); + } + if (handlers.append_file) + { + dispatcher.on( + kSessionFsAppendFile, + detail::wrap_fs_error_handler(handlers.append_file)); + } + if (handlers.exists) + { + dispatcher.on( + kSessionFsExists, + detail::wrap_value_handler( + handlers.exists)); + } + if (handlers.stat) + { + dispatcher.on( + kSessionFsStat, + detail::wrap_value_handler(handlers.stat)); + } + if (handlers.mkdir) + { + dispatcher.on( + kSessionFsMkdir, + detail::wrap_fs_error_handler(handlers.mkdir)); + } + if (handlers.readdir) + { + dispatcher.on( + kSessionFsReaddir, + detail::wrap_value_handler( + handlers.readdir)); + } + if (handlers.rm) + { + dispatcher.on( + kSessionFsRm, detail::wrap_fs_error_handler(handlers.rm)); + } + if (handlers.rename) + { + dispatcher.on( + kSessionFsRename, detail::wrap_fs_error_handler(handlers.rename)); + } +} + +} // namespace copilot::rpc diff --git a/src/client.cpp b/src/client.cpp index 2998f01..b0b3f62 100644 --- a/src/client.cpp +++ b/src/client.cpp @@ -3,6 +3,7 @@ #include #include +#include #include #include #include @@ -706,7 +707,7 @@ void Client::connect_to_server() void Client::verify_protocol_version() { - auto response = rpc_->invoke("ping", json{{"message", nullptr}}).get(); + auto response = rpc_->invoke(copilot::rpc::methods::kPing, json{{"message", nullptr}}).get(); if (!response.contains("protocolVersion") || response["protocolVersion"].is_null()) { @@ -759,7 +760,7 @@ std::future> Client::create_session(SessionConfig confi // Build and send request json request = build_session_create_request(config); - auto response = rpc_->invoke("session.create", request).get(); + auto response = rpc_->invoke(copilot::rpc::methods::kSessionCreate, request).get(); std::string session_id = response["sessionId"].get(); // Capture workspace path for infinite sessions @@ -811,7 +812,7 @@ Client::resume_session(const std::string& session_id, ResumeSessionConfig config // Build and send request json request = build_session_resume_request(session_id, config); - auto response = rpc_->invoke("session.resume", request).get(); + auto response = rpc_->invoke(copilot::rpc::methods::kSessionResume, request).get(); std::string returned_session_id = response["sessionId"].get(); // Capture workspace_path if present (for infinite sessions) @@ -870,7 +871,7 @@ std::future> Client::list_sessions(SessionListFilte if (!filter_json.empty()) params["filter"] = std::move(filter_json); - auto response = rpc_->invoke("session.list", params).get(); + auto response = rpc_->invoke(copilot::rpc::methods::kSessionList, params).get(); std::vector sessions; if (response.contains("sessions") && response["sessions"].is_array()) @@ -902,7 +903,7 @@ Client::get_session_metadata(const std::string& session_id) } auto response = - rpc_->invoke("session.getMetadata", json{{"sessionId", session_id}}).get(); + rpc_->invoke(copilot::rpc::methods::kSessionGetMetadata, json{{"sessionId", session_id}}).get(); if (!response.contains("session") || response["session"].is_null()) return std::nullopt; @@ -922,7 +923,7 @@ std::future Client::delete_session(const std::string& session_id) throw std::runtime_error("Client not connected"); auto response = - rpc_->invoke("session.delete", json{{"sessionId", session_id}}).get(); + rpc_->invoke(copilot::rpc::methods::kSessionDelete, json{{"sessionId", session_id}}).get(); if (response.contains("success") && !response["success"].get()) { @@ -952,7 +953,7 @@ std::future> Client::get_last_session_id() throw std::runtime_error("Client not connected. Call start() first."); } - auto response = rpc_->invoke("session.getLastId", json::object()).get(); + auto response = rpc_->invoke(copilot::rpc::methods::kSessionGetLastId, json::object()).get(); if (response.contains("sessionId") && !response["sessionId"].is_null()) return response["sessionId"].get(); @@ -981,7 +982,7 @@ std::future Client::ping(std::optional message) else params["message"] = nullptr; - auto response = rpc_->invoke("ping", params).get(); + auto response = rpc_->invoke(copilot::rpc::methods::kPing, params).get(); PingResponse result; if (response.contains("message") && !response["message"].is_null()) @@ -1009,7 +1010,7 @@ std::future Client::get_status() throw std::runtime_error("Client not connected. Call start() first."); } - auto response = rpc_->invoke("status.get", json::object()).get(); + auto response = rpc_->invoke(copilot::rpc::methods::kStatusGet, json::object()).get(); return response.get(); } ); @@ -1029,7 +1030,7 @@ std::future Client::get_auth_status() throw std::runtime_error("Client not connected. Call start() first."); } - auto response = rpc_->invoke("auth.getStatus", json::object()).get(); + auto response = rpc_->invoke(copilot::rpc::methods::kAuthGetStatus, json::object()).get(); return response.get(); } ); @@ -1071,7 +1072,7 @@ std::future> Client::list_models() throw std::runtime_error("Client not connected. Call start() first."); } - auto response = rpc_->invoke("models.list", json::object()).get(); + auto response = rpc_->invoke(copilot::rpc::methods::kModelsList, json::object()).get(); auto models_response = response.get(); // Store in cache @@ -1302,7 +1303,7 @@ std::future> Client::get_foreground_session_id() std::launch::async, [this]() -> std::optional { - auto response = rpc_client()->invoke("session.getForeground", json::object()).get(); + auto response = rpc_client()->invoke(copilot::rpc::methods::kSessionGetForeground, json::object()).get(); auto parsed = response.get(); return parsed.session_id; } @@ -1315,7 +1316,7 @@ std::future Client::set_foreground_session_id(const std::string& session_i std::launch::async, [this, session_id]() { - rpc_client()->invoke("session.setForeground", json{{"sessionId", session_id}}).get(); + rpc_client()->invoke(copilot::rpc::methods::kSessionSetForeground, json{{"sessionId", session_id}}).get(); } ); } diff --git a/src/session.cpp b/src/session.cpp index 662972a..115ae8c 100644 --- a/src/session.cpp +++ b/src/session.cpp @@ -2,6 +2,7 @@ // SPDX-License-Identifier: MIT #include +#include #include #include @@ -44,7 +45,7 @@ std::future Session::send(MessageOptions options) if (options.mode.has_value()) params["mode"] = *options.mode; - auto response = client_->rpc_client()->invoke("session.send", params).get(); + auto response = client_->rpc_client()->invoke(copilot::rpc::methods::kSessionSend, params).get(); return response["messageId"].get(); } ); @@ -59,7 +60,7 @@ std::future Session::abort() json params; params["sessionId"] = session_id_; - client_->rpc_client()->invoke("session.abort", params).get(); + client_->rpc_client()->invoke(copilot::rpc::methods::kSessionAbort, params).get(); } ); } @@ -73,7 +74,7 @@ std::future> Session::get_messages() json params; params["sessionId"] = session_id_; - auto response = client_->rpc_client()->invoke("session.getMessages", params).get(); + auto response = client_->rpc_client()->invoke(copilot::rpc::methods::kSessionGetMessages, params).get(); std::vector events; if (response.contains("events") && response["events"].is_array()) @@ -381,7 +382,7 @@ std::future Session::destroy() json params; params["sessionId"] = session_id_; - client_->rpc_client()->invoke("session.destroy", params).get(); + client_->rpc_client()->invoke(copilot::rpc::methods::kSessionDestroy, params).get(); } ); } @@ -402,7 +403,7 @@ std::future Session::set_model(const std::string& model_id, SetModelOption if (options.reasoning_effort.has_value()) params["reasoningEffort"] = *options.reasoning_effort; - client_->rpc_client()->invoke("session.model.switchTo", params).get(); + client_->rpc_client()->invoke(copilot::rpc::methods::kSessionModelSwitchTo, params).get(); } ); } @@ -415,7 +416,7 @@ std::future> Session::get_current_model() { json params; params["sessionId"] = session_id_; - auto response = client_->rpc_client()->invoke("session.model.getCurrent", params).get(); + auto response = client_->rpc_client()->invoke(copilot::rpc::methods::kSessionModelGetCurrent, params).get(); // Response: { modelId?: string } per nodejs CurrentModel shape. if (response.contains("modelId") && !response["modelId"].is_null()) return response["modelId"].get(); @@ -455,7 +456,7 @@ std::future Session::set_mode(Mode mode) json params; params["sessionId"] = session_id_; params["mode"] = mode_to_wire(mode); - client_->rpc_client()->invoke("session.mode.set", params).get(); + client_->rpc_client()->invoke(copilot::rpc::methods::kSessionModeSet, params).get(); } ); } @@ -468,7 +469,7 @@ std::future Session::get_mode() { json params; params["sessionId"] = session_id_; - auto response = client_->rpc_client()->invoke("session.mode.get", params).get(); + auto response = client_->rpc_client()->invoke(copilot::rpc::methods::kSessionModeGet, params).get(); // Response shape: { mode: "interactive" | "plan" | "autopilot" } std::string wire = response.contains("mode") && response["mode"].is_string() ? response["mode"].get() diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 43f9283..8ad1d07 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -107,6 +107,19 @@ target_link_libraries(test_tool_builder set_target_properties(test_tool_builder PROPERTIES FOLDER "Tests") +# Test for RPC method catalog and typed parameter/result structs +add_executable(test_rpc_methods + test_rpc_methods.cpp +) + +target_link_libraries(test_rpc_methods + PRIVATE + copilot_sdk_cpp + GTest::gtest_main +) + +set_target_properties(test_rpc_methods PROPERTIES FOLDER "Tests") + include(GoogleTest) gtest_discover_tests(test_types) gtest_discover_tests(test_transport) @@ -115,6 +128,7 @@ gtest_discover_tests(test_process) gtest_discover_tests(test_client_session) gtest_discover_tests(test_e2e) gtest_discover_tests(test_tool_builder) +gtest_discover_tests(test_rpc_methods) # Snapshot conformance tests (optional, requires upstream snapshots + Python) if(COPILOT_BUILD_SNAPSHOT_TESTS) diff --git a/tests/test_rpc_methods.cpp b/tests/test_rpc_methods.cpp new file mode 100644 index 0000000..cc65471 --- /dev/null +++ b/tests/test_rpc_methods.cpp @@ -0,0 +1,454 @@ +// Copyright (c) 2025 Elias Bachaalany +// SPDX-License-Identifier: MIT +// +// Unit tests for the RPC method-name catalog and typed parameter/result +// structs. The wire-string assertions guarantee parity with the upstream +// nodejs generated namespace at `reference/copilot-sdk/nodejs/src/generated/rpc.ts`. + +#include +#include +#include + +#include + +namespace +{ +using namespace copilot::rpc::methods; +} + +// ============================================================================= +// Method-name constants — exact wire strings from generated/rpc.ts +// ============================================================================= + +TEST(RpcMethodsCatalog, TopLevelMethods) +{ + EXPECT_STREQ(kPing, "ping"); + EXPECT_STREQ(kConnect, "connect"); + EXPECT_STREQ(kModelsList, "models.list"); + EXPECT_STREQ(kToolsList, "tools.list"); + EXPECT_STREQ(kAccountGetQuota, "account.getQuota"); + EXPECT_STREQ(kAuthGetStatus, "auth.getStatus"); + EXPECT_STREQ(kStatusGet, "status.get"); +} + +TEST(RpcMethodsCatalog, McpConfig) +{ + EXPECT_STREQ(kMcpConfigList, "mcp.config.list"); + EXPECT_STREQ(kMcpConfigAdd, "mcp.config.add"); + EXPECT_STREQ(kMcpConfigUpdate, "mcp.config.update"); + EXPECT_STREQ(kMcpConfigRemove, "mcp.config.remove"); + EXPECT_STREQ(kMcpConfigEnable, "mcp.config.enable"); + EXPECT_STREQ(kMcpConfigDisable, "mcp.config.disable"); + EXPECT_STREQ(kMcpDiscover, "mcp.discover"); +} + +TEST(RpcMethodsCatalog, SkillsAndFsProvider) +{ + EXPECT_STREQ(kSkillsConfigSetDisabledSkills, "skills.config.setDisabledSkills"); + EXPECT_STREQ(kSkillsDiscover, "skills.discover"); + EXPECT_STREQ(kSessionFsSetProvider, "sessionFs.setProvider"); +} + +TEST(RpcMethodsCatalog, SessionsLifecycle) +{ + EXPECT_STREQ(kSessionsFork, "sessions.fork"); + EXPECT_STREQ(kSessionsConnect, "sessions.connect"); +} + +TEST(RpcMethodsCatalog, SessionFsRequests) +{ + EXPECT_STREQ(kSessionFsReadFile, "sessionFs.readFile"); + EXPECT_STREQ(kSessionFsWriteFile, "sessionFs.writeFile"); + EXPECT_STREQ(kSessionFsAppendFile, "sessionFs.appendFile"); + EXPECT_STREQ(kSessionFsExists, "sessionFs.exists"); + EXPECT_STREQ(kSessionFsStat, "sessionFs.stat"); + EXPECT_STREQ(kSessionFsMkdir, "sessionFs.mkdir"); + EXPECT_STREQ(kSessionFsReaddir, "sessionFs.readdir"); + EXPECT_STREQ(kSessionFsReaddirWithTypes, "sessionFs.readdirWithTypes"); + EXPECT_STREQ(kSessionFsRm, "sessionFs.rm"); + EXPECT_STREQ(kSessionFsRename, "sessionFs.rename"); +} + +TEST(RpcMethodsCatalog, SessionScoped_Core) +{ + EXPECT_STREQ(kSessionSuspend, "session.suspend"); + EXPECT_STREQ(kSessionLog, "session.log"); + EXPECT_STREQ(kSessionAuthGetStatus, "session.auth.getStatus"); +} + +TEST(RpcMethodsCatalog, SessionScoped_Model) +{ + EXPECT_STREQ(kSessionModelGetCurrent, "session.model.getCurrent"); + EXPECT_STREQ(kSessionModelSwitchTo, "session.model.switchTo"); +} + +TEST(RpcMethodsCatalog, SessionScoped_ModeAndName) +{ + EXPECT_STREQ(kSessionModeGet, "session.mode.get"); + EXPECT_STREQ(kSessionModeSet, "session.mode.set"); + EXPECT_STREQ(kSessionNameGet, "session.name.get"); + EXPECT_STREQ(kSessionNameSet, "session.name.set"); +} + +TEST(RpcMethodsCatalog, SessionScoped_Plan) +{ + EXPECT_STREQ(kSessionPlanRead, "session.plan.read"); + EXPECT_STREQ(kSessionPlanUpdate, "session.plan.update"); + EXPECT_STREQ(kSessionPlanDelete, "session.plan.delete"); +} + +TEST(RpcMethodsCatalog, SessionScoped_Workspaces) +{ + EXPECT_STREQ(kSessionWorkspacesGetWorkspace, "session.workspaces.getWorkspace"); + EXPECT_STREQ(kSessionWorkspacesListFiles, "session.workspaces.listFiles"); + EXPECT_STREQ(kSessionWorkspacesReadFile, "session.workspaces.readFile"); + EXPECT_STREQ(kSessionWorkspacesCreateFile, "session.workspaces.createFile"); +} + +TEST(RpcMethodsCatalog, SessionScoped_InstructionsAndFleet) +{ + EXPECT_STREQ(kSessionInstructionsGetSources, "session.instructions.getSources"); + EXPECT_STREQ(kSessionFleetStart, "session.fleet.start"); +} + +TEST(RpcMethodsCatalog, SessionScoped_Agent) +{ + EXPECT_STREQ(kSessionAgentList, "session.agent.list"); + EXPECT_STREQ(kSessionAgentGetCurrent, "session.agent.getCurrent"); + EXPECT_STREQ(kSessionAgentSelect, "session.agent.select"); + EXPECT_STREQ(kSessionAgentDeselect, "session.agent.deselect"); + EXPECT_STREQ(kSessionAgentReload, "session.agent.reload"); +} + +TEST(RpcMethodsCatalog, SessionScoped_Tasks) +{ + EXPECT_STREQ(kSessionTasksStartAgent, "session.tasks.startAgent"); + EXPECT_STREQ(kSessionTasksList, "session.tasks.list"); + EXPECT_STREQ(kSessionTasksPromoteToBackground, "session.tasks.promoteToBackground"); + EXPECT_STREQ(kSessionTasksCancel, "session.tasks.cancel"); + EXPECT_STREQ(kSessionTasksRemove, "session.tasks.remove"); + EXPECT_STREQ(kSessionTasksSendMessage, "session.tasks.sendMessage"); +} + +TEST(RpcMethodsCatalog, SessionScoped_Skills) +{ + EXPECT_STREQ(kSessionSkillsList, "session.skills.list"); + EXPECT_STREQ(kSessionSkillsEnable, "session.skills.enable"); + EXPECT_STREQ(kSessionSkillsDisable, "session.skills.disable"); + EXPECT_STREQ(kSessionSkillsReload, "session.skills.reload"); +} + +TEST(RpcMethodsCatalog, SessionScoped_Mcp) +{ + EXPECT_STREQ(kSessionMcpList, "session.mcp.list"); + EXPECT_STREQ(kSessionMcpEnable, "session.mcp.enable"); + EXPECT_STREQ(kSessionMcpDisable, "session.mcp.disable"); + EXPECT_STREQ(kSessionMcpReload, "session.mcp.reload"); + EXPECT_STREQ(kSessionMcpOauthLogin, "session.mcp.oauth.login"); +} + +TEST(RpcMethodsCatalog, SessionScoped_PluginsExtensions) +{ + EXPECT_STREQ(kSessionPluginsList, "session.plugins.list"); + EXPECT_STREQ(kSessionExtensionsList, "session.extensions.list"); + EXPECT_STREQ(kSessionExtensionsEnable, "session.extensions.enable"); + EXPECT_STREQ(kSessionExtensionsDisable, "session.extensions.disable"); + EXPECT_STREQ(kSessionExtensionsReload, "session.extensions.reload"); +} + +TEST(RpcMethodsCatalog, SessionScoped_Tools) +{ + EXPECT_STREQ(kSessionToolsHandlePendingToolCall, "session.tools.handlePendingToolCall"); +} + +TEST(RpcMethodsCatalog, SessionScoped_Commands) +{ + EXPECT_STREQ(kSessionCommandsList, "session.commands.list"); + EXPECT_STREQ(kSessionCommandsInvoke, "session.commands.invoke"); + EXPECT_STREQ(kSessionCommandsHandlePendingCommand, "session.commands.handlePendingCommand"); + EXPECT_STREQ(kSessionCommandsRespondToQueuedCommand, "session.commands.respondToQueuedCommand"); +} + +TEST(RpcMethodsCatalog, SessionScoped_UiElicitation) +{ + EXPECT_STREQ(kSessionUiElicitation, "session.ui.elicitation"); + EXPECT_STREQ(kSessionUiHandlePendingElicitation, "session.ui.handlePendingElicitation"); +} + +TEST(RpcMethodsCatalog, SessionScoped_Permissions) +{ + EXPECT_STREQ( + kSessionPermissionsHandlePendingPermissionRequest, + "session.permissions.handlePendingPermissionRequest"); + EXPECT_STREQ(kSessionPermissionsSetApproveAll, "session.permissions.setApproveAll"); + EXPECT_STREQ(kSessionPermissionsResetSessionApprovals, "session.permissions.resetSessionApprovals"); +} + +TEST(RpcMethodsCatalog, SessionScoped_Shell) +{ + EXPECT_STREQ(kSessionShellExec, "session.shell.exec"); + EXPECT_STREQ(kSessionShellKill, "session.shell.kill"); +} + +TEST(RpcMethodsCatalog, SessionScoped_HistoryUsageRemote) +{ + EXPECT_STREQ(kSessionHistoryCompact, "session.history.compact"); + EXPECT_STREQ(kSessionHistoryTruncate, "session.history.truncate"); + EXPECT_STREQ(kSessionUsageGetMetrics, "session.usage.getMetrics"); + EXPECT_STREQ(kSessionRemoteEnable, "session.remote.enable"); + EXPECT_STREQ(kSessionRemoteDisable, "session.remote.disable"); +} + +TEST(RpcMethodsCatalog, LegacyV2Aliases) +{ + EXPECT_STREQ(kSessionCreate, "session.create"); + EXPECT_STREQ(kSessionResume, "session.resume"); + EXPECT_STREQ(kSessionList, "session.list"); + EXPECT_STREQ(kSessionGetMetadata, "session.getMetadata"); + EXPECT_STREQ(kSessionDelete, "session.delete"); + EXPECT_STREQ(kSessionGetLastId, "session.getLastId"); + EXPECT_STREQ(kSessionDestroy, "session.destroy"); + EXPECT_STREQ(kSessionSend, "session.send"); + EXPECT_STREQ(kSessionAbort, "session.abort"); + EXPECT_STREQ(kSessionGetMessages, "session.getMessages"); + EXPECT_STREQ(kSessionGetForeground, "session.getForeground"); + EXPECT_STREQ(kSessionSetForeground, "session.setForeground"); +} + +// ============================================================================= +// Typed struct round-trips +// ============================================================================= + +TEST(RpcTypes, NameSetRequestRoundTrip) +{ + copilot::rpc::NameSetRequest req{"my session"}; + copilot::json j = req; + EXPECT_EQ(j.at("name").get(), "my session"); + + auto back = j.get(); + EXPECT_EQ(back.name, "my session"); +} + +TEST(RpcTypes, NameGetResultNullable) +{ + copilot::rpc::NameGetResult r{}; + copilot::json j = r; + EXPECT_TRUE(j.at("name").is_null()); + + r.name = "thing"; + j = r; + EXPECT_EQ(j.at("name").get(), "thing"); + + auto back = j.get(); + ASSERT_TRUE(back.name.has_value()); + EXPECT_EQ(*back.name, "thing"); +} + +TEST(RpcTypes, ModelSwitchToRequestOptional) +{ + copilot::rpc::ModelSwitchToRequest req{"gpt-5", std::string{"medium"}}; + copilot::json j = req; + EXPECT_EQ(j.at("modelId").get(), "gpt-5"); + EXPECT_EQ(j.at("reasoningEffort").get(), "medium"); + + copilot::rpc::ModelSwitchToRequest bare{"gpt-5", std::nullopt}; + j = bare; + EXPECT_FALSE(j.contains("reasoningEffort")); +} + +TEST(RpcTypes, PlanReadResultNullable) +{ + copilot::rpc::PlanReadResult r{}; + copilot::json j = r; + EXPECT_EQ(j.at("exists").get(), false); + EXPECT_TRUE(j.at("content").is_null()); + EXPECT_TRUE(j.at("path").is_null()); + + r.exists = true; + r.content = std::string{"hello"}; + r.path = std::string{"/tmp/plan.md"}; + j = r; + auto back = j.get(); + EXPECT_TRUE(back.exists); + ASSERT_TRUE(back.content.has_value()); + EXPECT_EQ(*back.content, "hello"); + ASSERT_TRUE(back.path.has_value()); + EXPECT_EQ(*back.path, "/tmp/plan.md"); +} + +TEST(RpcTypes, HistoryTruncateRoundTrip) +{ + copilot::rpc::HistoryTruncateRequest req{"evt-123"}; + copilot::json j = req; + EXPECT_EQ(j.at("eventId").get(), "evt-123"); + + copilot::rpc::HistoryTruncateResult res{}; + res.events_removed = 4; + copilot::json jr = res; + EXPECT_EQ(jr.at("eventsRemoved").get(), 4); + + auto back = jr.get(); + EXPECT_EQ(back.events_removed, 4); +} + +TEST(RpcTypes, SessionsForkRoundTrip) +{ + copilot::rpc::SessionsForkRequest req{}; + req.session_id = "src-session"; + req.to_event_id = std::string{"evt-99"}; + req.name = std::string{"fork-1"}; + + copilot::json j = req; + EXPECT_EQ(j.at("sessionId").get(), "src-session"); + EXPECT_EQ(j.at("toEventId").get(), "evt-99"); + EXPECT_EQ(j.at("name").get(), "fork-1"); + + auto back = j.get(); + EXPECT_EQ(back.session_id, "src-session"); + ASSERT_TRUE(back.to_event_id.has_value()); + EXPECT_EQ(*back.to_event_id, "evt-99"); +} + +TEST(RpcTypes, ShellExecOptionalFields) +{ + copilot::rpc::ShellExecRequest req{}; + req.command = "echo hi"; + req.timeout_ms = 5000; + + copilot::json j = req; + EXPECT_EQ(j.at("command").get(), "echo hi"); + EXPECT_FALSE(j.contains("cwd")); + EXPECT_EQ(j.at("timeout").get(), 5000); +} + +TEST(RpcTypes, CommandsListRequestAllOptional) +{ + copilot::rpc::CommandsListRequest empty{}; + copilot::json j = empty; + EXPECT_TRUE(j.is_object()); + EXPECT_EQ(j.size(), 0u); + + copilot::rpc::CommandsListRequest filt{}; + filt.include_builtins = true; + filt.include_client_commands = false; + j = filt; + EXPECT_EQ(j.at("includeBuiltins").get(), true); + EXPECT_EQ(j.at("includeClientCommands").get(), false); + EXPECT_FALSE(j.contains("includeSkills")); +} + +TEST(RpcTypes, SessionFsReadFileRoundTrip) +{ + copilot::rpc::SessionFsReadFileRequest req{"sid-1", "/etc/hosts"}; + copilot::json j = req; + EXPECT_EQ(j.at("sessionId").get(), "sid-1"); + EXPECT_EQ(j.at("path").get(), "/etc/hosts"); + + copilot::rpc::SessionFsReadFileResult res{}; + res.content = "127.0.0.1 localhost"; + copilot::json jr = res; + EXPECT_FALSE(jr.contains("error")); + + res.error = copilot::rpc::SessionFsError{"ENOENT", std::string{"missing"}}; + jr = res; + EXPECT_EQ(jr.at("error").at("code").get(), "ENOENT"); + EXPECT_EQ(jr.at("error").at("message").get(), "missing"); +} + +TEST(RpcTypes, SessionFsStatRoundTrip) +{ + copilot::rpc::SessionFsStatResult res{}; + res.is_file = true; + res.size = 42; + res.mtime = "2025-01-01T00:00:00Z"; + res.birthtime = "2024-12-31T23:59:59Z"; + + copilot::json j = res; + auto back = j.get(); + EXPECT_TRUE(back.is_file); + EXPECT_FALSE(back.is_directory); + EXPECT_EQ(back.size, 42); + EXPECT_EQ(back.mtime, "2025-01-01T00:00:00Z"); +} + +// ============================================================================= +// RpcRequestDispatcher routing +// ============================================================================= + +TEST(RpcRequestDispatcher, DispatchesByMethod) +{ + copilot::rpc::RpcRequestDispatcher d; + bool ping_called = false; + bool other_called = false; + + d.on(kPing, [&](const copilot::json& p) -> copilot::json { + ping_called = true; + return copilot::json{{"echo", p.value("message", "")}}; + }); + d.on(kSessionFsReadFile, [&](const copilot::json&) -> copilot::json { + other_called = true; + return copilot::json{{"content", "x"}}; + }); + + EXPECT_EQ(d.size(), 2u); + EXPECT_TRUE(d.has(kPing)); + EXPECT_FALSE(d.has("unknown.method")); + + auto result = d.dispatch(kPing, copilot::json{{"message", "hello"}}); + EXPECT_TRUE(ping_called); + EXPECT_FALSE(other_called); + EXPECT_EQ(result.at("echo").get(), "hello"); +} + +TEST(RpcRequestDispatcher, UnregisteredMethodThrowsMethodNotFound) +{ + copilot::rpc::RpcRequestDispatcher d; + try + { + d.dispatch("session.does.not.exist", copilot::json::object()); + FAIL() << "expected JsonRpcError"; + } + catch (const copilot::JsonRpcError& e) + { + EXPECT_EQ(e.code(), copilot::JsonRpcErrorCode::MethodNotFound); + } +} + +TEST(RpcRequestDispatcher, OffRemovesHandler) +{ + copilot::rpc::RpcRequestDispatcher d; + d.on(kPing, [](const copilot::json&) { return copilot::json::object(); }); + EXPECT_TRUE(d.has(kPing)); + d.off(kPing); + EXPECT_FALSE(d.has(kPing)); +} + +TEST(RpcRequestDispatcher, SessionFsHandlersFacade) +{ + copilot::rpc::RpcRequestDispatcher d; + copilot::rpc::SessionFsHandlers handlers{}; + handlers.exists = [](const copilot::rpc::SessionFsExistsRequest& req) { + copilot::rpc::SessionFsExistsResult r; + r.exists = (req.path == "/exists"); + return r; + }; + handlers.rm = [](const copilot::rpc::SessionFsRmRequest&) -> std::optional { + return copilot::rpc::SessionFsError{"ENOENT", std::nullopt}; + }; + + copilot::rpc::register_session_fs_handlers(d, handlers); + + // Only the populated handlers should be registered. + EXPECT_TRUE(d.has(kSessionFsExists)); + EXPECT_TRUE(d.has(kSessionFsRm)); + EXPECT_FALSE(d.has(kSessionFsReadFile)); + EXPECT_FALSE(d.has(kSessionFsRename)); + + auto exists_resp = d.dispatch( + kSessionFsExists, copilot::json{{"sessionId", "s"}, {"path", "/exists"}}); + EXPECT_TRUE(exists_resp.at("exists").get()); + + auto rm_resp = d.dispatch( + kSessionFsRm, copilot::json{{"sessionId", "s"}, {"path", "/missing"}}); + EXPECT_EQ(rm_resp.at("code").get(), "ENOENT"); +} From cc7629a3c6b3f4f74a4aa7d7227931f70935920d Mon Sep 17 00:00:00 2001 From: Elias Bachaalany Date: Sat, 16 May 2026 10:11:10 -0700 Subject: [PATCH 09/15] feat(events): add v0.1.49 event variants for full parity Adds 43 new SessionEvent variants to bring events.hpp to schema parity with reference/copilot-sdk@d0eb531e (nodejs/src/generated/session-events.ts). New event types: - Session lifecycle: remote_steerable_changed, title_changed, schedule_created/cancelled, warning, mode_changed, plan_changed, workspace_file_changed, context_changed, task_complete, custom_notification, tools_updated, background_tasks_changed, skills_loaded, custom_agents_updated, mcp_servers_loaded, mcp_server_status_changed, extensions_loaded - Streaming: assistant.streaming_delta, assistant.message_start - Telemetry: model.call_failure - Subagent: subagent.deselected - Interactivity: permission.requested/completed, user_input.requested/completed, elicitation.requested/completed, sampling.requested/completed, mcp.oauth_required/completed, external_tool.requested/completed, command.queued/execute/completed, auto_mode_switch.requested/completed, commands.changed, capabilities.changed, exit_plan_mode.requested/completed, system.notification Also: - Adds optional `agent_id` to SessionEvent envelope (sub-agent identifier present on every upstream event). - Adds WorkingDirectoryContext shared type used by session.context_changed. - Permission/elicitation/system-notification payloads keep their discriminated-union sub-fields (permissionRequest, promptRequest, result, kind, content, requestedSchema) as raw json so callers can branch on inner `kind`/`type` discriminators without forcing a large struct hierarchy. - Unknown-event fallback to `json` is preserved. Tests: 30 new EventsTest cases in tests/test_types.cpp covering at least one parse case per new event family plus agent_id parsing and the unknown-event fallback. --- include/copilot/events.hpp | 1158 ++++++++++++++++++++++++++++++++++++ tests/test_types.cpp | 518 ++++++++++++++++ 2 files changed, 1676 insertions(+) diff --git a/include/copilot/events.hpp b/include/copilot/events.hpp index 25a06e8..920a957 100644 --- a/include/copilot/events.hpp +++ b/include/copilot/events.hpp @@ -56,6 +56,52 @@ struct SystemMessageData; struct SessionSnapshotRewindData; struct SessionShutdownData; struct SkillInvokedData; +// v0.1.49+ additions +struct WorkingDirectoryContext; +struct SessionRemoteSteerableChangedData; +struct SessionTitleChangedData; +struct SessionScheduleCreatedData; +struct SessionScheduleCancelledData; +struct SessionWarningData; +struct SessionModeChangedData; +struct SessionPlanChangedData; +struct SessionWorkspaceFileChangedData; +struct SessionContextChangedData; +struct SessionTaskCompleteData; +struct SessionCustomNotificationData; +struct SessionToolsUpdatedData; +struct SessionBackgroundTasksChangedData; +struct SessionSkillsLoadedData; +struct SessionCustomAgentsUpdatedData; +struct SessionMcpServersLoadedData; +struct SessionMcpServerStatusChangedData; +struct SessionExtensionsLoadedData; +struct AssistantStreamingDeltaData; +struct AssistantMessageStartData; +struct ModelCallFailureData; +struct SubagentDeselectedData; +struct PermissionRequestedData; +struct PermissionCompletedData; +struct UserInputRequestedData; +struct UserInputCompletedData; +struct ElicitationRequestedData; +struct ElicitationCompletedData; +struct SamplingRequestedData; +struct SamplingCompletedData; +struct McpOauthRequiredData; +struct McpOauthCompletedData; +struct ExternalToolRequestedData; +struct ExternalToolCompletedData; +struct CommandQueuedData; +struct CommandExecuteData; +struct CommandCompletedData; +struct AutoModeSwitchRequestedData; +struct AutoModeSwitchCompletedData; +struct CommandsChangedData; +struct CapabilitiesChangedData; +struct ExitPlanModeRequestedData; +struct ExitPlanModeCompletedData; +struct SystemNotificationData; // ============================================================================= // Nested Types @@ -944,6 +990,9 @@ struct SkillInvokedData std::string path; std::string content; std::optional> allowed_tools; + std::optional description; + std::optional plugin_name; + std::optional plugin_version; }; inline void from_json(const json& j, SkillInvokedData& d) @@ -953,6 +1002,844 @@ inline void from_json(const json& j, SkillInvokedData& d) j.at("content").get_to(d.content); if (j.contains("allowedTools") && !j["allowedTools"].is_null()) d.allowed_tools = j.at("allowedTools").get>(); + if (j.contains("description") && !j["description"].is_null()) + d.description = j.at("description").get(); + if (j.contains("pluginName") && !j["pluginName"].is_null()) + d.plugin_name = j.at("pluginName").get(); + if (j.contains("pluginVersion") && !j["pluginVersion"].is_null()) + d.plugin_version = j.at("pluginVersion").get(); +} + +// ============================================================================= +// New Event Data Types (v0.1.49) +// ============================================================================= + +/// Working directory and git context (used by session.start and session.context_changed) +struct WorkingDirectoryContext +{ + std::string cwd; + std::optional base_commit; + std::optional branch; + std::optional git_root; + std::optional head_commit; + std::optional host_type; + std::optional repository; + std::optional repository_host; +}; + +inline void from_json(const json& j, WorkingDirectoryContext& d) +{ + j.at("cwd").get_to(d.cwd); + if (j.contains("baseCommit") && !j["baseCommit"].is_null()) + d.base_commit = j.at("baseCommit").get(); + if (j.contains("branch") && !j["branch"].is_null()) + d.branch = j.at("branch").get(); + if (j.contains("gitRoot") && !j["gitRoot"].is_null()) + d.git_root = j.at("gitRoot").get(); + if (j.contains("headCommit") && !j["headCommit"].is_null()) + d.head_commit = j.at("headCommit").get(); + if (j.contains("hostType") && !j["hostType"].is_null()) + d.host_type = j.at("hostType").get(); + if (j.contains("repository") && !j["repository"].is_null()) + d.repository = j.at("repository").get(); + if (j.contains("repositoryHost") && !j["repositoryHost"].is_null()) + d.repository_host = j.at("repositoryHost").get(); +} + +/// Data for session.remote_steerable_changed event +struct SessionRemoteSteerableChangedData +{ + bool remote_steerable = false; +}; + +inline void from_json(const json& j, SessionRemoteSteerableChangedData& d) +{ + j.at("remoteSteerable").get_to(d.remote_steerable); +} + +/// Data for session.title_changed event +struct SessionTitleChangedData +{ + std::string title; +}; + +inline void from_json(const json& j, SessionTitleChangedData& d) +{ + j.at("title").get_to(d.title); +} + +/// Data for session.schedule_created event +struct SessionScheduleCreatedData +{ + double id = 0; + double interval_ms = 0; + std::string prompt; + std::optional display_prompt; + std::optional recurring; +}; + +inline void from_json(const json& j, SessionScheduleCreatedData& d) +{ + j.at("id").get_to(d.id); + j.at("intervalMs").get_to(d.interval_ms); + j.at("prompt").get_to(d.prompt); + if (j.contains("displayPrompt") && !j["displayPrompt"].is_null()) + d.display_prompt = j.at("displayPrompt").get(); + if (j.contains("recurring") && !j["recurring"].is_null()) + d.recurring = j.at("recurring").get(); +} + +/// Data for session.schedule_cancelled event +struct SessionScheduleCancelledData +{ + double id = 0; +}; + +inline void from_json(const json& j, SessionScheduleCancelledData& d) +{ + j.at("id").get_to(d.id); +} + +/// Data for session.warning event +struct SessionWarningData +{ + std::string warning_type; + std::string message; + std::optional url; +}; + +inline void from_json(const json& j, SessionWarningData& d) +{ + j.at("warningType").get_to(d.warning_type); + j.at("message").get_to(d.message); + if (j.contains("url") && !j["url"].is_null()) + d.url = j.at("url").get(); +} + +/// Data for session.mode_changed event +struct SessionModeChangedData +{ + std::string previous_mode; + std::string new_mode; +}; + +inline void from_json(const json& j, SessionModeChangedData& d) +{ + j.at("previousMode").get_to(d.previous_mode); + j.at("newMode").get_to(d.new_mode); +} + +/// Data for session.plan_changed event +struct SessionPlanChangedData +{ + std::string operation; // "create" | "update" | "delete" +}; + +inline void from_json(const json& j, SessionPlanChangedData& d) +{ + j.at("operation").get_to(d.operation); +} + +/// Data for session.workspace_file_changed event +struct SessionWorkspaceFileChangedData +{ + std::string operation; // "create" | "update" + std::string path; +}; + +inline void from_json(const json& j, SessionWorkspaceFileChangedData& d) +{ + j.at("operation").get_to(d.operation); + j.at("path").get_to(d.path); +} + +/// Data for session.context_changed event (same shape as WorkingDirectoryContext) +struct SessionContextChangedData +{ + WorkingDirectoryContext context; +}; + +inline void from_json(const json& j, SessionContextChangedData& d) +{ + d.context = j.get(); +} + +/// Data for session.task_complete event +struct SessionTaskCompleteData +{ + std::optional success; + std::optional summary; +}; + +inline void from_json(const json& j, SessionTaskCompleteData& d) +{ + if (j.contains("success") && !j["success"].is_null()) + d.success = j.at("success").get(); + if (j.contains("summary") && !j["summary"].is_null()) + d.summary = j.at("summary").get(); +} + +/// Data for session.custom_notification event +struct SessionCustomNotificationData +{ + std::string source; + std::string name; + json payload; + std::optional> subject; + std::optional version; +}; + +inline void from_json(const json& j, SessionCustomNotificationData& d) +{ + j.at("source").get_to(d.source); + j.at("name").get_to(d.name); + d.payload = j.at("payload"); + if (j.contains("subject") && !j["subject"].is_null()) + d.subject = j.at("subject").get>(); + if (j.contains("version") && !j["version"].is_null()) + d.version = j.at("version").get(); +} + +/// Data for session.tools_updated event +struct SessionToolsUpdatedData +{ + std::string model; +}; + +inline void from_json(const json& j, SessionToolsUpdatedData& d) +{ + j.at("model").get_to(d.model); +} + +/// Data for session.background_tasks_changed event (empty payload) +struct SessionBackgroundTasksChangedData +{ +}; + +inline void from_json(const json&, SessionBackgroundTasksChangedData&) {} + +/// Skill metadata in session.skills_loaded +struct SkillsLoadedSkill +{ + std::string name; + std::string description; + bool enabled = false; + std::string source; + bool user_invocable = false; + std::optional path; +}; + +inline void from_json(const json& j, SkillsLoadedSkill& d) +{ + j.at("name").get_to(d.name); + j.at("description").get_to(d.description); + j.at("enabled").get_to(d.enabled); + j.at("source").get_to(d.source); + j.at("userInvocable").get_to(d.user_invocable); + if (j.contains("path") && !j["path"].is_null()) + d.path = j.at("path").get(); +} + +/// Data for session.skills_loaded event +struct SessionSkillsLoadedData +{ + std::vector skills; +}; + +inline void from_json(const json& j, SessionSkillsLoadedData& d) +{ + j.at("skills").get_to(d.skills); +} + +/// Custom agent metadata in session.custom_agents_updated +struct CustomAgentsUpdatedAgent +{ + std::string id; + std::string name; + std::string display_name; + std::string description; + std::string source; + bool user_invocable = false; + std::optional> tools; // null = all tools + std::optional model; +}; + +inline void from_json(const json& j, CustomAgentsUpdatedAgent& d) +{ + j.at("id").get_to(d.id); + j.at("name").get_to(d.name); + j.at("displayName").get_to(d.display_name); + j.at("description").get_to(d.description); + j.at("source").get_to(d.source); + j.at("userInvocable").get_to(d.user_invocable); + if (j.contains("tools") && !j["tools"].is_null()) + d.tools = j.at("tools").get>(); + if (j.contains("model") && !j["model"].is_null()) + d.model = j.at("model").get(); +} + +/// Data for session.custom_agents_updated event +struct SessionCustomAgentsUpdatedData +{ + std::vector agents; + std::vector errors; + std::vector warnings; +}; + +inline void from_json(const json& j, SessionCustomAgentsUpdatedData& d) +{ + j.at("agents").get_to(d.agents); + if (j.contains("errors")) + j.at("errors").get_to(d.errors); + if (j.contains("warnings")) + j.at("warnings").get_to(d.warnings); +} + +/// MCP server entry in session.mcp_servers_loaded +struct McpServersLoadedServer +{ + std::string name; + std::string status; + std::optional error; + std::optional source; +}; + +inline void from_json(const json& j, McpServersLoadedServer& d) +{ + j.at("name").get_to(d.name); + j.at("status").get_to(d.status); + if (j.contains("error") && !j["error"].is_null()) + d.error = j.at("error").get(); + if (j.contains("source") && !j["source"].is_null()) + d.source = j.at("source").get(); +} + +/// Data for session.mcp_servers_loaded event +struct SessionMcpServersLoadedData +{ + std::vector servers; +}; + +inline void from_json(const json& j, SessionMcpServersLoadedData& d) +{ + j.at("servers").get_to(d.servers); +} + +/// Data for session.mcp_server_status_changed event +struct SessionMcpServerStatusChangedData +{ + std::string server_name; + std::string status; +}; + +inline void from_json(const json& j, SessionMcpServerStatusChangedData& d) +{ + j.at("serverName").get_to(d.server_name); + j.at("status").get_to(d.status); +} + +/// Extension entry in session.extensions_loaded +struct ExtensionsLoadedExtension +{ + std::string id; + std::string name; + std::string source; // "project" | "user" + std::string status; // "running" | "disabled" | "failed" | "starting" +}; + +inline void from_json(const json& j, ExtensionsLoadedExtension& d) +{ + j.at("id").get_to(d.id); + j.at("name").get_to(d.name); + j.at("source").get_to(d.source); + j.at("status").get_to(d.status); +} + +/// Data for session.extensions_loaded event +struct SessionExtensionsLoadedData +{ + std::vector extensions; +}; + +inline void from_json(const json& j, SessionExtensionsLoadedData& d) +{ + j.at("extensions").get_to(d.extensions); +} + +/// Data for assistant.streaming_delta event +struct AssistantStreamingDeltaData +{ + double total_response_size_bytes = 0; +}; + +inline void from_json(const json& j, AssistantStreamingDeltaData& d) +{ + j.at("totalResponseSizeBytes").get_to(d.total_response_size_bytes); +} + +/// Data for assistant.message_start event +struct AssistantMessageStartData +{ + std::string message_id; + std::optional phase; +}; + +inline void from_json(const json& j, AssistantMessageStartData& d) +{ + j.at("messageId").get_to(d.message_id); + if (j.contains("phase") && !j["phase"].is_null()) + d.phase = j.at("phase").get(); +} + +/// Data for model.call_failure event +struct ModelCallFailureData +{ + std::string source; // "top_level" | "subagent" | "mcp_sampling" + std::optional api_call_id; + std::optional duration_ms; + std::optional error_message; + std::optional initiator; + std::optional model; + std::optional provider_call_id; + std::optional status_code; +}; + +inline void from_json(const json& j, ModelCallFailureData& d) +{ + j.at("source").get_to(d.source); + if (j.contains("apiCallId") && !j["apiCallId"].is_null()) + d.api_call_id = j.at("apiCallId").get(); + if (j.contains("durationMs") && !j["durationMs"].is_null()) + d.duration_ms = j.at("durationMs").get(); + if (j.contains("errorMessage") && !j["errorMessage"].is_null()) + d.error_message = j.at("errorMessage").get(); + if (j.contains("initiator") && !j["initiator"].is_null()) + d.initiator = j.at("initiator").get(); + if (j.contains("model") && !j["model"].is_null()) + d.model = j.at("model").get(); + if (j.contains("providerCallId") && !j["providerCallId"].is_null()) + d.provider_call_id = j.at("providerCallId").get(); + if (j.contains("statusCode") && !j["statusCode"].is_null()) + d.status_code = j.at("statusCode").get(); +} + +/// Data for subagent.deselected event (empty payload) +struct SubagentDeselectedData +{ +}; + +inline void from_json(const json&, SubagentDeselectedData&) {} + +/// Data for permission.requested event. +/// `permission_request` / `prompt_request` are kept as raw JSON because upstream +/// models them as large discriminated unions (shell/write/read/mcp/url/memory/...). +/// Callers needing the variant data can inspect the JSON via these fields. +struct PermissionRequestedData +{ + std::string request_id; + json permission_request; + std::optional prompt_request; + std::optional resolved_by_hook; +}; + +inline void from_json(const json& j, PermissionRequestedData& d) +{ + j.at("requestId").get_to(d.request_id); + d.permission_request = j.at("permissionRequest"); + if (j.contains("promptRequest")) + d.prompt_request = j.at("promptRequest"); + if (j.contains("resolvedByHook") && !j["resolvedByHook"].is_null()) + d.resolved_by_hook = j.at("resolvedByHook").get(); +} + +/// Data for permission.completed event. +/// `result` is kept as raw JSON (PermissionResult is a large union upstream). +struct PermissionCompletedData +{ + std::string request_id; + json result; + std::optional tool_call_id; +}; + +inline void from_json(const json& j, PermissionCompletedData& d) +{ + j.at("requestId").get_to(d.request_id); + d.result = j.at("result"); + if (j.contains("toolCallId") && !j["toolCallId"].is_null()) + d.tool_call_id = j.at("toolCallId").get(); +} + +/// Data for user_input.requested event +struct UserInputRequestedData +{ + std::string request_id; + std::string question; + std::optional allow_freeform; + std::optional> choices; + std::optional tool_call_id; +}; + +inline void from_json(const json& j, UserInputRequestedData& d) +{ + j.at("requestId").get_to(d.request_id); + j.at("question").get_to(d.question); + if (j.contains("allowFreeform") && !j["allowFreeform"].is_null()) + d.allow_freeform = j.at("allowFreeform").get(); + if (j.contains("choices") && !j["choices"].is_null()) + d.choices = j.at("choices").get>(); + if (j.contains("toolCallId") && !j["toolCallId"].is_null()) + d.tool_call_id = j.at("toolCallId").get(); +} + +/// Data for user_input.completed event +struct UserInputCompletedData +{ + std::string request_id; + std::optional answer; + std::optional was_freeform; +}; + +inline void from_json(const json& j, UserInputCompletedData& d) +{ + j.at("requestId").get_to(d.request_id); + if (j.contains("answer") && !j["answer"].is_null()) + d.answer = j.at("answer").get(); + if (j.contains("wasFreeform") && !j["wasFreeform"].is_null()) + d.was_freeform = j.at("wasFreeform").get(); +} + +/// Data for elicitation.requested event. +/// `requested_schema` kept as raw JSON (callers can inspect it on demand). +struct ElicitationRequestedData +{ + std::string request_id; + std::string message; + std::optional mode; // "form" | "url" + std::optional elicitation_source; + std::optional tool_call_id; + std::optional url; + std::optional requested_schema; +}; + +inline void from_json(const json& j, ElicitationRequestedData& d) +{ + j.at("requestId").get_to(d.request_id); + j.at("message").get_to(d.message); + if (j.contains("mode") && !j["mode"].is_null()) + d.mode = j.at("mode").get(); + if (j.contains("elicitationSource") && !j["elicitationSource"].is_null()) + d.elicitation_source = j.at("elicitationSource").get(); + if (j.contains("toolCallId") && !j["toolCallId"].is_null()) + d.tool_call_id = j.at("toolCallId").get(); + if (j.contains("url") && !j["url"].is_null()) + d.url = j.at("url").get(); + if (j.contains("requestedSchema")) + d.requested_schema = j.at("requestedSchema"); +} + +/// Data for elicitation.completed event. +/// `content` kept as raw JSON because per-field values can be string|number|bool|string[]. +struct ElicitationCompletedData +{ + std::string request_id; + std::optional action; // "accept" | "decline" | "cancel" + std::optional content; +}; + +inline void from_json(const json& j, ElicitationCompletedData& d) +{ + j.at("requestId").get_to(d.request_id); + if (j.contains("action") && !j["action"].is_null()) + d.action = j.at("action").get(); + if (j.contains("content")) + d.content = j.at("content"); +} + +/// Data for sampling.requested event +struct SamplingRequestedData +{ + std::string request_id; + std::string server_name; + json mcp_request_id; // string | number +}; + +inline void from_json(const json& j, SamplingRequestedData& d) +{ + j.at("requestId").get_to(d.request_id); + j.at("serverName").get_to(d.server_name); + d.mcp_request_id = j.at("mcpRequestId"); +} + +/// Data for sampling.completed event +struct SamplingCompletedData +{ + std::string request_id; +}; + +inline void from_json(const json& j, SamplingCompletedData& d) +{ + j.at("requestId").get_to(d.request_id); +} + +/// Static OAuth client config (mcp.oauth_required nested) +struct McpOauthRequiredStaticClientConfig +{ + std::string client_id; + std::optional grant_type; + std::optional public_client; +}; + +inline void from_json(const json& j, McpOauthRequiredStaticClientConfig& d) +{ + j.at("clientId").get_to(d.client_id); + if (j.contains("grantType") && !j["grantType"].is_null()) + d.grant_type = j.at("grantType").get(); + if (j.contains("publicClient") && !j["publicClient"].is_null()) + d.public_client = j.at("publicClient").get(); +} + +/// Data for mcp.oauth_required event +struct McpOauthRequiredData +{ + std::string request_id; + std::string server_name; + std::string server_url; + std::optional static_client_config; +}; + +inline void from_json(const json& j, McpOauthRequiredData& d) +{ + j.at("requestId").get_to(d.request_id); + j.at("serverName").get_to(d.server_name); + j.at("serverUrl").get_to(d.server_url); + if (j.contains("staticClientConfig") && !j["staticClientConfig"].is_null()) + d.static_client_config = j.at("staticClientConfig").get(); +} + +/// Data for mcp.oauth_completed event +struct McpOauthCompletedData +{ + std::string request_id; +}; + +inline void from_json(const json& j, McpOauthCompletedData& d) +{ + j.at("requestId").get_to(d.request_id); +} + +/// Data for external_tool.requested event +struct ExternalToolRequestedData +{ + std::string request_id; + std::string session_id; + std::string tool_call_id; + std::string tool_name; + std::optional arguments; + std::optional traceparent; + std::optional tracestate; +}; + +inline void from_json(const json& j, ExternalToolRequestedData& d) +{ + j.at("requestId").get_to(d.request_id); + j.at("sessionId").get_to(d.session_id); + j.at("toolCallId").get_to(d.tool_call_id); + j.at("toolName").get_to(d.tool_name); + if (j.contains("arguments")) + d.arguments = j.at("arguments"); + if (j.contains("traceparent") && !j["traceparent"].is_null()) + d.traceparent = j.at("traceparent").get(); + if (j.contains("tracestate") && !j["tracestate"].is_null()) + d.tracestate = j.at("tracestate").get(); +} + +/// Data for external_tool.completed event +struct ExternalToolCompletedData +{ + std::string request_id; +}; + +inline void from_json(const json& j, ExternalToolCompletedData& d) +{ + j.at("requestId").get_to(d.request_id); +} + +/// Data for command.queued event +struct CommandQueuedData +{ + std::string request_id; + std::string command; +}; + +inline void from_json(const json& j, CommandQueuedData& d) +{ + j.at("requestId").get_to(d.request_id); + j.at("command").get_to(d.command); +} + +/// Data for command.execute event +struct CommandExecuteData +{ + std::string request_id; + std::string command; + std::string command_name; + std::string args; +}; + +inline void from_json(const json& j, CommandExecuteData& d) +{ + j.at("requestId").get_to(d.request_id); + j.at("command").get_to(d.command); + j.at("commandName").get_to(d.command_name); + j.at("args").get_to(d.args); +} + +/// Data for command.completed event +struct CommandCompletedData +{ + std::string request_id; +}; + +inline void from_json(const json& j, CommandCompletedData& d) +{ + j.at("requestId").get_to(d.request_id); +} + +/// Data for auto_mode_switch.requested event +struct AutoModeSwitchRequestedData +{ + std::string request_id; + std::optional error_code; + std::optional retry_after_seconds; +}; + +inline void from_json(const json& j, AutoModeSwitchRequestedData& d) +{ + j.at("requestId").get_to(d.request_id); + if (j.contains("errorCode") && !j["errorCode"].is_null()) + d.error_code = j.at("errorCode").get(); + if (j.contains("retryAfterSeconds") && !j["retryAfterSeconds"].is_null()) + d.retry_after_seconds = j.at("retryAfterSeconds").get(); +} + +/// Data for auto_mode_switch.completed event +struct AutoModeSwitchCompletedData +{ + std::string request_id; + std::string response; // "yes" | "yes_always" | "no" +}; + +inline void from_json(const json& j, AutoModeSwitchCompletedData& d) +{ + j.at("requestId").get_to(d.request_id); + j.at("response").get_to(d.response); +} + +/// SDK command entry in commands.changed +struct CommandsChangedCommand +{ + std::string name; + std::optional description; +}; + +inline void from_json(const json& j, CommandsChangedCommand& d) +{ + j.at("name").get_to(d.name); + if (j.contains("description") && !j["description"].is_null()) + d.description = j.at("description").get(); +} + +/// Data for commands.changed event +struct CommandsChangedData +{ + std::vector commands; +}; + +inline void from_json(const json& j, CommandsChangedData& d) +{ + j.at("commands").get_to(d.commands); +} + +/// UI capability changes (capabilities.changed nested) +struct CapabilitiesChangedUI +{ + std::optional elicitation; +}; + +inline void from_json(const json& j, CapabilitiesChangedUI& d) +{ + if (j.contains("elicitation") && !j["elicitation"].is_null()) + d.elicitation = j.at("elicitation").get(); +} + +/// Data for capabilities.changed event +struct CapabilitiesChangedData +{ + std::optional ui; +}; + +inline void from_json(const json& j, CapabilitiesChangedData& d) +{ + if (j.contains("ui") && !j["ui"].is_null()) + d.ui = j.at("ui").get(); +} + +/// Data for exit_plan_mode.requested event +struct ExitPlanModeRequestedData +{ + std::string request_id; + std::string plan_content; + std::string summary; + std::string recommended_action; + std::vector actions; +}; + +inline void from_json(const json& j, ExitPlanModeRequestedData& d) +{ + j.at("requestId").get_to(d.request_id); + j.at("planContent").get_to(d.plan_content); + j.at("summary").get_to(d.summary); + j.at("recommendedAction").get_to(d.recommended_action); + j.at("actions").get_to(d.actions); +} + +/// Data for exit_plan_mode.completed event +struct ExitPlanModeCompletedData +{ + std::string request_id; + std::optional approved; + std::optional auto_approve_edits; + std::optional feedback; + std::optional selected_action; +}; + +inline void from_json(const json& j, ExitPlanModeCompletedData& d) +{ + j.at("requestId").get_to(d.request_id); + if (j.contains("approved") && !j["approved"].is_null()) + d.approved = j.at("approved").get(); + if (j.contains("autoApproveEdits") && !j["autoApproveEdits"].is_null()) + d.auto_approve_edits = j.at("autoApproveEdits").get(); + if (j.contains("feedback") && !j["feedback"].is_null()) + d.feedback = j.at("feedback").get(); + if (j.contains("selectedAction") && !j["selectedAction"].is_null()) + d.selected_action = j.at("selectedAction").get(); +} + +/// Data for system.notification event. +/// `kind` is one of several variants discriminated by an inner `type` field +/// (agent_completed/agent_idle/new_inbox_message/shell_completed/...). +/// We keep it as raw JSON so consumers can branch on `kind.type` themselves. +struct SystemNotificationData +{ + std::string content; + json kind; +}; + +inline void from_json(const json& j, SystemNotificationData& d) +{ + j.at("content").get_to(d.content); + d.kind = j.at("kind"); } // ============================================================================= @@ -999,6 +1886,51 @@ enum class SessionEventType SessionSnapshotRewind, SessionShutdown, SkillInvoked, + // v0.1.49+ additions + SessionRemoteSteerableChanged, + SessionTitleChanged, + SessionScheduleCreated, + SessionScheduleCancelled, + SessionWarning, + SessionModeChanged, + SessionPlanChanged, + SessionWorkspaceFileChanged, + SessionContextChanged, + SessionTaskComplete, + SessionCustomNotification, + SessionToolsUpdated, + SessionBackgroundTasksChanged, + SessionSkillsLoaded, + SessionCustomAgentsUpdated, + SessionMcpServersLoaded, + SessionMcpServerStatusChanged, + SessionExtensionsLoaded, + AssistantStreamingDelta, + AssistantMessageStart, + ModelCallFailure, + SubagentDeselected, + PermissionRequested, + PermissionCompleted, + UserInputRequested, + UserInputCompleted, + ElicitationRequested, + ElicitationCompleted, + SamplingRequested, + SamplingCompleted, + McpOauthRequired, + McpOauthCompleted, + ExternalToolRequested, + ExternalToolCompleted, + CommandQueued, + CommandExecute, + CommandCompleted, + AutoModeSwitchRequested, + AutoModeSwitchCompleted, + CommandsChanged, + CapabilitiesChanged, + ExitPlanModeRequested, + ExitPlanModeCompleted, + SystemNotification, Unknown }; @@ -1041,6 +1973,51 @@ using SessionEventData = std::variant< SessionSnapshotRewindData, SessionShutdownData, SkillInvokedData, + // v0.1.49+ additions + SessionRemoteSteerableChangedData, + SessionTitleChangedData, + SessionScheduleCreatedData, + SessionScheduleCancelledData, + SessionWarningData, + SessionModeChangedData, + SessionPlanChangedData, + SessionWorkspaceFileChangedData, + SessionContextChangedData, + SessionTaskCompleteData, + SessionCustomNotificationData, + SessionToolsUpdatedData, + SessionBackgroundTasksChangedData, + SessionSkillsLoadedData, + SessionCustomAgentsUpdatedData, + SessionMcpServersLoadedData, + SessionMcpServerStatusChangedData, + SessionExtensionsLoadedData, + AssistantStreamingDeltaData, + AssistantMessageStartData, + ModelCallFailureData, + SubagentDeselectedData, + PermissionRequestedData, + PermissionCompletedData, + UserInputRequestedData, + UserInputCompletedData, + ElicitationRequestedData, + ElicitationCompletedData, + SamplingRequestedData, + SamplingCompletedData, + McpOauthRequiredData, + McpOauthCompletedData, + ExternalToolRequestedData, + ExternalToolCompletedData, + CommandQueuedData, + CommandExecuteData, + CommandCompletedData, + AutoModeSwitchRequestedData, + AutoModeSwitchCompletedData, + CommandsChangedData, + CapabilitiesChangedData, + ExitPlanModeRequestedData, + ExitPlanModeCompletedData, + SystemNotificationData, json // Unknown event fallback >; @@ -1051,6 +2028,7 @@ struct SessionEvent std::string timestamp; // ISO 8601 std::optional parent_id; std::optional ephemeral; + std::optional agent_id; // sub-agent instance identifier SessionEventType type; std::string type_string; // Original type string for unknown events SessionEventData data; @@ -1089,6 +2067,8 @@ inline SessionEvent parse_session_event(const json& j) event.parent_id = j.at("parentId").get(); if (j.contains("ephemeral")) event.ephemeral = j.at("ephemeral").get(); + if (j.contains("agentId") && !j.at("agentId").is_null()) + event.agent_id = j.at("agentId").get(); // Parse type and data event.type_string = j.at("type").get(); @@ -1137,6 +2117,51 @@ inline SessionEvent parse_session_event(const json& j) {"session.snapshot_rewind", SessionEventType::SessionSnapshotRewind}, {"session.shutdown", SessionEventType::SessionShutdown}, {"skill.invoked", SessionEventType::SkillInvoked}, + // v0.1.49+ additions + {"session.remote_steerable_changed", SessionEventType::SessionRemoteSteerableChanged}, + {"session.title_changed", SessionEventType::SessionTitleChanged}, + {"session.schedule_created", SessionEventType::SessionScheduleCreated}, + {"session.schedule_cancelled", SessionEventType::SessionScheduleCancelled}, + {"session.warning", SessionEventType::SessionWarning}, + {"session.mode_changed", SessionEventType::SessionModeChanged}, + {"session.plan_changed", SessionEventType::SessionPlanChanged}, + {"session.workspace_file_changed", SessionEventType::SessionWorkspaceFileChanged}, + {"session.context_changed", SessionEventType::SessionContextChanged}, + {"session.task_complete", SessionEventType::SessionTaskComplete}, + {"session.custom_notification", SessionEventType::SessionCustomNotification}, + {"session.tools_updated", SessionEventType::SessionToolsUpdated}, + {"session.background_tasks_changed", SessionEventType::SessionBackgroundTasksChanged}, + {"session.skills_loaded", SessionEventType::SessionSkillsLoaded}, + {"session.custom_agents_updated", SessionEventType::SessionCustomAgentsUpdated}, + {"session.mcp_servers_loaded", SessionEventType::SessionMcpServersLoaded}, + {"session.mcp_server_status_changed", SessionEventType::SessionMcpServerStatusChanged}, + {"session.extensions_loaded", SessionEventType::SessionExtensionsLoaded}, + {"assistant.streaming_delta", SessionEventType::AssistantStreamingDelta}, + {"assistant.message_start", SessionEventType::AssistantMessageStart}, + {"model.call_failure", SessionEventType::ModelCallFailure}, + {"subagent.deselected", SessionEventType::SubagentDeselected}, + {"permission.requested", SessionEventType::PermissionRequested}, + {"permission.completed", SessionEventType::PermissionCompleted}, + {"user_input.requested", SessionEventType::UserInputRequested}, + {"user_input.completed", SessionEventType::UserInputCompleted}, + {"elicitation.requested", SessionEventType::ElicitationRequested}, + {"elicitation.completed", SessionEventType::ElicitationCompleted}, + {"sampling.requested", SessionEventType::SamplingRequested}, + {"sampling.completed", SessionEventType::SamplingCompleted}, + {"mcp.oauth_required", SessionEventType::McpOauthRequired}, + {"mcp.oauth_completed", SessionEventType::McpOauthCompleted}, + {"external_tool.requested", SessionEventType::ExternalToolRequested}, + {"external_tool.completed", SessionEventType::ExternalToolCompleted}, + {"command.queued", SessionEventType::CommandQueued}, + {"command.execute", SessionEventType::CommandExecute}, + {"command.completed", SessionEventType::CommandCompleted}, + {"auto_mode_switch.requested", SessionEventType::AutoModeSwitchRequested}, + {"auto_mode_switch.completed", SessionEventType::AutoModeSwitchCompleted}, + {"commands.changed", SessionEventType::CommandsChanged}, + {"capabilities.changed", SessionEventType::CapabilitiesChanged}, + {"exit_plan_mode.requested", SessionEventType::ExitPlanModeRequested}, + {"exit_plan_mode.completed", SessionEventType::ExitPlanModeCompleted}, + {"system.notification", SessionEventType::SystemNotification}, }; auto it = type_map.find(event.type_string); @@ -1258,6 +2283,139 @@ inline SessionEvent parse_session_event(const json& j) case SessionEventType::SkillInvoked: event.data = data_json.get(); break; + // v0.1.49+ additions + case SessionEventType::SessionRemoteSteerableChanged: + event.data = data_json.get(); + break; + case SessionEventType::SessionTitleChanged: + event.data = data_json.get(); + break; + case SessionEventType::SessionScheduleCreated: + event.data = data_json.get(); + break; + case SessionEventType::SessionScheduleCancelled: + event.data = data_json.get(); + break; + case SessionEventType::SessionWarning: + event.data = data_json.get(); + break; + case SessionEventType::SessionModeChanged: + event.data = data_json.get(); + break; + case SessionEventType::SessionPlanChanged: + event.data = data_json.get(); + break; + case SessionEventType::SessionWorkspaceFileChanged: + event.data = data_json.get(); + break; + case SessionEventType::SessionContextChanged: + event.data = data_json.get(); + break; + case SessionEventType::SessionTaskComplete: + event.data = data_json.get(); + break; + case SessionEventType::SessionCustomNotification: + event.data = data_json.get(); + break; + case SessionEventType::SessionToolsUpdated: + event.data = data_json.get(); + break; + case SessionEventType::SessionBackgroundTasksChanged: + event.data = data_json.get(); + break; + case SessionEventType::SessionSkillsLoaded: + event.data = data_json.get(); + break; + case SessionEventType::SessionCustomAgentsUpdated: + event.data = data_json.get(); + break; + case SessionEventType::SessionMcpServersLoaded: + event.data = data_json.get(); + break; + case SessionEventType::SessionMcpServerStatusChanged: + event.data = data_json.get(); + break; + case SessionEventType::SessionExtensionsLoaded: + event.data = data_json.get(); + break; + case SessionEventType::AssistantStreamingDelta: + event.data = data_json.get(); + break; + case SessionEventType::AssistantMessageStart: + event.data = data_json.get(); + break; + case SessionEventType::ModelCallFailure: + event.data = data_json.get(); + break; + case SessionEventType::SubagentDeselected: + event.data = data_json.get(); + break; + case SessionEventType::PermissionRequested: + event.data = data_json.get(); + break; + case SessionEventType::PermissionCompleted: + event.data = data_json.get(); + break; + case SessionEventType::UserInputRequested: + event.data = data_json.get(); + break; + case SessionEventType::UserInputCompleted: + event.data = data_json.get(); + break; + case SessionEventType::ElicitationRequested: + event.data = data_json.get(); + break; + case SessionEventType::ElicitationCompleted: + event.data = data_json.get(); + break; + case SessionEventType::SamplingRequested: + event.data = data_json.get(); + break; + case SessionEventType::SamplingCompleted: + event.data = data_json.get(); + break; + case SessionEventType::McpOauthRequired: + event.data = data_json.get(); + break; + case SessionEventType::McpOauthCompleted: + event.data = data_json.get(); + break; + case SessionEventType::ExternalToolRequested: + event.data = data_json.get(); + break; + case SessionEventType::ExternalToolCompleted: + event.data = data_json.get(); + break; + case SessionEventType::CommandQueued: + event.data = data_json.get(); + break; + case SessionEventType::CommandExecute: + event.data = data_json.get(); + break; + case SessionEventType::CommandCompleted: + event.data = data_json.get(); + break; + case SessionEventType::AutoModeSwitchRequested: + event.data = data_json.get(); + break; + case SessionEventType::AutoModeSwitchCompleted: + event.data = data_json.get(); + break; + case SessionEventType::CommandsChanged: + event.data = data_json.get(); + break; + case SessionEventType::CapabilitiesChanged: + event.data = data_json.get(); + break; + case SessionEventType::ExitPlanModeRequested: + event.data = data_json.get(); + break; + case SessionEventType::ExitPlanModeCompleted: + event.data = data_json.get(); + break; + case SessionEventType::SystemNotification: + event.data = data_json.get(); + break; default: event.data = data_json; // Fallback to raw JSON break; diff --git a/tests/test_types.cpp b/tests/test_types.cpp index 37db58d..61da113 100644 --- a/tests/test_types.cpp +++ b/tests/test_types.cpp @@ -1994,3 +1994,521 @@ TEST(SessionSetModelOptionsTest, DefaultsEmpty) Session::SetModelOptions opts; EXPECT_FALSE(opts.reasoning_effort.has_value()); } + +// ============================================================================= +// New Event Variants (v0.1.49 parity) +// ============================================================================= + +namespace +{ +json make_event_envelope(const char* type, json data) +{ + return json{ + {"id", "evt_test"}, + {"timestamp", "2025-01-15T10:00:00Z"}, + {"parentId", nullptr}, + {"type", type}, + {"data", std::move(data)}, + }; +} +} // namespace + +TEST(EventsTest, SessionRemoteSteerableChanged) +{ + auto input = make_event_envelope("session.remote_steerable_changed", {{"remoteSteerable", true}}); + auto event = input.get(); + EXPECT_EQ(event.type, SessionEventType::SessionRemoteSteerableChanged); + EXPECT_TRUE(event.as().remote_steerable); +} + +TEST(EventsTest, SessionTitleChanged) +{ + auto input = make_event_envelope("session.title_changed", {{"title", "My session"}}); + auto event = input.get(); + EXPECT_EQ(event.type, SessionEventType::SessionTitleChanged); + EXPECT_EQ(event.as().title, "My session"); +} + +TEST(EventsTest, SessionScheduleCreatedAndCancelled) +{ + auto created = make_event_envelope( + "session.schedule_created", + {{"id", 1}, {"intervalMs", 5000}, {"prompt", "ping"}, {"recurring", true}} + ); + auto e1 = created.get(); + EXPECT_EQ(e1.type, SessionEventType::SessionScheduleCreated); + const auto& cd = e1.as(); + EXPECT_EQ(cd.id, 1); + EXPECT_EQ(cd.prompt, "ping"); + EXPECT_TRUE(cd.recurring.has_value() && *cd.recurring); + + auto cancelled = make_event_envelope("session.schedule_cancelled", {{"id", 1}}); + auto e2 = cancelled.get(); + EXPECT_EQ(e2.type, SessionEventType::SessionScheduleCancelled); + EXPECT_EQ(e2.as().id, 1); +} + +TEST(EventsTest, SessionWarning) +{ + auto input = make_event_envelope( + "session.warning", + {{"warningType", "policy"}, {"message", "policy violation"}, {"url", "https://example/help"}} + ); + auto event = input.get(); + EXPECT_EQ(event.type, SessionEventType::SessionWarning); + const auto& d = event.as(); + EXPECT_EQ(d.warning_type, "policy"); + EXPECT_EQ(*d.url, "https://example/help"); +} + +TEST(EventsTest, SessionModeChanged) +{ + auto input = make_event_envelope( + "session.mode_changed", {{"previousMode", "interactive"}, {"newMode", "plan"}} + ); + auto event = input.get(); + EXPECT_EQ(event.type, SessionEventType::SessionModeChanged); + const auto& d = event.as(); + EXPECT_EQ(d.previous_mode, "interactive"); + EXPECT_EQ(d.new_mode, "plan"); +} + +TEST(EventsTest, SessionPlanAndWorkspaceFileChanged) +{ + auto plan = make_event_envelope("session.plan_changed", {{"operation", "update"}}); + auto e1 = plan.get(); + EXPECT_EQ(e1.type, SessionEventType::SessionPlanChanged); + EXPECT_EQ(e1.as().operation, "update"); + + auto wf = make_event_envelope( + "session.workspace_file_changed", {{"operation", "create"}, {"path", "notes.md"}} + ); + auto e2 = wf.get(); + EXPECT_EQ(e2.type, SessionEventType::SessionWorkspaceFileChanged); + EXPECT_EQ(e2.as().path, "notes.md"); +} + +TEST(EventsTest, SessionContextChanged) +{ + auto input = make_event_envelope( + "session.context_changed", + {{"cwd", "/tmp/repo"}, {"branch", "main"}, {"repository", "owner/repo"}} + ); + auto event = input.get(); + EXPECT_EQ(event.type, SessionEventType::SessionContextChanged); + const auto& d = event.as(); + EXPECT_EQ(d.context.cwd, "/tmp/repo"); + EXPECT_EQ(*d.context.branch, "main"); +} + +TEST(EventsTest, SessionTaskComplete) +{ + auto input = make_event_envelope( + "session.task_complete", {{"success", true}, {"summary", "ok"}} + ); + auto event = input.get(); + EXPECT_EQ(event.type, SessionEventType::SessionTaskComplete); + EXPECT_TRUE(*event.as().success); +} + +TEST(EventsTest, SessionCustomNotification) +{ + auto input = make_event_envelope( + "session.custom_notification", + {{"source", "ext.foo"}, {"name", "ping"}, {"payload", {{"k", 1}}}, {"version", 2}} + ); + auto event = input.get(); + EXPECT_EQ(event.type, SessionEventType::SessionCustomNotification); + const auto& d = event.as(); + EXPECT_EQ(d.source, "ext.foo"); + EXPECT_EQ(d.name, "ping"); + EXPECT_EQ(d.payload["k"], 1); +} + +TEST(EventsTest, SessionToolsUpdatedAndBackgroundTasks) +{ + auto tools = make_event_envelope("session.tools_updated", {{"model", "gpt-4"}}); + auto e1 = tools.get(); + EXPECT_EQ(e1.type, SessionEventType::SessionToolsUpdated); + EXPECT_EQ(e1.as().model, "gpt-4"); + + auto bg = make_event_envelope("session.background_tasks_changed", json::object()); + auto e2 = bg.get(); + EXPECT_EQ(e2.type, SessionEventType::SessionBackgroundTasksChanged); +} + +TEST(EventsTest, SessionSkillsLoaded) +{ + json skill = { + {"name", "pdf"}, + {"description", "PDF helper"}, + {"enabled", true}, + {"source", "plugin"}, + {"userInvocable", true}, + {"path", "/skills/pdf"}, + }; + auto input = make_event_envelope("session.skills_loaded", {{"skills", json::array({skill})}}); + auto event = input.get(); + EXPECT_EQ(event.type, SessionEventType::SessionSkillsLoaded); + const auto& d = event.as(); + ASSERT_EQ(d.skills.size(), 1u); + EXPECT_EQ(d.skills[0].name, "pdf"); + EXPECT_TRUE(d.skills[0].enabled); +} + +TEST(EventsTest, SessionCustomAgentsUpdated) +{ + json agent = { + {"id", "ag1"}, + {"name", "reviewer"}, + {"displayName", "Reviewer"}, + {"description", "reviews code"}, + {"source", "project"}, + {"userInvocable", true}, + {"tools", json::array({"read"})}, + }; + auto input = make_event_envelope( + "session.custom_agents_updated", + {{"agents", json::array({agent})}, {"errors", json::array()}, {"warnings", json::array()}} + ); + auto event = input.get(); + EXPECT_EQ(event.type, SessionEventType::SessionCustomAgentsUpdated); + const auto& d = event.as(); + ASSERT_EQ(d.agents.size(), 1u); + EXPECT_EQ(d.agents[0].id, "ag1"); + ASSERT_TRUE(d.agents[0].tools.has_value()); + EXPECT_EQ(d.agents[0].tools->at(0), "read"); +} + +TEST(EventsTest, SessionMcpServersAndStatus) +{ + json srv = {{"name", "github"}, {"status", "connected"}, {"source", "user"}}; + auto loaded = make_event_envelope( + "session.mcp_servers_loaded", {{"servers", json::array({srv})}} + ); + auto e1 = loaded.get(); + EXPECT_EQ(e1.type, SessionEventType::SessionMcpServersLoaded); + EXPECT_EQ(e1.as().servers.at(0).status, "connected"); + + auto chg = make_event_envelope( + "session.mcp_server_status_changed", {{"serverName", "github"}, {"status", "needs-auth"}} + ); + auto e2 = chg.get(); + EXPECT_EQ(e2.type, SessionEventType::SessionMcpServerStatusChanged); + EXPECT_EQ(e2.as().status, "needs-auth"); +} + +TEST(EventsTest, SessionExtensionsLoaded) +{ + json ext = {{"id", "user:foo"}, {"name", "foo"}, {"source", "user"}, {"status", "running"}}; + auto input = make_event_envelope( + "session.extensions_loaded", {{"extensions", json::array({ext})}} + ); + auto event = input.get(); + EXPECT_EQ(event.type, SessionEventType::SessionExtensionsLoaded); + EXPECT_EQ(event.as().extensions.at(0).status, "running"); +} + +TEST(EventsTest, AssistantStreamingDeltaAndMessageStart) +{ + auto sd = make_event_envelope("assistant.streaming_delta", {{"totalResponseSizeBytes", 1234}}); + auto e1 = sd.get(); + EXPECT_EQ(e1.type, SessionEventType::AssistantStreamingDelta); + EXPECT_EQ(e1.as().total_response_size_bytes, 1234); + + auto ms = make_event_envelope( + "assistant.message_start", {{"messageId", "msg_1"}, {"phase", "response"}} + ); + auto e2 = ms.get(); + EXPECT_EQ(e2.type, SessionEventType::AssistantMessageStart); + EXPECT_EQ(e2.as().message_id, "msg_1"); + EXPECT_EQ(*e2.as().phase, "response"); +} + +TEST(EventsTest, ModelCallFailure) +{ + auto input = make_event_envelope( + "model.call_failure", + {{"source", "top_level"}, + {"model", "gpt-4"}, + {"statusCode", 500}, + {"errorMessage", "boom"}} + ); + auto event = input.get(); + EXPECT_EQ(event.type, SessionEventType::ModelCallFailure); + const auto& d = event.as(); + EXPECT_EQ(d.source, "top_level"); + EXPECT_EQ(*d.status_code, 500); + EXPECT_EQ(*d.error_message, "boom"); +} + +TEST(EventsTest, SubagentDeselected) +{ + auto input = make_event_envelope("subagent.deselected", json::object()); + auto event = input.get(); + EXPECT_EQ(event.type, SessionEventType::SubagentDeselected); + EXPECT_TRUE(event.is()); +} + +TEST(EventsTest, PermissionRequestedAndCompleted) +{ + auto req = make_event_envelope( + "permission.requested", + {{"requestId", "p1"}, + {"permissionRequest", + {{"kind", "shell"}, + {"fullCommandText", "ls"}, + {"intention", "list"}, + {"canOfferSessionApproval", true}, + {"commands", json::array()}, + {"possiblePaths", json::array()}, + {"possibleUrls", json::array()}, + {"hasWriteFileRedirection", false}}}} + ); + auto e1 = req.get(); + EXPECT_EQ(e1.type, SessionEventType::PermissionRequested); + EXPECT_EQ(e1.as().request_id, "p1"); + EXPECT_EQ(e1.as().permission_request["kind"], "shell"); + + auto comp = make_event_envelope( + "permission.completed", {{"requestId", "p1"}, {"result", {{"kind", "approved"}}}} + ); + auto e2 = comp.get(); + EXPECT_EQ(e2.type, SessionEventType::PermissionCompleted); + EXPECT_EQ(e2.as().result["kind"], "approved"); +} + +TEST(EventsTest, UserInputRequestedAndCompleted) +{ + auto req = make_event_envelope( + "user_input.requested", + {{"requestId", "u1"}, + {"question", "Pick"}, + {"choices", json::array({"a", "b"})}, + {"allowFreeform", true}} + ); + auto e1 = req.get(); + EXPECT_EQ(e1.type, SessionEventType::UserInputRequested); + EXPECT_EQ(e1.as().question, "Pick"); + EXPECT_TRUE(*e1.as().allow_freeform); + + auto comp = make_event_envelope( + "user_input.completed", {{"requestId", "u1"}, {"answer", "a"}, {"wasFreeform", false}} + ); + auto e2 = comp.get(); + EXPECT_EQ(e2.type, SessionEventType::UserInputCompleted); + EXPECT_EQ(*e2.as().answer, "a"); +} + +TEST(EventsTest, ElicitationRequestedAndCompleted) +{ + auto req = make_event_envelope( + "elicitation.requested", + {{"requestId", "e1"}, + {"message", "Need details"}, + {"mode", "form"}, + {"requestedSchema", + {{"type", "object"}, + {"properties", {{"name", {{"type", "string"}}}}}, + {"required", json::array({"name"})}}}} + ); + auto e1 = req.get(); + EXPECT_EQ(e1.type, SessionEventType::ElicitationRequested); + const auto& d1 = e1.as(); + EXPECT_EQ(d1.request_id, "e1"); + EXPECT_EQ(*d1.mode, "form"); + ASSERT_TRUE(d1.requested_schema.has_value()); + EXPECT_EQ((*d1.requested_schema)["type"], "object"); + + auto comp = make_event_envelope( + "elicitation.completed", + {{"requestId", "e1"}, {"action", "accept"}, {"content", {{"name", "x"}}}} + ); + auto e2 = comp.get(); + EXPECT_EQ(e2.type, SessionEventType::ElicitationCompleted); + EXPECT_EQ(*e2.as().action, "accept"); +} + +TEST(EventsTest, SamplingRequestedAndCompleted) +{ + auto req = make_event_envelope( + "sampling.requested", + {{"requestId", "s1"}, {"serverName", "github"}, {"mcpRequestId", 42}} + ); + auto e1 = req.get(); + EXPECT_EQ(e1.type, SessionEventType::SamplingRequested); + EXPECT_EQ(e1.as().server_name, "github"); + + auto comp = make_event_envelope("sampling.completed", {{"requestId", "s1"}}); + auto e2 = comp.get(); + EXPECT_EQ(e2.type, SessionEventType::SamplingCompleted); + EXPECT_EQ(e2.as().request_id, "s1"); +} + +TEST(EventsTest, McpOauthRequiredAndCompleted) +{ + auto req = make_event_envelope( + "mcp.oauth_required", + {{"requestId", "o1"}, + {"serverName", "github"}, + {"serverUrl", "https://example/mcp"}, + {"staticClientConfig", {{"clientId", "cid"}, {"publicClient", true}}}} + ); + auto e1 = req.get(); + EXPECT_EQ(e1.type, SessionEventType::McpOauthRequired); + const auto& d1 = e1.as(); + EXPECT_EQ(d1.server_url, "https://example/mcp"); + ASSERT_TRUE(d1.static_client_config.has_value()); + EXPECT_EQ(d1.static_client_config->client_id, "cid"); + + auto comp = make_event_envelope("mcp.oauth_completed", {{"requestId", "o1"}}); + auto e2 = comp.get(); + EXPECT_EQ(e2.type, SessionEventType::McpOauthCompleted); +} + +TEST(EventsTest, ExternalToolRequestedAndCompleted) +{ + auto req = make_event_envelope( + "external_tool.requested", + {{"requestId", "x1"}, + {"sessionId", "sess"}, + {"toolCallId", "tc1"}, + {"toolName", "my_tool"}, + {"arguments", {{"a", 1}}}} + ); + auto e1 = req.get(); + EXPECT_EQ(e1.type, SessionEventType::ExternalToolRequested); + EXPECT_EQ(e1.as().tool_name, "my_tool"); + + auto comp = make_event_envelope("external_tool.completed", {{"requestId", "x1"}}); + auto e2 = comp.get(); + EXPECT_EQ(e2.type, SessionEventType::ExternalToolCompleted); +} + +TEST(EventsTest, CommandQueuedExecuteCompleted) +{ + auto q = make_event_envelope( + "command.queued", {{"requestId", "c1"}, {"command", "/help"}} + ); + auto e1 = q.get(); + EXPECT_EQ(e1.type, SessionEventType::CommandQueued); + EXPECT_EQ(e1.as().command, "/help"); + + auto ex = make_event_envelope( + "command.execute", + {{"requestId", "c2"}, {"command", "/deploy prod"}, {"commandName", "deploy"}, {"args", "prod"}} + ); + auto e2 = ex.get(); + EXPECT_EQ(e2.type, SessionEventType::CommandExecute); + EXPECT_EQ(e2.as().command_name, "deploy"); + + auto comp = make_event_envelope("command.completed", {{"requestId", "c1"}}); + auto e3 = comp.get(); + EXPECT_EQ(e3.type, SessionEventType::CommandCompleted); +} + +TEST(EventsTest, AutoModeSwitchRequestedAndCompleted) +{ + auto req = make_event_envelope( + "auto_mode_switch.requested", + {{"requestId", "a1"}, {"errorCode", "rate_limited"}, {"retryAfterSeconds", 60}} + ); + auto e1 = req.get(); + EXPECT_EQ(e1.type, SessionEventType::AutoModeSwitchRequested); + EXPECT_EQ(*e1.as().retry_after_seconds, 60); + + auto comp = make_event_envelope( + "auto_mode_switch.completed", {{"requestId", "a1"}, {"response", "yes"}} + ); + auto e2 = comp.get(); + EXPECT_EQ(e2.type, SessionEventType::AutoModeSwitchCompleted); + EXPECT_EQ(e2.as().response, "yes"); +} + +TEST(EventsTest, CommandsChangedAndCapabilitiesChanged) +{ + auto cc = make_event_envelope( + "commands.changed", + {{"commands", + json::array({{{"name", "deploy"}, {"description", "Deploy"}}, {{"name", "rollback"}}})}} + ); + auto e1 = cc.get(); + EXPECT_EQ(e1.type, SessionEventType::CommandsChanged); + const auto& d1 = e1.as(); + ASSERT_EQ(d1.commands.size(), 2u); + EXPECT_EQ(d1.commands[0].name, "deploy"); + EXPECT_FALSE(d1.commands[1].description.has_value()); + + auto caps = make_event_envelope( + "capabilities.changed", {{"ui", {{"elicitation", true}}}} + ); + auto e2 = caps.get(); + EXPECT_EQ(e2.type, SessionEventType::CapabilitiesChanged); + ASSERT_TRUE(e2.as().ui.has_value()); + EXPECT_TRUE(*e2.as().ui->elicitation); +} + +TEST(EventsTest, ExitPlanModeRequestedAndCompleted) +{ + auto req = make_event_envelope( + "exit_plan_mode.requested", + {{"requestId", "pm1"}, + {"planContent", "1. do it\n2. profit"}, + {"summary", "Plan"}, + {"recommendedAction", "approve"}, + {"actions", json::array({"approve", "edit", "reject"})}} + ); + auto e1 = req.get(); + EXPECT_EQ(e1.type, SessionEventType::ExitPlanModeRequested); + EXPECT_EQ(e1.as().actions.size(), 3u); + + auto comp = make_event_envelope( + "exit_plan_mode.completed", + {{"requestId", "pm1"}, {"approved", true}, {"selectedAction", "autopilot"}} + ); + auto e2 = comp.get(); + EXPECT_EQ(e2.type, SessionEventType::ExitPlanModeCompleted); + EXPECT_TRUE(*e2.as().approved); +} + +TEST(EventsTest, SystemNotificationParsesKindRaw) +{ + auto input = make_event_envelope( + "system.notification", + {{"content", "..."}, + {"kind", + {{"type", "agent_completed"}, + {"agentId", "ag1"}, + {"agentType", "task"}, + {"status", "completed"}}}} + ); + auto event = input.get(); + EXPECT_EQ(event.type, SessionEventType::SystemNotification); + const auto& d = event.as(); + EXPECT_EQ(d.kind["type"], "agent_completed"); + EXPECT_EQ(d.kind["status"], "completed"); +} + +TEST(EventsTest, AgentIdParsedOnSessionEvent) +{ + json input = { + {"id", "evt_agent"}, + {"timestamp", "2025-01-15T10:00:00Z"}, + {"agentId", "subagent_42"}, + {"type", "session.idle"}, + {"data", json::object()}, + }; + auto event = input.get(); + ASSERT_TRUE(event.agent_id.has_value()); + EXPECT_EQ(*event.agent_id, "subagent_42"); +} + +TEST(EventsTest, UnknownEventStillFallsBack) +{ + auto input = make_event_envelope("totally.unknown_v999", {{"foo", "bar"}}); + auto event = input.get(); + EXPECT_EQ(event.type, SessionEventType::Unknown); + EXPECT_EQ(event.type_string, "totally.unknown_v999"); + EXPECT_TRUE(event.is()); + EXPECT_EQ(event.as()["foo"], "bar"); +} From c2208e1aa9e3bf305e4dd0d313873540392e1c10 Mon Sep 17 00:00:00 2001 From: Elias Bachaalany Date: Sat, 16 May 2026 10:24:08 -0700 Subject: [PATCH 10/15] test(v0149): add focused unit tests + examples for v0.1.49 features Augments the existing test/example suites with offline-only coverage for surfaces added during the v0.1.49 sync cycle. No existing tests were modified or removed; all 377 prior ctest cases continue to pass. New tests (tests/test_v0149_features.cpp, 26 cases): * ClientOptions::tcp_connection_token validation - empty string rejected - stdio + explicit token rejected - auto-UUID generated path does not throw in TCP mode - explicit token accepted in TCP mode - external server (cli_url) bypasses token requirement - RFC-4122 v4 shape regex sanity * Tool flag wiring through build_session_create_request / build_session_resume_request: - skipPermission + overridesBuiltInTool serialize per-tool when set - flags are omitted when false * ResumeSessionConfig v0.1.49 fields: - omitted from resume payload by default - clientName / enableSessionTelemetry / includeSubAgentStreamingEvents / enableConfigDiscovery / instructionDirectories / remoteSession all serialize when set * RemoteSessionMode edge cases: - all three known values round-trip via session.create payload - unknown wire value falls back to first-listed enumerator * Session::dispatch_event end-to-end typed-variant routing: - session.title_changed routes to SessionTitleChangedData - session.warning + model.call_failure both route to typed variants - multiple handlers fire in registration order - Subscription destructor removes handler - handler exception does not break other subscribers - SessionWarningData carries warning_type / message after dispatch * Typed RPC struct round-trips (gaps left by test_rpc_methods.cpp): - ModeSetRequest / ModeGetResult - ModelSwitchToResult nullable modelId - HistoryCompactResult - PermissionsSetApproveAllRequest / Result - PlanUpdateRequest * SessionStartData baseline parse + WorkingDirectoryContext all-fields parse (used by session.start and session.context_changed). New examples (compile-only demos, no live CLI required): * examples/instruction_directories.cpp -- mirrors upstream PR #1190. Builds a SessionConfig populated with per-session instruction directories and dumps the resulting session.create payload via the public build_session_create_request helper. * examples/remote_session.cpp -- mirrors upstream PR #1295. Walks through the three RemoteSessionMode values (off / export / on) and prints the resulting session.create payload for each. Both examples are registered in examples/CMakeLists.txt and link against copilot_sdk_cpp. Final ctest: 403 passed / 0 failed (was 377). --- examples/CMakeLists.txt | 10 + examples/instruction_directories.cpp | 55 +++ examples/remote_session.cpp | 72 ++++ tests/CMakeLists.txt | 14 + tests/test_v0149_features.cpp | 525 +++++++++++++++++++++++++++ 5 files changed, 676 insertions(+) create mode 100644 examples/instruction_directories.cpp create mode 100644 examples/remote_session.cpp create mode 100644 tests/test_v0149_features.cpp diff --git a/examples/CMakeLists.txt b/examples/CMakeLists.txt index e95341b..887354e 100644 --- a/examples/CMakeLists.txt +++ b/examples/CMakeLists.txt @@ -84,3 +84,13 @@ set_target_properties(user_input PROPERTIES FOLDER "Examples") add_executable(reasoning_effort reasoning_effort.cpp) target_link_libraries(reasoning_effort PRIVATE copilot_sdk_cpp) set_target_properties(reasoning_effort PROPERTIES FOLDER "Examples") + +# Instruction directories example (v0.1.49 - PR #1190) +add_executable(instruction_directories instruction_directories.cpp) +target_link_libraries(instruction_directories PRIVATE copilot_sdk_cpp) +set_target_properties(instruction_directories PROPERTIES FOLDER "Examples") + +# Remote session example (v0.1.49 - PR #1295, Mission Control integration) +add_executable(remote_session remote_session.cpp) +target_link_libraries(remote_session PRIVATE copilot_sdk_cpp) +set_target_properties(remote_session PROPERTIES FOLDER "Examples") diff --git a/examples/instruction_directories.cpp b/examples/instruction_directories.cpp new file mode 100644 index 0000000..b911d72 --- /dev/null +++ b/examples/instruction_directories.cpp @@ -0,0 +1,55 @@ +// Copyright (c) 2025 Elias Bachaalany +// SPDX-License-Identifier: MIT + +/// @file instruction_directories.cpp +/// @brief Compile-only demo for the v0.1.49 `instructionDirectories` field on +/// `SessionConfig` and `ResumeSessionConfig`. +/// +/// Builds a `SessionConfig` populated with per-session instruction directories +/// and dumps the resulting `session.create` request payload that the SDK would +/// send to the Copilot CLI. No network or CLI is required: this example +/// exercises the public `build_session_create_request` helper so the API is +/// exercised at build time and the JSON envelope is human-inspectable. +/// +/// Background: instructionDirectories (upstream nodejs PR #1190) lets a host +/// application supplement the global instruction set with additional +/// directories scoped to a single session — useful for ephemeral or +/// workspace-specific guidance without mutating the user's CLI config. + +#include + +#include + +int main() +{ + using namespace copilot; + + SessionConfig cfg; + cfg.client_name = "instruction-dirs-demo"; + cfg.instruction_directories = std::vector{ + "/etc/copilot/instructions", + "./workspace/.copilot/instructions", + }; + cfg.enable_config_discovery = true; + cfg.streaming = false; + + json request = build_session_create_request(cfg); + + std::cout << "session.create request payload:\n"; + std::cout << request.dump(2) << "\n"; + + // Sanity-check the fields actually round-tripped through the builder. + if (!request.contains("instructionDirectories") || + !request["instructionDirectories"].is_array() || + request["instructionDirectories"].size() != 2) + { + std::cerr << "instructionDirectories field missing or malformed\n"; + return 1; + } + if (request.value("clientName", std::string{}) != "instruction-dirs-demo") + { + std::cerr << "clientName missing\n"; + return 1; + } + return 0; +} diff --git a/examples/remote_session.cpp b/examples/remote_session.cpp new file mode 100644 index 0000000..f892dc9 --- /dev/null +++ b/examples/remote_session.cpp @@ -0,0 +1,72 @@ +// Copyright (c) 2025 Elias Bachaalany +// SPDX-License-Identifier: MIT + +/// @file remote_session.cpp +/// @brief Compile-only demo for the v0.1.49 `remoteSession` field on +/// `SessionConfig` (Mission Control integration, upstream nodejs PR #1295). +/// +/// Demonstrates the three `RemoteSessionMode` values and shows how the +/// resulting `session.create` payload differs. No live Copilot CLI is +/// required: the example uses the public `build_session_create_request` +/// helper to render each request payload for inspection. +/// +/// Remote-session modes (matches upstream nodejs): +/// * `Off` — explicitly disable remote steering for this session. +/// * `Export` — export this session so it shows up in Mission Control +/// without accepting remote commands. +/// * `On` — enable full remote steering from GitHub web / mobile. + +#include + +#include +#include +#include + +namespace +{ + +const char* mode_name(copilot::RemoteSessionMode mode) +{ + using copilot::RemoteSessionMode; + switch (mode) + { + case RemoteSessionMode::Off: return "off"; + case RemoteSessionMode::Export: return "export"; + case RemoteSessionMode::On: return "on"; + } + return "?"; +} + +} // namespace + +int main() +{ + using namespace copilot; + + const std::vector modes = { + RemoteSessionMode::Off, + RemoteSessionMode::Export, + RemoteSessionMode::On, + }; + + for (auto mode : modes) + { + SessionConfig cfg; + cfg.client_name = "remote-session-demo"; + cfg.remote_session = mode; + cfg.enable_session_telemetry = (mode != RemoteSessionMode::Off); + + json request = build_session_create_request(cfg); + + std::cout << "[mode=" << mode_name(mode) << "] session.create payload:\n"; + std::cout << request.dump(2) << "\n\n"; + + if (!request.contains("remoteSession")) + { + std::cerr << "remoteSession field missing for mode " << mode_name(mode) << "\n"; + return 1; + } + } + + return 0; +} diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 8ad1d07..f318e92 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -120,6 +120,19 @@ target_link_libraries(test_rpc_methods set_target_properties(test_rpc_methods PROPERTIES FOLDER "Tests") +# Focused tests for v0.1.49 parity additions (augments other suites) +add_executable(test_v0149_features + test_v0149_features.cpp +) + +target_link_libraries(test_v0149_features + PRIVATE + copilot_sdk_cpp + GTest::gtest_main +) + +set_target_properties(test_v0149_features PROPERTIES FOLDER "Tests") + include(GoogleTest) gtest_discover_tests(test_types) gtest_discover_tests(test_transport) @@ -129,6 +142,7 @@ gtest_discover_tests(test_client_session) gtest_discover_tests(test_e2e) gtest_discover_tests(test_tool_builder) gtest_discover_tests(test_rpc_methods) +gtest_discover_tests(test_v0149_features) # Snapshot conformance tests (optional, requires upstream snapshots + Python) if(COPILOT_BUILD_SNAPSHOT_TESTS) diff --git a/tests/test_v0149_features.cpp b/tests/test_v0149_features.cpp new file mode 100644 index 0000000..98c7858 --- /dev/null +++ b/tests/test_v0149_features.cpp @@ -0,0 +1,525 @@ +// Copyright (c) 2025 Elias Bachaalany +// SPDX-License-Identifier: MIT + +/// @file test_v0149_features.cpp +/// @brief Focused unit tests for v0.1.49 parity additions. +/// +/// Mirrors gaps left by the v0.1.49 sync cycle that existing test files do +/// not cover. Augments — does not replace — `test_types.cpp`, +/// `test_client_session.cpp`, and `test_rpc_methods.cpp`. +/// +/// Covered surfaces (offline-only; no live CLI required): +/// * `ClientOptions::tcp_connection_token` validation + auto-UUID generation. +/// * `Tool::skip_permission` / `Tool::overrides_built_in_tool` request wiring +/// through `build_session_create_request` and `build_session_resume_request`. +/// * `Session::dispatch_event` end-to-end typed-variant routing for new +/// v0.1.49 event variants delivered through the public `Session::on()` API. +/// * Round-trip serialization for typed RPC parameter/result structs added +/// by the v3 generated namespace (mode/model/history/permissions/plan). +/// * `ResumeSessionConfig` v0.1.49 fields are omitted from the resume +/// request payload by default. +/// * `RemoteSessionMode` JSON edge cases (unknown value falls back, all +/// three known values round-trip via the session.create payload). +/// * `SessionStartData` baseline parse for v0.1.49 fields. + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +using namespace copilot; + +// ============================================================================= +// Helpers +// ============================================================================= + +namespace +{ + +/// Build an event envelope around a typed data payload, matching the wire +/// shape produced by the CLI. Mirrors the helper in `test_types.cpp` but +/// lives in this TU so the two files stay independent. +json envelope(const char* type, json data) +{ + return json{ + {"id", "evt_v0149"}, + {"timestamp", "2025-01-15T10:00:00Z"}, + {"parentId", nullptr}, + {"type", type}, + {"data", std::move(data)}, + }; +} + +bool looks_like_uuid_v4(const std::string& s) +{ + // 8-4-4-4-12 hex; version nibble must be 4 in the 3rd group; variant + // nibble in the 4th group must be 8/9/a/b. Matches RFC 4122. + static const std::regex re( + "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$"); + return std::regex_match(s, re); +} + +} // namespace + +// ============================================================================= +// ClientOptions::tcp_connection_token +// ============================================================================= + +TEST(TcpConnectionTokenValidation, EmptyStringRejected) +{ + ClientOptions opts; + opts.use_stdio = false; + opts.port = 8765; + opts.tcp_connection_token = std::string{}; + EXPECT_THROW(Client client(opts), std::invalid_argument); +} + +TEST(TcpConnectionTokenValidation, StdioWithExplicitTokenRejected) +{ + ClientOptions opts; + opts.use_stdio = true; + opts.tcp_connection_token = "deadbeef"; + EXPECT_THROW(Client client(opts), std::invalid_argument); +} + +TEST(TcpConnectionTokenValidation, AutoUuidGeneratedForTcpServer) +{ + ClientOptions opts; + opts.use_stdio = false; + opts.port = 8765; + // Token intentionally left empty so the constructor synthesizes one. + Client client(opts); + // The constructor stores the populated options internally; we re-issue + // construction via the public API surface and assert through a second + // instance that the token would have been generated. The public Client + // does not expose the mutated options, so we re-create with a wrapper + // that captures the token from the options copy used to construct. + // + // We use an indirect probe: building two clients back-to-back should both + // succeed and the token field on the local copy stays unset (the + // constructor takes ClientOptions by value, so `opts` here is unchanged). + EXPECT_FALSE(opts.tcp_connection_token.has_value()); + // Client construction did not throw, which is the primary property under + // test: the auto-UUID path must not reject an unset token in TCP mode. + EXPECT_EQ(client.state(), ConnectionState::Disconnected); +} + +TEST(TcpConnectionTokenValidation, AutoUuidUuidShapeIsRfc4122) +{ + // The auto-generation code path is internal; verify the regex used to + // recognize UUIDs accepts a freshly generated UUID v4 of the kind the + // SDK creates. This guards against regressions in the regex itself. + EXPECT_TRUE(looks_like_uuid_v4("550e8400-e29b-41d4-a716-446655440000")); + EXPECT_TRUE(looks_like_uuid_v4("00000000-0000-4000-8000-000000000000")); + EXPECT_FALSE(looks_like_uuid_v4("not-a-uuid")); + EXPECT_FALSE(looks_like_uuid_v4("00000000-0000-3000-8000-000000000000")); // wrong version +} + +TEST(TcpConnectionTokenValidation, ExplicitTokenAcceptedForTcpServer) +{ + ClientOptions opts; + opts.use_stdio = false; + opts.port = 8765; + opts.tcp_connection_token = "my-explicit-token-1234567890"; + // Should not throw; explicit non-empty token is the documented path. + Client client(opts); + EXPECT_EQ(client.state(), ConnectionState::Disconnected); +} + +TEST(TcpConnectionTokenValidation, ExternalServerNoTokenIsValid) +{ + ClientOptions opts; + opts.cli_url = "localhost:9090"; + opts.use_stdio = false; + // No explicit token; cli_url means we connect to an external server, so + // no token validation applies (and no auto-generation either). + Client client(opts); + EXPECT_EQ(client.state(), ConnectionState::Disconnected); +} + +// ============================================================================= +// Tool flag wiring through build_session_create_request / build_session_resume_request +// ============================================================================= + +TEST(ToolFlagsRequestWiring, CreateRequestEmitsSkipPermissionAndOverride) +{ + SessionConfig cfg; + Tool t1{}; + t1.name = "fast_tool"; + t1.description = "needs no approval"; + t1.skip_permission = true; + Tool t2{}; + t2.name = "ls"; + t2.description = "override built-in ls"; + t2.overrides_built_in_tool = true; + Tool t3{}; + t3.name = "plain"; + t3.description = "no flags"; + cfg.tools = {t1, t2, t3}; + + auto req = build_session_create_request(cfg); + ASSERT_TRUE(req.contains("tools")); + ASSERT_TRUE(req["tools"].is_array()); + ASSERT_EQ(req["tools"].size(), 3u); + + // t1: skipPermission set, overridesBuiltInTool absent + EXPECT_EQ(req["tools"][0]["name"], "fast_tool"); + ASSERT_TRUE(req["tools"][0].contains("skipPermission")); + EXPECT_TRUE(req["tools"][0]["skipPermission"].get()); + EXPECT_FALSE(req["tools"][0].contains("overridesBuiltInTool")); + + // t2: overridesBuiltInTool set, skipPermission absent + EXPECT_EQ(req["tools"][1]["name"], "ls"); + ASSERT_TRUE(req["tools"][1].contains("overridesBuiltInTool")); + EXPECT_TRUE(req["tools"][1]["overridesBuiltInTool"].get()); + EXPECT_FALSE(req["tools"][1].contains("skipPermission")); + + // t3: both flags absent + EXPECT_EQ(req["tools"][2]["name"], "plain"); + EXPECT_FALSE(req["tools"][2].contains("skipPermission")); + EXPECT_FALSE(req["tools"][2].contains("overridesBuiltInTool")); +} + +TEST(ToolFlagsRequestWiring, ResumeRequestEmitsSkipPermissionAndOverride) +{ + ResumeSessionConfig cfg; + Tool t{}; + t.name = "deploy"; + t.description = "production deploy"; + t.skip_permission = true; + t.overrides_built_in_tool = true; + cfg.tools = {t}; + + auto req = build_session_resume_request("sess-x", cfg); + ASSERT_TRUE(req.contains("tools")); + ASSERT_EQ(req["tools"].size(), 1u); + EXPECT_TRUE(req["tools"][0]["skipPermission"].get()); + EXPECT_TRUE(req["tools"][0]["overridesBuiltInTool"].get()); +} + +// ============================================================================= +// ResumeSessionConfig v0.1.49 fields omitted by default +// ============================================================================= + +TEST(ResumeSessionConfigV0149, FieldsOmittedByDefault) +{ + ResumeSessionConfig cfg; + auto req = build_session_resume_request("sess-1", cfg); + EXPECT_FALSE(req.contains("clientName")); + EXPECT_FALSE(req.contains("enableSessionTelemetry")); + EXPECT_FALSE(req.contains("includeSubAgentStreamingEvents")); + EXPECT_FALSE(req.contains("enableConfigDiscovery")); + EXPECT_FALSE(req.contains("instructionDirectories")); + EXPECT_FALSE(req.contains("remoteSession")); +} + +TEST(ResumeSessionConfigV0149, AllNewFieldsSerialize) +{ + ResumeSessionConfig cfg; + cfg.client_name = "resumer"; + cfg.enable_session_telemetry = true; + cfg.include_sub_agent_streaming_events = true; + cfg.enable_config_discovery = false; + cfg.instruction_directories = std::vector{"/instr/a", "/instr/b"}; + cfg.remote_session = RemoteSessionMode::Off; + + auto req = build_session_resume_request("sess-1", cfg); + EXPECT_EQ(req["clientName"], "resumer"); + EXPECT_TRUE(req["enableSessionTelemetry"].get()); + EXPECT_TRUE(req["includeSubAgentStreamingEvents"].get()); + EXPECT_FALSE(req["enableConfigDiscovery"].get()); + ASSERT_TRUE(req["instructionDirectories"].is_array()); + EXPECT_EQ(req["instructionDirectories"].size(), 2u); + EXPECT_EQ(req["instructionDirectories"][1], "/instr/b"); + EXPECT_EQ(req["remoteSession"], "off"); +} + +// ============================================================================= +// RemoteSessionMode JSON edge cases +// ============================================================================= + +TEST(RemoteSessionModeEdge, AllThreeKnownValuesThroughCreateRequest) +{ + for (auto mode : {RemoteSessionMode::Off, RemoteSessionMode::Export, RemoteSessionMode::On}) + { + SessionConfig cfg; + cfg.remote_session = mode; + auto req = build_session_create_request(cfg); + ASSERT_TRUE(req.contains("remoteSession")); + // Echo the wire mapping for completeness. + json expected = mode; + EXPECT_EQ(req["remoteSession"], expected); + } +} + +TEST(RemoteSessionModeEdge, UnknownWireValueFallsBack) +{ + // NLOHMANN_JSON_SERIALIZE_ENUM falls back to the first-listed value when + // the wire string is unrecognized. Document and lock that behavior. + EXPECT_EQ(json("bogus").get(), RemoteSessionMode::Off); +} + +// ============================================================================= +// Session::dispatch_event end-to-end typed-variant routing +// ============================================================================= + +TEST(SessionDispatch, RoutesV0149TitleChangedEventToTypedHandler) +{ + auto session = std::make_shared("sess-disp-1", /*client=*/nullptr); + + std::string captured_title; + SessionEventType captured_type = SessionEventType::Unknown; + auto sub = session->on( + [&](const SessionEvent& e) + { + captured_type = e.type; + if (const auto* d = e.try_as()) + captured_title = d->title; + }); + + auto event = envelope("session.title_changed", {{"title", "dispatched"}}).get(); + session->dispatch_event(event); + + EXPECT_EQ(captured_type, SessionEventType::SessionTitleChanged); + EXPECT_EQ(captured_title, "dispatched"); +} + +TEST(SessionDispatch, RoutesV0149WarningAndModelCallFailure) +{ + auto session = std::make_shared("sess-disp-2", /*client=*/nullptr); + + int warning_seen = 0; + int failure_seen = 0; + auto sub = session->on( + [&](const SessionEvent& e) + { + if (e.is()) + ++warning_seen; + else if (e.is()) + ++failure_seen; + }); + + session->dispatch_event( + envelope( + "session.warning", + {{"warningType", "policy"}, {"message", "soft warning"}, {"url", "https://e.x/h"}}) + .get()); + session->dispatch_event( + envelope( + "model.call_failure", + {{"source", "top_level"}, + {"errorMessage", "model timed out"}, + {"model", "gpt-4"}, + {"statusCode", 504}}) + .get()); + + EXPECT_EQ(warning_seen, 1); + EXPECT_EQ(failure_seen, 1); +} + +TEST(SessionDispatch, MultipleHandlersAllFireInRegistrationOrder) +{ + auto session = std::make_shared("sess-disp-3", /*client=*/nullptr); + + std::vector order; + auto s1 = session->on([&](const SessionEvent&) { order.push_back(1); }); + auto s2 = session->on([&](const SessionEvent&) { order.push_back(2); }); + auto s3 = session->on([&](const SessionEvent&) { order.push_back(3); }); + + session->dispatch_event( + envelope("session.title_changed", {{"title", "x"}}).get()); + + ASSERT_EQ(order.size(), 3u); + EXPECT_EQ(order[0], 1); + EXPECT_EQ(order[1], 2); + EXPECT_EQ(order[2], 3); +} + +TEST(SessionDispatch, SubscriptionDestructorRemovesHandler) +{ + auto session = std::make_shared("sess-disp-4", /*client=*/nullptr); + + std::atomic fired{0}; + { + auto sub = session->on([&](const SessionEvent&) { fired.fetch_add(1); }); + session->dispatch_event( + envelope("session.title_changed", {{"title", "a"}}).get()); + EXPECT_EQ(fired.load(), 1); + // sub goes out of scope here -> unsubscribed + } + session->dispatch_event( + envelope("session.title_changed", {{"title", "b"}}).get()); + EXPECT_EQ(fired.load(), 1); +} + +TEST(SessionDispatch, HandlerExceptionDoesNotBreakOtherSubscribers) +{ + auto session = std::make_shared("sess-disp-5", /*client=*/nullptr); + + int saw = 0; + auto bad = session->on([&](const SessionEvent&) { throw std::runtime_error("boom"); }); + auto good = session->on([&](const SessionEvent&) { ++saw; }); + + EXPECT_NO_THROW(session->dispatch_event( + envelope("session.title_changed", {{"title", "y"}}).get())); + EXPECT_EQ(saw, 1); +} + +TEST(SessionDispatch, NewV0149SessionWarningCarriesAllFields) +{ + auto session = std::make_shared("sess-disp-6", /*client=*/nullptr); + + SessionWarningData captured{}; + bool got = false; + auto sub = session->on( + [&](const SessionEvent& e) + { + if (const auto* d = e.try_as()) + { + captured = *d; + got = true; + } + }); + + auto ev = envelope( + "session.warning", + {{"warningType", "rate"}, + {"message", "approaching rate limit"}, + {"url", "https://github.com/help/rate-limit"}}) + .get(); + session->dispatch_event(ev); + + ASSERT_TRUE(got); + EXPECT_EQ(captured.warning_type, "rate"); + EXPECT_EQ(captured.message, "approaching rate limit"); +} + +// ============================================================================= +// Round-trip serialization for typed RPC structs not already covered. +// ============================================================================= + +TEST(RpcTypesRoundTripV0149, ModeSetRequest) +{ + copilot::rpc::ModeSetRequest req{"plan"}; + json j = req; + json expected = {{"mode", "plan"}}; + EXPECT_EQ(j, expected); + auto back = j.get(); + EXPECT_EQ(back.mode, "plan"); +} + +TEST(RpcTypesRoundTripV0149, ModeGetResult) +{ + copilot::rpc::ModeGetResult res{"interactive"}; + json j = res; + json expected = {{"mode", "interactive"}}; + EXPECT_EQ(j, expected); + auto back = j.get(); + EXPECT_EQ(back.mode, "interactive"); +} + +TEST(RpcTypesRoundTripV0149, ModelSwitchToResultNullableModelId) +{ + copilot::rpc::ModelSwitchToResult res{}; + json empty = res; + EXPECT_TRUE(empty.is_object()); + EXPECT_FALSE(empty.contains("modelId")); + + res.model_id = "gpt-5"; + json populated = res; + EXPECT_EQ(populated["modelId"], "gpt-5"); + + auto back = populated.get(); + ASSERT_TRUE(back.model_id.has_value()); + EXPECT_EQ(*back.model_id, "gpt-5"); +} + +TEST(RpcTypesRoundTripV0149, HistoryCompactResult) +{ + copilot::rpc::HistoryCompactResult res{true, 1024, 12}; + json j = res; + EXPECT_TRUE(j["success"].get()); + EXPECT_EQ(j["tokensRemoved"], 1024); + EXPECT_EQ(j["messagesRemoved"], 12); + auto back = j.get(); + EXPECT_TRUE(back.success); + EXPECT_EQ(back.tokens_removed, 1024); + EXPECT_EQ(back.messages_removed, 12); +} + +TEST(RpcTypesRoundTripV0149, PermissionsSetApproveAllRoundTrip) +{ + copilot::rpc::PermissionsSetApproveAllRequest req{true}; + json reqj = req; + EXPECT_TRUE(reqj["enabled"].get()); + auto back_req = reqj.get(); + EXPECT_TRUE(back_req.enabled); + + copilot::rpc::PermissionsSetApproveAllResult res{true}; + json resj = res; + EXPECT_TRUE(resj["success"].get()); + auto back_res = resj.get(); + EXPECT_TRUE(back_res.success); +} + +TEST(RpcTypesRoundTripV0149, PlanUpdateRequest) +{ + copilot::rpc::PlanUpdateRequest req{"# Plan\n- step 1"}; + json j = req; + EXPECT_EQ(j["content"], "# Plan\n- step 1"); + auto back = j.get(); + EXPECT_EQ(back.content, "# Plan\n- step 1"); +} + +// ============================================================================= +// SessionStartData parse (v0.1.49 fields present on the wire) +// ============================================================================= + +TEST(SessionStartDataParse, BaselineFieldsPopulated) +{ + json data = { + {"sessionId", "sess-1"}, + {"version", 3.0}, + {"producer", "copilot-cli"}, + {"copilotVersion", "0.1.49"}, + {"startTime", "2025-01-15T10:00:00Z"}, + {"selectedModel", "gpt-5"}, + }; + auto event = envelope("session.start", data).get(); + EXPECT_EQ(event.type, SessionEventType::SessionStart); + const auto& d = event.as(); + EXPECT_EQ(d.session_id, "sess-1"); + EXPECT_EQ(d.copilot_version, "0.1.49"); + ASSERT_TRUE(d.selected_model.has_value()); + EXPECT_EQ(*d.selected_model, "gpt-5"); +} + +TEST(WorkingDirectoryContextParse, AllOptionalFieldsRoundTrip) +{ + // Tests the structure used by session.start and session.context_changed. + json j = { + {"cwd", "/repo"}, + {"baseCommit", "deadbeef"}, + {"branch", "main"}, + {"gitRoot", "/repo"}, + {"headCommit", "feedface"}, + {"hostType", "github.com"}, + {"repository", "owner/repo"}, + {"repositoryHost", "github.com"}, + }; + auto ctx = j.get(); + EXPECT_EQ(ctx.cwd, "/repo"); + ASSERT_TRUE(ctx.branch.has_value()); + EXPECT_EQ(*ctx.branch, "main"); + ASSERT_TRUE(ctx.repository.has_value()); + EXPECT_EQ(*ctx.repository, "owner/repo"); + ASSERT_TRUE(ctx.head_commit.has_value()); + EXPECT_EQ(*ctx.head_commit, "feedface"); +} From b73cdcb0bdf278a79adea14e377a0295fd254337 Mon Sep 17 00:00:00 2001 From: Elias Bachaalany Date: Sat, 16 May 2026 10:45:20 -0700 Subject: [PATCH 11/15] test(conformance): add offline conformance suite closing E2E gap Adds tests/test_conformance.cpp with 24 offline ctest cases that exercise wire-level behavior without requiring the real Copilot CLI. Closes the coverage gap left by E2E tests skipped via COPILOT_SDK_CPP_SKIP_E2E. Test sections: * Section A - CLI argv / env mapping (8 tests). Asserts COPILOT_HOME, COPILOT_CONNECTION_TOKEN, COPILOT_SDK_AUTH_TOKEN env passthrough plus --session-idle-timeout, --remote, --log-level, --port / --stdio CLI flag emission. NODE_DEBUG stripping is also verified. * Section B - v0.1.49 session.create / session.resume payload (3 tests). Verifies all v0.1.49 fields are omitted by default and that, when set, they serialize with the upstream camelCase names (clientName, enableSessionTelemetry, includeSubAgentStreamingEvents, enableConfigDiscovery, instructionDirectories, remoteSession). * Section C - Pending lifecycle (4 tests). Drives an in-process JSON-RPC peer that connects to the SDK over loopback TCP and injects server-to -client tool.call / permission.request / userInput.request requests. Asserts the registered handler is invoked and the SDK's reply payload matches the expected wire shape. * Section D - Session-event fixture parsing (7 tests). Feeds representative JSON envelopes for major event families (session.idle, assistant.message, tool.execution_start/complete, permission.requested, user_input.requested, unknown) through the event router and asserts the variant is correctly typed. * Section E - Async lifetime (2 tests). Registers a slow tool handler, drives a server-side tool.call, then calls Session::destroy() / Client::stop() while the handler is mid-flight. Asserts no crash, no UAF; completion happens or is gracefully cancelled. * Section F - TODO(conformance) marker documenting that real-CLI subprocess lifecycle remains gated behind the E2E tier (test_e2e.cpp), per p2-cpp-buildsys-ci. Supporting changes: * include/copilot/client.hpp + src/client.cpp - extract build_cli_command_args and build_cli_environment as free functions in the copilot:: namespace so they can be unit-tested directly. Client::start_cli_server() now delegates to them; behavior is unchanged. * tests/CMakeLists.txt - register the new test_conformance executable next to the existing focused test targets. The in-process JSON-RPC peer mirrors the SnapshotRpcServer pattern from tests/snapshot_tests/snapshot_replay.cpp - opens a loopback listener, accepts one connection, runs a reader + writer thread, and exposes inject_request() for tests to push server-to-client requests and capture the SDK's reply. ctest summary: Before: 403 tests, 100% passing. After: 427 tests, 100% passing (+24 conformance). --- include/copilot/client.hpp | 18 + src/client.cpp | 110 +++-- tests/CMakeLists.txt | 14 + tests/test_conformance.cpp | 907 +++++++++++++++++++++++++++++++++++++ 4 files changed, 1001 insertions(+), 48 deletions(-) create mode 100644 tests/test_conformance.cpp diff --git a/include/copilot/client.hpp b/include/copilot/client.hpp index b2b66f9..6731dc7 100644 --- a/include/copilot/client.hpp +++ b/include/copilot/client.hpp @@ -46,6 +46,24 @@ json build_session_create_request(const SessionConfig& config); /// @return JSON object ready to send to server json build_session_resume_request(const std::string& session_id, const ResumeSessionConfig& config); +/// Build the CLI argument vector that {@link Client} will pass to the spawned +/// Copilot CLI process, given a fully-populated {@link ClientOptions}. +/// Exposed for conformance unit testing of process-launch behavior. Mirrors +/// what `start_cli_server()` emits before command resolution (i.e. no Node / +/// `cmd /c` wrapping is applied). +/// @param options Client options +/// @return Argument list (does not include the executable itself) +std::vector build_cli_command_args(const ClientOptions& options); + +/// Build the environment-variable map that {@link Client} will use when +/// spawning the Copilot CLI process. The returned map reflects the SDK's +/// additions and removals (COPILOT_HOME, COPILOT_CONNECTION_TOKEN, +/// COPILOT_SDK_AUTH_TOKEN, NODE_DEBUG erase) layered on top of the explicit +/// `options.environment`. Exposed for conformance unit testing. +/// @param options Client options +/// @return Environment map ready for ProcessOptions::environment +std::map build_cli_environment(const ClientOptions& options); + // ============================================================================= // CopilotClient - Main client class // ============================================================================= diff --git a/src/client.cpp b/src/client.cpp index b0b3f62..88b497e 100644 --- a/src/client.cpp +++ b/src/client.cpp @@ -233,6 +233,65 @@ json build_session_resume_request(const std::string& session_id, const ResumeSes return request; } +// ============================================================================= +// CLI Process Launch Helpers (exposed for unit testing) +// ============================================================================= + +std::vector build_cli_command_args(const ClientOptions& options) +{ + std::vector args; + if (options.cli_args.has_value()) + args.insert(args.end(), options.cli_args->begin(), options.cli_args->end()); + args.push_back("--server"); + args.push_back("--log-level"); + args.push_back(json(options.log_level).get()); + + if (options.use_stdio) + { + args.push_back("--stdio"); + } + else if (options.port > 0) + { + args.push_back("--port"); + args.push_back(std::to_string(options.port)); + } + + // Session idle timeout (forwarded as CLI flag; ignored by server when 0/absent). + if (options.session_idle_timeout_seconds.has_value() && + *options.session_idle_timeout_seconds > 0) + { + args.push_back("--session-idle-timeout"); + args.push_back(std::to_string(*options.session_idle_timeout_seconds)); + } + + // Remote session support (Mission Control integration). + if (options.remote) + args.push_back("--remote"); + + return args; +} + +std::map build_cli_environment(const ClientOptions& options) +{ + std::map env; + if (options.environment.has_value()) + env = *options.environment; + + // Remove NODE_DEBUG to avoid debug output interfering with JSON-RPC. + env.erase("NODE_DEBUG"); + + if (options.github_token.has_value()) + env["COPILOT_SDK_AUTH_TOKEN"] = *options.github_token; + + if (options.tcp_connection_token.has_value()) + env["COPILOT_CONNECTION_TOKEN"] = *options.tcp_connection_token; + + if (options.copilot_home.has_value()) + env["COPILOT_HOME"] = *options.copilot_home; + + return env; +} + // ============================================================================= // Constructor / Destructor // ============================================================================= @@ -540,37 +599,8 @@ void Client::start_cli_server() { std::string cli_path = options_.cli_path.value_or("copilot"); - // Build arguments - std::vector args; - if (options_.cli_args.has_value()) - args.insert(args.end(), options_.cli_args->begin(), options_.cli_args->end()); - args.push_back("--server"); - args.push_back("--log-level"); - args.push_back(json(options_.log_level).get()); - - if (options_.use_stdio) - { - args.push_back("--stdio"); - } - else if (options_.port > 0) - { - args.push_back("--port"); - args.push_back(std::to_string(options_.port)); - } - - // Session idle timeout (forwarded as CLI flag; ignored by server when 0/absent). - if (options_.session_idle_timeout_seconds.has_value() && - *options_.session_idle_timeout_seconds > 0) - { - args.push_back("--session-idle-timeout"); - args.push_back(std::to_string(*options_.session_idle_timeout_seconds)); - } - - // Remote session support (Mission Control integration). - if (options_.remote) - { - args.push_back("--remote"); - } + // Build arguments and environment via the testable free-function helpers. + std::vector args = build_cli_command_args(options_); // Resolve command auto [executable, full_args] = resolve_cli_command(cli_path, args); @@ -586,25 +616,9 @@ void Client::start_cli_server() proc_opts.working_directory = *options_.cwd; if (options_.environment.has_value()) - { proc_opts.inherit_environment = false; - proc_opts.environment = *options_.environment; - } - - // Remove NODE_DEBUG to avoid debug output interfering with JSON-RPC - proc_opts.environment.erase("NODE_DEBUG"); - - // Forward GitHub token as environment variable - if (options_.github_token.has_value()) - proc_opts.environment["COPILOT_SDK_AUTH_TOKEN"] = *options_.github_token; - - // Forward TCP connection token (auto-generated UUID in TCP+spawn mode if caller did not set one). - if (options_.tcp_connection_token.has_value()) - proc_opts.environment["COPILOT_CONNECTION_TOKEN"] = *options_.tcp_connection_token; - // Configurable Copilot data directory. - if (options_.copilot_home.has_value()) - proc_opts.environment["COPILOT_HOME"] = *options_.copilot_home; + proc_opts.environment = build_cli_environment(options_); // Spawn process process_ = std::make_unique(); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index f318e92..ba1c0d4 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -133,6 +133,19 @@ target_link_libraries(test_v0149_features set_target_properties(test_v0149_features PROPERTIES FOLDER "Tests") +# Offline conformance suite (in-process JSON-RPC peer; closes E2E gap) +add_executable(test_conformance + test_conformance.cpp +) + +target_link_libraries(test_conformance + PRIVATE + copilot_sdk_cpp + GTest::gtest_main +) + +set_target_properties(test_conformance PROPERTIES FOLDER "Tests") + include(GoogleTest) gtest_discover_tests(test_types) gtest_discover_tests(test_transport) @@ -143,6 +156,7 @@ gtest_discover_tests(test_e2e) gtest_discover_tests(test_tool_builder) gtest_discover_tests(test_rpc_methods) gtest_discover_tests(test_v0149_features) +gtest_discover_tests(test_conformance) # Snapshot conformance tests (optional, requires upstream snapshots + Python) if(COPILOT_BUILD_SNAPSHOT_TESTS) diff --git a/tests/test_conformance.cpp b/tests/test_conformance.cpp new file mode 100644 index 0000000..b1b854b --- /dev/null +++ b/tests/test_conformance.cpp @@ -0,0 +1,907 @@ +// Copyright (c) 2025 Elias Bachaalany +// SPDX-License-Identifier: MIT + +/// @file test_conformance.cpp +/// @brief Offline conformance test suite. +/// +/// This suite closes the coverage gap left by E2E tests that are skipped when +/// the real Copilot CLI is not installed (`COPILOT_SDK_CPP_SKIP_E2E=1`). It +/// exercises wire-level behavior with no external dependencies by spinning up +/// an in-process JSON-RPC peer over a loopback TCP socket (mirroring the +/// `SnapshotRpcServer` pattern from `tests/snapshot_tests/snapshot_replay.cpp`) +/// and by directly invoking the SDK request-builder / argv-builder seams that +/// are exposed for testing. +/// +/// Covered surfaces: +/// * `build_cli_command_args` + `build_cli_environment` for COPILOT_HOME, +/// COPILOT_CONNECTION_TOKEN, COPILOT_SDK_AUTH_TOKEN, --session-idle-timeout, +/// --remote, --log-level, --port / --stdio. +/// * Omission tests for v0.1.49 SessionConfig / ResumeSessionConfig fields +/// (no field on the wire when the option is not set). +/// * Pending lifecycle: server-side `tool.call`, `permission.request`, +/// `userInput.request` requests dispatched through a stub RPC peer, with +/// reply payload assertions. +/// * Session-event fixture parsing through `parse_session_event` / +/// `json::get()` for major event families. +/// * Async lifetime: `Session::destroy()` / `Client::stop()` called while a +/// tool handler future is mid-flight (handler sleeps briefly); no crash, +/// no UAF, the call completes or is gracefully cancelled. + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace copilot; + +namespace +{ + +// ============================================================================= +// Helpers shared by multiple suites +// ============================================================================= + +json envelope(const char* type, json data, const char* id = "evt_conf") +{ + return json{ + {"id", id}, + {"timestamp", "2025-02-01T00:00:00Z"}, + {"parentId", nullptr}, + {"type", type}, + {"data", std::move(data)}, + }; +} + +bool args_contain_sequence(const std::vector& haystack, + const std::vector& needle) +{ + if (needle.empty() || haystack.size() < needle.size()) + return false; + for (size_t i = 0; i + needle.size() <= haystack.size(); ++i) + { + bool match = true; + for (size_t j = 0; j < needle.size(); ++j) + if (haystack[i + j] != needle[j]) + { + match = false; + break; + } + if (match) + return true; + } + return false; +} + +// ============================================================================= +// In-process JSON-RPC peer (mirrors SnapshotRpcServer) +// ============================================================================= +// +// Connects to the SDK by opening a TCP listener on loopback and letting the +// Client connect via `ClientOptions::cli_url = ""`. The peer: +// * Responds to `ping` with `{message:"pong", protocolVersion: kSdkProtocolVersion}` +// * Responds to `session.create` / `session.resume` / `session.destroy` / +// `session.send` with minimal valid payloads. +// * Exposes an `inject_request()` API for tests to push server-initiated +// requests (tool.call, permission.request, userInput.request) and capture +// the SDK's reply payload via a future. + +class InProcessRpcPeer +{ + public: + InProcessRpcPeer() = default; + + ~InProcessRpcPeer() + { + stop(); + } + + /// Start listening on a free loopback port; returns the bound port. + int start() + { +#ifdef _WIN32 + WinsockInitializer::instance(); +#endif + TcpTransport::Socket sock = ::socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); + if (sock == TcpTransport::kInvalidSocket) + throw std::runtime_error("InProcessRpcPeer: socket() failed"); + + sockaddr_in addr{}; + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + addr.sin_port = htons(0); + + int yes = 1; + setsockopt( + sock, SOL_SOCKET, SO_REUSEADDR, reinterpret_cast(&yes), sizeof(yes)); + + if (::bind(sock, reinterpret_cast(&addr), sizeof(addr)) != 0) + { + close_socket(sock); + throw std::runtime_error("InProcessRpcPeer: bind() failed"); + } + sockaddr_in bound{}; + socklen_t len = sizeof(bound); + if (::getsockname(sock, reinterpret_cast(&bound), &len) != 0) + { + close_socket(sock); + throw std::runtime_error("InProcessRpcPeer: getsockname() failed"); + } + int port = ntohs(bound.sin_port); + + if (::listen(sock, 1) != 0) + { + close_socket(sock); + throw std::runtime_error("InProcessRpcPeer: listen() failed"); + } + listen_sock_ = sock; + thread_ = std::thread([this]() { this->run(); }); + return port; + } + + void stop() + { + stop_requested_ = true; + if (listen_sock_ != TcpTransport::kInvalidSocket) + { + close_socket(listen_sock_); + listen_sock_ = TcpTransport::kInvalidSocket; + } + // Drop any pending injections so threads waiting on futures unblock. + { + std::lock_guard lock(pending_mutex_); + for (auto& [id, slot] : pending_) + slot.promise.set_value(json::object()); + pending_.clear(); + } + if (thread_.joinable()) + thread_.join(); + } + + /// Push a server-initiated request to the SDK; returns a future that + /// resolves to the JSON the SDK sent back as the response result. + /// Safe to call before or after the SDK has connected. + std::future inject_request(const std::string& method, json params) + { + int id; + std::future fut; + { + std::lock_guard lock(pending_mutex_); + id = next_request_id_++; + PendingSlot slot; + slot.method = method; + slot.params = std::move(params); + fut = slot.promise.get_future(); + pending_.emplace(id, std::move(slot)); + } + flush_cv_.notify_one(); + return fut; + } + + /// Hold for a connected framer so tests can also push raw notifications + /// (e.g. session.event) on demand. + bool send_notification(const std::string& method, const json& params) + { + std::lock_guard lock(framer_mutex_); + if (!framer_) + return false; + json msg = {{"jsonrpc", "2.0"}, {"method", method}, {"params", params}}; + try + { + framer_->write_message(msg.dump()); + return true; + } + catch (...) + { + return false; + } + } + + private: + struct PendingSlot + { + std::string method; + json params; + std::promise promise; + bool sent = false; + }; + + TcpTransport::Socket listen_sock_ = TcpTransport::kInvalidSocket; + std::thread thread_; + std::atomic stop_requested_{false}; + + // Map of in-flight request id (as int) to slot. + std::mutex pending_mutex_; + std::map pending_; + int next_request_id_ = 1; + std::condition_variable flush_cv_; + + // Framer is held under a mutex so other threads can push notifications. + std::mutex framer_mutex_; + MessageFramer* framer_ = nullptr; + + static void close_socket(TcpTransport::Socket sock) + { +#ifdef _WIN32 + closesocket(sock); +#else + ::close(sock); +#endif + } + + void run() + { + sockaddr_in client_addr{}; + socklen_t addr_len = sizeof(client_addr); + TcpTransport::Socket client_sock = + ::accept(listen_sock_, reinterpret_cast(&client_addr), &addr_len); + if (client_sock == TcpTransport::kInvalidSocket) + return; + + TcpTransport transport(client_sock); + MessageFramer framer(transport); + { + std::lock_guard lock(framer_mutex_); + framer_ = &framer; + } + + // Reader thread: pulls inbound messages and routes + // them to either pending-request fulfillment or request handling. + std::thread reader([&]() { + while (transport.is_open() && !stop_requested_) + { + json incoming; + try + { + incoming = json::parse(framer.read_message()); + } + catch (...) + { + break; + } + + // SDK reply to one of our injected requests + if (incoming.contains("id") && (incoming.contains("result") || incoming.contains("error")) && + !incoming.contains("method")) + { + int rid = incoming["id"].get(); + std::unique_lock lock(pending_mutex_); + auto it = pending_.find(rid); + if (it != pending_.end()) + { + json payload = + incoming.contains("result") ? incoming["result"] : incoming["error"]; + it->second.promise.set_value(std::move(payload)); + pending_.erase(it); + } + continue; + } + + // SDK -> server request: respond minimally so the SDK can make progress + if (incoming.contains("method") && incoming.contains("id")) + { + const std::string method = incoming["method"].get(); + json result = json::object(); + if (method == "ping") + { + result = json{ + {"message", "pong"}, + {"protocolVersion", kSdkProtocolVersion}, + }; + } + else if (method == "session.create") + { + result = json{{"sessionId", "sess-conf-1"}}; + } + else if (method == "session.resume") + { + json params = incoming.value("params", json::object()); + result = json{ + {"sessionId", params.value("sessionId", std::string("sess-conf-1"))}}; + } + else if (method == "session.destroy") + { + result = json::object(); + } + else if (method == "session.send") + { + result = json{{"messageId", "msg-1"}}; + } + else if (method == "session.list") + { + result = json{{"sessions", json::array()}}; + } + else if (method == "session.getLastId") + { + result = json{{"sessionId", ""}}; + } + json resp{ + {"jsonrpc", "2.0"}, {"id", incoming["id"]}, {"result", result}}; + try + { + std::lock_guard lock(framer_mutex_); + framer.write_message(resp.dump()); + } + catch (...) + { + break; + } + } + } + }); + + // Flush thread: dispatches any queued injected requests. + while (!stop_requested_) + { + std::unique_lock lock(pending_mutex_); + flush_cv_.wait_for( + lock, std::chrono::milliseconds(50), [this]() { return stop_requested_.load(); }); + + for (auto& [id, slot] : pending_) + { + if (slot.sent) + continue; + slot.sent = true; + json req{ + {"jsonrpc", "2.0"}, + {"id", id}, + {"method", slot.method}, + {"params", slot.params}, + }; + lock.unlock(); + try + { + std::lock_guard fl(framer_mutex_); + framer.write_message(req.dump()); + } + catch (...) + { + } + lock.lock(); + } + if (!transport.is_open()) + break; + } + + { + std::lock_guard lock(framer_mutex_); + framer_ = nullptr; + } + if (reader.joinable()) + reader.join(); + } +}; + +/// RAII connected-client harness backed by an InProcessRpcPeer. +struct ConnectedHarness +{ + std::unique_ptr peer; + std::unique_ptr client; + + ConnectedHarness() + { + peer = std::make_unique(); + int port = peer->start(); + ClientOptions opts; + opts.use_stdio = false; + opts.cli_url = std::to_string(port); + opts.auto_start = false; + client = std::make_unique(opts); + client->start().get(); + } + + ~ConnectedHarness() + { + try + { + if (client) + client->stop().get(); + } + catch (...) + { + } + if (peer) + peer->stop(); + } +}; + +} // namespace + +// ============================================================================= +// Section A. ClientOptions -> argv / env mapping (testable seams) +// ============================================================================= + +TEST(ConformanceCliArgs, BaselineStdioEmitsServerAndLogLevel) +{ + ClientOptions opts; + opts.use_stdio = true; + opts.log_level = LogLevel::Debug; + auto args = build_cli_command_args(opts); + EXPECT_TRUE(args_contain_sequence(args, {"--server"})); + EXPECT_TRUE(args_contain_sequence(args, {"--log-level", "debug"})); + EXPECT_TRUE(args_contain_sequence(args, {"--stdio"})); + // --port must not appear when use_stdio is true. + EXPECT_FALSE(args_contain_sequence(args, {"--port"})); +} + +TEST(ConformanceCliArgs, TcpServerEmitsPortFlag) +{ + ClientOptions opts; + opts.use_stdio = false; + opts.port = 8765; + auto args = build_cli_command_args(opts); + EXPECT_TRUE(args_contain_sequence(args, {"--port", "8765"})); + EXPECT_FALSE(args_contain_sequence(args, {"--stdio"})); +} + +TEST(ConformanceCliArgs, SessionIdleTimeoutFlagOnlyWhenPositive) +{ + ClientOptions opts; + opts.use_stdio = true; + auto args_off = build_cli_command_args(opts); + EXPECT_FALSE(args_contain_sequence(args_off, {"--session-idle-timeout"})); + + opts.session_idle_timeout_seconds = 0; + auto args_zero = build_cli_command_args(opts); + EXPECT_FALSE(args_contain_sequence(args_zero, {"--session-idle-timeout"})); + + opts.session_idle_timeout_seconds = 300; + auto args_on = build_cli_command_args(opts); + EXPECT_TRUE(args_contain_sequence(args_on, {"--session-idle-timeout", "300"})); +} + +TEST(ConformanceCliArgs, RemoteFlagToggle) +{ + ClientOptions opts; + opts.use_stdio = true; + auto args_off = build_cli_command_args(opts); + EXPECT_FALSE(args_contain_sequence(args_off, {"--remote"})); + + opts.remote = true; + auto args_on = build_cli_command_args(opts); + EXPECT_TRUE(args_contain_sequence(args_on, {"--remote"})); +} + +TEST(ConformanceCliArgs, ExtraCliArgsPrependedBeforeServerFlag) +{ + ClientOptions opts; + opts.use_stdio = true; + opts.cli_args = std::vector{"--extra-flag", "extra-value"}; + auto args = build_cli_command_args(opts); + ASSERT_GE(args.size(), 3u); + EXPECT_EQ(args[0], "--extra-flag"); + EXPECT_EQ(args[1], "extra-value"); + EXPECT_EQ(args[2], "--server"); +} + +TEST(ConformanceCliEnv, ForwardsAllThreeWellKnownVarsWhenSet) +{ + ClientOptions opts; + opts.copilot_home = std::string{"/custom/home"}; + opts.tcp_connection_token = std::string{"tok-1234"}; + opts.github_token = std::string{"gh-token-xyz"}; + auto env = build_cli_environment(opts); + EXPECT_EQ(env["COPILOT_HOME"], "/custom/home"); + EXPECT_EQ(env["COPILOT_CONNECTION_TOKEN"], "tok-1234"); + EXPECT_EQ(env["COPILOT_SDK_AUTH_TOKEN"], "gh-token-xyz"); +} + +TEST(ConformanceCliEnv, OmittedVarsAreNotInjected) +{ + ClientOptions opts; + auto env = build_cli_environment(opts); + EXPECT_EQ(env.count("COPILOT_HOME"), 0u); + EXPECT_EQ(env.count("COPILOT_CONNECTION_TOKEN"), 0u); + EXPECT_EQ(env.count("COPILOT_SDK_AUTH_TOKEN"), 0u); +} + +TEST(ConformanceCliEnv, StripsNodeDebugFromCallerEnv) +{ + ClientOptions opts; + opts.environment = std::map{ + {"NODE_DEBUG", "verbose"}, + {"KEEP_ME", "yes"}, + }; + auto env = build_cli_environment(opts); + EXPECT_EQ(env.count("NODE_DEBUG"), 0u) << "NODE_DEBUG must be stripped"; + EXPECT_EQ(env["KEEP_ME"], "yes"); +} + +// ============================================================================= +// Section B. v0.1.49 omission tests on session.create / session.resume payloads +// ============================================================================= + +TEST(ConformanceSessionPayload, CreateRequestOmitsAllV0149FieldsByDefault) +{ + SessionConfig cfg; + auto req = build_session_create_request(cfg); + EXPECT_FALSE(req.contains("clientName")); + EXPECT_FALSE(req.contains("enableSessionTelemetry")); + EXPECT_FALSE(req.contains("includeSubAgentStreamingEvents")); + EXPECT_FALSE(req.contains("enableConfigDiscovery")); + EXPECT_FALSE(req.contains("instructionDirectories")); + EXPECT_FALSE(req.contains("remoteSession")); +} + +TEST(ConformanceSessionPayload, CreateRequestEmitsCamelCaseForAllV0149Fields) +{ + SessionConfig cfg; + cfg.client_name = "conformance-suite"; + cfg.enable_session_telemetry = false; + cfg.include_sub_agent_streaming_events = true; + cfg.enable_config_discovery = true; + cfg.instruction_directories = std::vector{"/a", "/b"}; + cfg.remote_session = RemoteSessionMode::On; + + auto req = build_session_create_request(cfg); + // Field names must match the upstream camelCase wire contract exactly. + EXPECT_EQ(req["clientName"], "conformance-suite"); + EXPECT_FALSE(req["enableSessionTelemetry"].get()); + EXPECT_TRUE(req["includeSubAgentStreamingEvents"].get()); + EXPECT_TRUE(req["enableConfigDiscovery"].get()); + ASSERT_TRUE(req["instructionDirectories"].is_array()); + EXPECT_EQ(req["instructionDirectories"].size(), 2u); + EXPECT_EQ(req["remoteSession"], "on"); +} + +TEST(ConformanceSessionPayload, ResumeRequestOmitsAllV0149FieldsByDefault) +{ + ResumeSessionConfig cfg; + auto req = build_session_resume_request("sess-omit", cfg); + EXPECT_FALSE(req.contains("clientName")); + EXPECT_FALSE(req.contains("enableSessionTelemetry")); + EXPECT_FALSE(req.contains("includeSubAgentStreamingEvents")); + EXPECT_FALSE(req.contains("enableConfigDiscovery")); + EXPECT_FALSE(req.contains("instructionDirectories")); + EXPECT_FALSE(req.contains("remoteSession")); +} + +// ============================================================================= +// Section C. Pending lifecycle: tool.call / permission.request / userInput.request +// ============================================================================= + +TEST(ConformancePending, ToolCallInvokesHandlerAndReturnsResultPayload) +{ + ConnectedHarness h; + SessionConfig cfg; + Tool tool; + tool.name = "echo"; + tool.description = "echoes its 'msg' argument"; + tool.parameters_schema = json{{"type", "object"}}; + tool.handler = [](const ToolInvocation& inv) -> ToolResultObject { + ToolResultObject r; + r.text_result_for_llm = + "echoed:" + inv.arguments.value_or(json::object()).value("msg", std::string()); + r.result_type = ToolResultType::Success; + return r; + }; + cfg.tools = {tool}; + auto session = h.client->create_session(cfg).get(); + + // Inject tool.call from the server side and wait for the SDK's reply. + json params{ + {"sessionId", session->session_id()}, + {"toolCallId", "tc-1"}, + {"toolName", "echo"}, + {"arguments", json{{"msg", "hi"}}}, + }; + auto fut = h.peer->inject_request("tool.call", params); + auto status = fut.wait_for(std::chrono::seconds(5)); + ASSERT_EQ(status, std::future_status::ready); + + json reply = fut.get(); + ASSERT_TRUE(reply.contains("result")); + EXPECT_EQ(reply["result"]["textResultForLlm"], "echoed:hi"); + EXPECT_EQ(reply["result"]["resultType"], "success"); +} + +TEST(ConformancePending, ToolCallForUnknownToolReturnsFailurePayload) +{ + ConnectedHarness h; + auto session = h.client->create_session(SessionConfig{}).get(); + + json params{ + {"sessionId", session->session_id()}, + {"toolCallId", "tc-missing"}, + {"toolName", "no_such_tool"}, + {"arguments", json::object()}, + }; + auto fut = h.peer->inject_request("tool.call", params); + ASSERT_EQ(fut.wait_for(std::chrono::seconds(5)), std::future_status::ready); + + json reply = fut.get(); + ASSERT_TRUE(reply.contains("result")); + EXPECT_EQ(reply["result"]["resultType"], "failure"); + EXPECT_NE( + reply["result"]["textResultForLlm"].get().find("no_such_tool"), + std::string::npos); +} + +TEST(ConformancePending, PermissionRequestDispatchesToHandlerAndReturnsNestedResult) +{ + ConnectedHarness h; + SessionConfig cfg; + auto session = h.client->create_session(cfg).get(); + + std::atomic handler_calls{0}; + session->register_permission_handler( + [&](const PermissionRequest& req) -> PermissionRequestResult + { + ++handler_calls; + EXPECT_EQ(req.kind, "tool"); + PermissionRequestResult r; + r.kind = "approved"; + return r; + }); + + json params{ + {"sessionId", session->session_id()}, + {"permissionRequest", + json{{"kind", "tool"}, {"toolCallId", "tc-perm"}, {"toolName", "write"}}}, + }; + auto fut = h.peer->inject_request("permission.request", params); + ASSERT_EQ(fut.wait_for(std::chrono::seconds(5)), std::future_status::ready); + json reply = fut.get(); + + EXPECT_EQ(handler_calls.load(), 1); + ASSERT_TRUE(reply.contains("result")); + EXPECT_EQ(reply["result"]["kind"], "approved"); +} + +TEST(ConformancePending, UserInputRequestDispatchesToHandlerAndReturnsAnswer) +{ + ConnectedHarness h; + SessionConfig cfg; + auto session = h.client->create_session(cfg).get(); + + session->register_user_input_handler( + [](const UserInputRequest& req, const UserInputInvocation&) -> UserInputResponse + { + EXPECT_EQ(req.question, "What is 2+2?"); + UserInputResponse r; + r.answer = "4"; + r.was_freeform = true; + return r; + }); + + json params{ + {"sessionId", session->session_id()}, + {"question", "What is 2+2?"}, + {"allowFreeform", true}, + }; + auto fut = h.peer->inject_request("userInput.request", params); + ASSERT_EQ(fut.wait_for(std::chrono::seconds(5)), std::future_status::ready); + json reply = fut.get(); + + EXPECT_EQ(reply["answer"], "4"); + EXPECT_TRUE(reply["wasFreeform"].get()); +} + +// ============================================================================= +// Section D. Session-event fixture parsing +// ============================================================================= + +TEST(ConformanceEvents, ParsesSessionIdleFromWireEnvelope) +{ + auto event = envelope("session.idle", json::object()).get(); + EXPECT_EQ(event.type, SessionEventType::SessionIdle); +} + +TEST(ConformanceEvents, ParsesAssistantMessageWithOptionalFields) +{ + json data = { + {"messageId", "msg-asst-1"}, + {"content", "hello, world"}, + {"chunkContent", "hello"}, + }; + auto event = envelope("assistant.message", data).get(); + ASSERT_EQ(event.type, SessionEventType::AssistantMessage); + const auto* msg = event.try_as(); + ASSERT_NE(msg, nullptr); + EXPECT_EQ(msg->message_id, "msg-asst-1"); + EXPECT_EQ(msg->content, "hello, world"); + ASSERT_TRUE(msg->chunk_content.has_value()); + EXPECT_EQ(*msg->chunk_content, "hello"); +} + +TEST(ConformanceEvents, ParsesToolExecutionStartFamily) +{ + json data = { + {"toolCallId", "tc-99"}, + {"toolName", "ls"}, + {"arguments", json{{"path", "/tmp"}}}, + }; + auto event = envelope("tool.execution_start", data).get(); + ASSERT_EQ(event.type, SessionEventType::ToolExecutionStart); + const auto* d = event.try_as(); + ASSERT_NE(d, nullptr); + EXPECT_EQ(d->tool_call_id, "tc-99"); + EXPECT_EQ(d->tool_name, "ls"); +} + +TEST(ConformanceEvents, ParsesToolExecutionCompleteSuccess) +{ + json data = { + {"toolCallId", "tc-99"}, + {"success", true}, + }; + auto event = envelope("tool.execution_complete", data).get(); + ASSERT_EQ(event.type, SessionEventType::ToolExecutionComplete); + const auto* d = event.try_as(); + ASSERT_NE(d, nullptr); + EXPECT_EQ(d->tool_call_id, "tc-99"); + EXPECT_TRUE(d->success); +} + +TEST(ConformanceEvents, ParsesPermissionRequestedAsLifecycleEvent) +{ + json data = { + {"requestId", "req-1"}, + {"permissionRequest", json{{"kind", "tool"}, {"toolName", "rm"}}}, + }; + auto event = envelope("permission.requested", data).get(); + ASSERT_EQ(event.type, SessionEventType::PermissionRequested); + const auto* d = event.try_as(); + ASSERT_NE(d, nullptr); + EXPECT_EQ(d->request_id, "req-1"); +} + +TEST(ConformanceEvents, ParsesUserInputRequestedAsLifecycleEvent) +{ + json data = { + {"requestId", "req-2"}, + {"question", "continue?"}, + {"allowFreeform", false}, + {"choices", json::array({"yes", "no"})}, + }; + auto event = envelope("user_input.requested", data).get(); + ASSERT_EQ(event.type, SessionEventType::UserInputRequested); + const auto* d = event.try_as(); + ASSERT_NE(d, nullptr); + EXPECT_EQ(d->question, "continue?"); + ASSERT_TRUE(d->choices.has_value()); + EXPECT_EQ(d->choices->size(), 2u); +} + +TEST(ConformanceEvents, ParsingUnknownEventTypeIsNotFatal) +{ + // The SDK's permissive event router should tolerate brand-new server-side + // event types it has not learned about yet by routing to ::Unknown rather + // than throwing. + auto event = envelope("definitely.brand.new.event", json{{"foo", "bar"}}).get(); + EXPECT_EQ(event.type, SessionEventType::Unknown); +} + +// ============================================================================= +// Section E. Async lifetime: destroy/stop during an in-flight handler future +// ============================================================================= + +TEST(ConformanceLifetime, DestroyDuringInFlightToolHandlerDoesNotCrash) +{ + ConnectedHarness h; + SessionConfig cfg; + std::atomic handler_entered{false}; + std::atomic handler_completed{false}; + + Tool tool; + tool.name = "slow"; + tool.description = "deliberately slow"; + tool.parameters_schema = json{{"type", "object"}}; + tool.handler = [&](const ToolInvocation&) -> ToolResultObject { + handler_entered = true; + std::this_thread::sleep_for(std::chrono::milliseconds(400)); + handler_completed = true; + ToolResultObject r; + r.text_result_for_llm = "done"; + r.result_type = ToolResultType::Success; + return r; + }; + cfg.tools = {tool}; + auto session = h.client->create_session(cfg).get(); + + json params{ + {"sessionId", session->session_id()}, + {"toolCallId", "tc-slow"}, + {"toolName", "slow"}, + {"arguments", json::object()}, + }; + auto reply_fut = h.peer->inject_request("tool.call", params); + + // Wait until the handler is on the inside, then trigger destroy() while + // the handler is still sleeping. + for (int i = 0; i < 200 && !handler_entered.load(); ++i) + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + ASSERT_TRUE(handler_entered.load()) << "tool handler did not run"; + + // Should not crash, should not throw, should complete in bounded time. + EXPECT_NO_THROW(session->destroy().get()); + + // The handler thread may finish on its own; give it a chance. + (void)reply_fut.wait_for(std::chrono::seconds(2)); + // Either the handler completed and the reply landed, or the peer's + // promise was satisfied during stop(). Both are acceptable outcomes. + SUCCEED(); +} + +TEST(ConformanceLifetime, ClientStopDuringInFlightToolHandlerIsGraceful) +{ + auto peer = std::make_unique(); + int port = peer->start(); + + ClientOptions opts; + opts.use_stdio = false; + opts.cli_url = std::to_string(port); + opts.auto_start = false; + auto client = std::make_unique(opts); + client->start().get(); + + SessionConfig cfg; + std::atomic handler_entered{false}; + Tool tool; + tool.name = "slow"; + tool.description = "deliberately slow"; + tool.parameters_schema = json{{"type", "object"}}; + tool.handler = [&](const ToolInvocation&) -> ToolResultObject { + handler_entered = true; + std::this_thread::sleep_for(std::chrono::milliseconds(300)); + ToolResultObject r; + r.text_result_for_llm = "done"; + r.result_type = ToolResultType::Success; + return r; + }; + cfg.tools = {tool}; + auto session = client->create_session(cfg).get(); + + json params{ + {"sessionId", session->session_id()}, + {"toolCallId", "tc-stop"}, + {"toolName", "slow"}, + {"arguments", json::object()}, + }; + auto reply_fut = peer->inject_request("tool.call", params); + + for (int i = 0; i < 200 && !handler_entered.load(); ++i) + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + ASSERT_TRUE(handler_entered.load()) << "tool handler did not run"; + + // Stop the client while the tool handler is mid-flight; should not throw, + // should not crash. Any errors are reported via the StopError vector + // rather than exceptions. + EXPECT_NO_THROW(client->stop().get()); + + // Allow the detached handler thread to wind down before destroying the + // peer (it owns the socket the handler's reply path runs over). + (void)reply_fut.wait_for(std::chrono::milliseconds(800)); + + peer->stop(); + SUCCEED(); +} + +// ============================================================================= +// Section F. Documented offline-infeasible cases +// ============================================================================= + +// TODO(conformance): The end-to-end "spawn-real-CLI" path that exercises +// `Process::spawn` + port discovery + JSON-RPC handshake is intentionally +// validated only by the E2E suite (`test_e2e.cpp`), which is gated behind +// `COPILOT_SDK_CPP_SKIP_E2E`. The argv/env-building seams that feed +// `ProcessOptions` are covered above by direct unit tests against +// `build_cli_command_args` / `build_cli_environment`. Full subprocess +// validation belongs to CI's non-skipped E2E tier; see p2-cpp-buildsys-ci +// for E2E gating. From 8e43019c3747b4c5ae7450fd0851c308096975f4 Mon Sep 17 00:00:00 2001 From: Elias Bachaalany Date: Mon, 8 Jun 2026 13:40:39 -0700 Subject: [PATCH 12/15] feat(types): add skills and model fields to CustomAgentConfig Port upstream d0eb531e (add model field to CustomAgentConfig) and backfill the pre-existing skills field gap. Both fields are optional and omitted from JSON when unset. - Add skills (vector) and model (string) to CustomAgentConfig - Update to_json/from_json serialization - Add round-trip and omission tests --- include/copilot/types.hpp | 13 +++++++++++++ tests/test_types.cpp | 25 ++++++++++++++++++++++++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/include/copilot/types.hpp b/include/copilot/types.hpp index ce37f4e..e681223 100644 --- a/include/copilot/types.hpp +++ b/include/copilot/types.hpp @@ -825,6 +825,11 @@ struct CustomAgentConfig std::string prompt; std::optional> mcp_servers; std::optional infer; + std::optional> skills; + /// Model identifier for this agent (e.g. "claude-haiku-4.5"). + /// When set, the runtime will attempt to use this model for the agent, + /// falling back to the parent session model if unavailable. + std::optional model; }; inline void to_json(json& j, const CustomAgentConfig& c) @@ -840,6 +845,10 @@ inline void to_json(json& j, const CustomAgentConfig& c) j["mcpServers"] = *c.mcp_servers; if (c.infer) j["infer"] = *c.infer; + if (c.skills) + j["skills"] = *c.skills; + if (c.model) + j["model"] = *c.model; } inline void from_json(const json& j, CustomAgentConfig& c) @@ -856,6 +865,10 @@ inline void from_json(const json& j, CustomAgentConfig& c) c.mcp_servers = j.at("mcpServers").get>(); if (j.contains("infer")) c.infer = j.at("infer").get(); + if (j.contains("skills")) + c.skills = j.at("skills").get>(); + if (j.contains("model")) + c.model = j.at("model").get(); } // ============================================================================= diff --git a/tests/test_types.cpp b/tests/test_types.cpp index 61da113..4395fe9 100644 --- a/tests/test_types.cpp +++ b/tests/test_types.cpp @@ -122,7 +122,9 @@ TEST(TypesTest, CustomAgentConfig) .description = "Reviews code for issues", .tools = std::vector{"read_file", "grep"}, .prompt = "You are a code reviewer...", - .infer = true + .infer = true, + .skills = std::vector{"code-analysis", "security"}, + .model = "claude-haiku-4.5" }; json j = agent; @@ -132,6 +134,27 @@ TEST(TypesTest, CustomAgentConfig) EXPECT_EQ(j["tools"], json::array({"read_file", "grep"})); EXPECT_EQ(j["prompt"], "You are a code reviewer..."); EXPECT_EQ(j["infer"], true); + EXPECT_EQ(j["skills"], json::array({"code-analysis", "security"})); + EXPECT_EQ(j["model"], "claude-haiku-4.5"); +} + +TEST(TypesTest, CustomAgentConfigModelRoundTrip) +{ + CustomAgentConfig agent{.name = "model_agent", .prompt = "prompt", .model = "claude-haiku-4.5"}; + json j = agent; + EXPECT_EQ(j["model"], "claude-haiku-4.5"); + + auto parsed = j.get(); + EXPECT_EQ(parsed.model.value(), "claude-haiku-4.5"); + EXPECT_EQ(parsed.name, "model_agent"); +} + +TEST(TypesTest, CustomAgentConfigOmitsModelWhenUnset) +{ + CustomAgentConfig agent{.name = "no_model", .prompt = "prompt"}; + json j = agent; + EXPECT_FALSE(j.contains("model")); + EXPECT_FALSE(j.contains("skills")); } TEST(TypesTest, MessageOptions) From a7d699e3b1428c0e5d49bbcac41f907e37b214aa Mon Sep 17 00:00:00 2001 From: Elias Bachaalany Date: Mon, 8 Jun 2026 17:00:51 -0700 Subject: [PATCH 13/15] feat(types): close type-surface parity gaps with upstream .NET SDK Add missing enums, types, and fields to match upstream Types.cs: - SectionOverrideAction enum (5 variants) - SectionOverride struct (action + content) - McpHttpServerConfigOauthGrantType enum (2 variants) - SystemMessageMode::Customize variant - DefaultAgentConfig struct (excludedTools) - SystemMessageConfig.sections field - ProviderConfig: headers, modelId, wireModel, maxPromptTokens, maxOutputTokens - SessionConfig: commands, defaultAgent, agent, modelCapabilities, githubToken - ResumeSessionConfig: commands, defaultAgent, agent, modelCapabilities - MessageOptions: requestHeaders Callback handlers (OnElicitationRequest, OnExitPlanMode, etc.) deferred. Tests: 437/437 passing (8 new parity tests added) --- include/copilot/types.hpp | 136 ++++++++++++++++++++++++++++- src/client.cpp | 42 +++++---- src/session.cpp | 8 +- tests/test_types.cpp | 175 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 331 insertions(+), 30 deletions(-) diff --git a/include/copilot/types.hpp b/include/copilot/types.hpp index e681223..1b6156e 100644 --- a/include/copilot/types.hpp +++ b/include/copilot/types.hpp @@ -57,7 +57,25 @@ enum class ConnectionState enum class SystemMessageMode { Append, - Replace + Replace, + Customize +}; + +/// Section override action for system message customization +enum class SectionOverrideAction +{ + Replace, + Remove, + Append, + Prepend, + Transform +}; + +/// OAuth grant type for an MCP HTTP server +enum class McpHttpServerConfigOauthGrantType +{ + AuthorizationCode, + ClientCredentials }; // JSON enum serialization @@ -76,6 +94,26 @@ NLOHMANN_JSON_SERIALIZE_ENUM( { {SystemMessageMode::Append, "append"}, {SystemMessageMode::Replace, "replace"}, + {SystemMessageMode::Customize, "customize"}, + } +) + +NLOHMANN_JSON_SERIALIZE_ENUM( + SectionOverrideAction, + { + {SectionOverrideAction::Replace, "replace"}, + {SectionOverrideAction::Remove, "remove"}, + {SectionOverrideAction::Append, "append"}, + {SectionOverrideAction::Prepend, "prepend"}, + {SectionOverrideAction::Transform, "transform"}, + } +) + +NLOHMANN_JSON_SERIALIZE_ENUM( + McpHttpServerConfigOauthGrantType, + { + {McpHttpServerConfigOauthGrantType::AuthorizationCode, "authorization_code"}, + {McpHttpServerConfigOauthGrantType::ClientCredentials, "client_credentials"}, } ) @@ -595,11 +633,34 @@ struct SessionHooks // Configuration Types // ============================================================================= +/// Override operation for a single system prompt section +struct SectionOverride +{ + SectionOverrideAction action = SectionOverrideAction::Replace; + std::optional content; +}; + +inline void to_json(json& j, const SectionOverride& c) +{ + j = json{{"action", c.action}}; + if (c.content) + j["content"] = *c.content; +} + +inline void from_json(const json& j, SectionOverride& c) +{ + if (j.contains("action")) + c.action = j.at("action").get(); + if (j.contains("content")) + c.content = j.at("content").get(); +} + /// System message configuration struct SystemMessageConfig { std::optional mode; std::optional content; + std::optional> sections; }; inline void to_json(json& j, const SystemMessageConfig& c) @@ -609,6 +670,8 @@ inline void to_json(json& j, const SystemMessageConfig& c) j["mode"] = *c.mode; if (c.content) j["content"] = *c.content; + if (c.sections) + j["sections"] = *c.sections; } inline void from_json(const json& j, SystemMessageConfig& c) @@ -617,6 +680,8 @@ inline void from_json(const json& j, SystemMessageConfig& c) c.mode = j.at("mode").get(); if (j.contains("content")) c.content = j.at("content").get(); + if (j.contains("sections")) + c.sections = j.at("sections").get>(); } /// Azure-specific provider options @@ -647,6 +712,11 @@ struct ProviderConfig std::optional api_key; std::optional bearer_token; std::optional azure; + std::optional> headers; + std::optional model_id; + std::optional wire_model; + std::optional max_input_tokens; + std::optional max_output_tokens; // ───────────────────────────────────────────────────────────────────────── // Environment Variable Support @@ -717,6 +787,16 @@ inline void to_json(json& j, const ProviderConfig& c) j["bearerToken"] = *c.bearer_token; if (c.azure) j["azure"] = *c.azure; + if (c.headers) + j["headers"] = *c.headers; + if (c.model_id) + j["modelId"] = *c.model_id; + if (c.wire_model) + j["wireModel"] = *c.wire_model; + if (c.max_input_tokens) + j["maxPromptTokens"] = *c.max_input_tokens; + if (c.max_output_tokens) + j["maxOutputTokens"] = *c.max_output_tokens; } inline void from_json(const json& j, ProviderConfig& c) @@ -732,6 +812,16 @@ inline void from_json(const json& j, ProviderConfig& c) c.bearer_token = j.at("bearerToken").get(); if (j.contains("azure")) c.azure = j.at("azure").get(); + if (j.contains("headers")) + c.headers = j.at("headers").get>(); + if (j.contains("modelId")) + c.model_id = j.at("modelId").get(); + if (j.contains("wireModel")) + c.wire_model = j.at("wireModel").get(); + if (j.contains("maxPromptTokens")) + c.max_input_tokens = j.at("maxPromptTokens").get(); + if (j.contains("maxOutputTokens")) + c.max_output_tokens = j.at("maxOutputTokens").get(); } // ============================================================================= @@ -871,6 +961,24 @@ inline void from_json(const json& j, CustomAgentConfig& c) c.model = j.at("model").get(); } +struct DefaultAgentConfig +{ + std::optional> excluded_tools; +}; + +inline void to_json(json& j, const DefaultAgentConfig& c) +{ + j = json::object(); + if (c.excluded_tools) + j["excludedTools"] = *c.excluded_tools; +} + +inline void from_json(const json& j, DefaultAgentConfig& c) +{ + if (j.contains("excludedTools")) + c.excluded_tools = j.at("excludedTools").get>(); +} + // ============================================================================= // Attachment Types (for MessageOptions) // ============================================================================= @@ -1004,7 +1112,9 @@ struct SessionConfig { std::optional session_id; std::optional model; + std::optional model_capabilities; std::vector tools; + std::optional> commands; std::optional system_message; std::optional> available_tools; std::optional> excluded_tools; @@ -1013,6 +1123,8 @@ struct SessionConfig bool streaming = false; std::optional> mcp_servers; std::optional> custom_agents; + std::optional default_agent; + std::optional agent; /// Directories to load skills from. std::optional> skill_directories; @@ -1044,6 +1156,9 @@ struct SessionConfig /// Working directory for the session. std::optional working_directory; + /// GitHub token for per-session authentication. + std::optional github_token; + // ===== v0.1.49 additions ===== /// Client identifier reported to the CLI (PR #510). @@ -1075,6 +1190,8 @@ struct ResumeSessionConfig bool streaming = false; std::optional> mcp_servers; std::optional> custom_agents; + std::optional default_agent; + std::optional agent; /// Directories to load skills from. std::optional> skill_directories; @@ -1092,10 +1209,13 @@ struct ResumeSessionConfig /// Model to use for this session. Can change the model when resuming. std::optional model; + std::optional model_capabilities; /// Reasoning effort level for models that support it. std::optional reasoning_effort; + std::optional> commands; + /// System message configuration. std::optional system_message; @@ -1136,6 +1256,7 @@ struct MessageOptions std::string prompt; std::optional> attachments; std::optional mode; + std::optional> request_headers; }; inline void to_json(json& j, const MessageOptions& o) @@ -1145,6 +1266,19 @@ inline void to_json(json& j, const MessageOptions& o) j["attachments"] = *o.attachments; if (o.mode) j["mode"] = *o.mode; + if (o.request_headers) + j["requestHeaders"] = *o.request_headers; +} + +inline void from_json(const json& j, MessageOptions& o) +{ + j.at("prompt").get_to(o.prompt); + if (j.contains("attachments")) + o.attachments = j.at("attachments").get>(); + if (j.contains("mode")) + o.mode = j.at("mode").get(); + if (j.contains("requestHeaders")) + o.request_headers = j.at("requestHeaders").get>(); } // ============================================================================= diff --git a/src/client.cpp b/src/client.cpp index 88b497e..de2a75d 100644 --- a/src/client.cpp +++ b/src/client.cpp @@ -34,20 +34,12 @@ json build_session_create_request(const SessionConfig& config) if (config.session_id.has_value()) request["sessionId"] = *config.session_id; + if (config.model_capabilities.has_value()) + request["modelCapabilities"] = *config.model_capabilities; if (config.on_permission_request.has_value()) request["requestPermission"] = true; if (config.system_message.has_value()) - { - json sys_msg; - if (config.system_message->content.has_value()) - sys_msg["content"] = *config.system_message->content; - if (config.system_message->mode.has_value()) - { - sys_msg["mode"] = - (*config.system_message->mode == SystemMessageMode::Replace) ? "replace" : "append"; - } - request["systemMessage"] = sys_msg; - } + request["systemMessage"] = *config.system_message; // Add custom tool definitions to the request if (!config.tools.empty()) { @@ -67,6 +59,8 @@ json build_session_create_request(const SessionConfig& config) } request["tools"] = tool_defs; } + if (config.commands.has_value()) + request["commands"] = *config.commands; if (config.available_tools.has_value()) request["availableTools"] = *config.available_tools; if (config.excluded_tools.has_value()) @@ -94,6 +88,10 @@ json build_session_create_request(const SessionConfig& config) agents.push_back(agent); request["customAgents"] = agents; } + if (config.default_agent.has_value()) + request["defaultAgent"] = *config.default_agent; + if (config.agent.has_value()) + request["agent"] = *config.agent; if (config.skill_directories.has_value()) request["skillDirectories"] = *config.skill_directories; if (config.disabled_skills.has_value()) @@ -110,6 +108,8 @@ json build_session_create_request(const SessionConfig& config) request["hooks"] = true; if (config.working_directory.has_value()) request["workingDirectory"] = *config.working_directory; + if (config.github_token.has_value()) + request["githubToken"] = *config.github_token; // v0.1.49 additions if (config.client_name.has_value()) @@ -154,6 +154,8 @@ json build_session_resume_request(const std::string& session_id, const ResumeSes } request["tools"] = tool_defs; } + if (config.commands.has_value()) + request["commands"] = *config.commands; if (config.streaming) request["streaming"] = config.streaming; @@ -177,6 +179,10 @@ json build_session_resume_request(const std::string& session_id, const ResumeSes agents.push_back(agent); request["customAgents"] = agents; } + if (config.default_agent.has_value()) + request["defaultAgent"] = *config.default_agent; + if (config.agent.has_value()) + request["agent"] = *config.agent; if (config.skill_directories.has_value()) request["skillDirectories"] = *config.skill_directories; if (config.disabled_skills.has_value()) @@ -187,20 +193,12 @@ json build_session_resume_request(const std::string& session_id, const ResumeSes // New fields for v0.1.23 parity if (config.model.has_value()) request["model"] = *config.model; + if (config.model_capabilities.has_value()) + request["modelCapabilities"] = *config.model_capabilities; if (config.reasoning_effort.has_value()) request["reasoningEffort"] = *config.reasoning_effort; if (config.system_message.has_value()) - { - json sys_msg; - if (config.system_message->content.has_value()) - sys_msg["content"] = *config.system_message->content; - if (config.system_message->mode.has_value()) - { - sys_msg["mode"] = - (*config.system_message->mode == SystemMessageMode::Replace) ? "replace" : "append"; - } - request["systemMessage"] = sys_msg; - } + request["systemMessage"] = *config.system_message; if (config.available_tools.has_value()) request["availableTools"] = *config.available_tools; if (config.excluded_tools.has_value()) diff --git a/src/session.cpp b/src/session.cpp index 115ae8c..45271eb 100644 --- a/src/session.cpp +++ b/src/session.cpp @@ -36,14 +36,8 @@ std::future Session::send(MessageOptions options) std::launch::async, [this, options = std::move(options)]() { - json params; + json params = options; params["sessionId"] = session_id_; - params["prompt"] = options.prompt; - - if (options.attachments.has_value()) - params["attachments"] = *options.attachments; - if (options.mode.has_value()) - params["mode"] = *options.mode; auto response = client_->rpc_client()->invoke(copilot::rpc::methods::kSessionSend, params).get(); return response["messageId"].get(); diff --git a/tests/test_types.cpp b/tests/test_types.cpp index 4395fe9..05fa87b 100644 --- a/tests/test_types.cpp +++ b/tests/test_types.cpp @@ -26,6 +26,72 @@ TEST(TypesTest, SystemMessageModeEnum) j = SystemMessageMode::Replace; EXPECT_EQ(j, "replace"); + + j = SystemMessageMode::Customize; + EXPECT_EQ(j, "customize"); + EXPECT_EQ(json("customize").get(), SystemMessageMode::Customize); +} + +TEST(TypesTest, SectionOverrideActionEnumRoundTrip) +{ + struct EnumCase + { + SectionOverrideAction value; + const char* wire; + }; + + const std::vector cases = { + {SectionOverrideAction::Replace, "replace"}, + {SectionOverrideAction::Remove, "remove"}, + {SectionOverrideAction::Append, "append"}, + {SectionOverrideAction::Prepend, "prepend"}, + {SectionOverrideAction::Transform, "transform"}, + }; + + for (const auto& test_case : cases) + { + json j = test_case.value; + EXPECT_EQ(j, test_case.wire); + EXPECT_EQ(j.get(), test_case.value); + } +} + +TEST(TypesTest, McpHttpServerConfigOauthGrantTypeEnumRoundTrip) +{ + struct EnumCase + { + McpHttpServerConfigOauthGrantType value; + const char* wire; + }; + + const std::vector cases = { + {McpHttpServerConfigOauthGrantType::AuthorizationCode, "authorization_code"}, + {McpHttpServerConfigOauthGrantType::ClientCredentials, "client_credentials"}, + }; + + for (const auto& test_case : cases) + { + json j = test_case.value; + EXPECT_EQ(j, test_case.wire); + EXPECT_EQ(j.get(), test_case.value); + } +} + +TEST(TypesTest, SectionOverrideStructRoundTrip) +{ + SectionOverride original{ + .action = SectionOverrideAction::Prepend, + .content = "prepend this" + }; + + json j = original; + EXPECT_EQ(j["action"], "prepend"); + EXPECT_EQ(j["content"], "prepend this"); + + auto parsed = j.get(); + EXPECT_EQ(parsed.action, SectionOverrideAction::Prepend); + ASSERT_TRUE(parsed.content.has_value()); + EXPECT_EQ(*parsed.content, "prepend this"); } TEST(TypesTest, ToolBinaryResultRoundTrip) @@ -93,6 +159,37 @@ TEST(TypesTest, ProviderConfig) EXPECT_EQ(j["azure"]["apiVersion"], "2024-02-01"); } +TEST(TypesTest, ProviderConfigNewFieldsRoundTrip) +{ + ProviderConfig config{ + .type = "azure", + .wire_api = "responses", + .base_url = "https://example.invalid/v1", + .api_key = "api-key", + .bearer_token = "bearer-token", + .headers = std::map{{"X-Test", "value"}}, + .model_id = "gpt-4.1", + .wire_model = "deployment-name", + .max_input_tokens = 64000, + .max_output_tokens = 4096 + }; + + json j = config; + EXPECT_EQ(j["headers"]["X-Test"], "value"); + EXPECT_EQ(j["modelId"], "gpt-4.1"); + EXPECT_EQ(j["wireModel"], "deployment-name"); + EXPECT_EQ(j["maxPromptTokens"], 64000); + EXPECT_EQ(j["maxOutputTokens"], 4096); + + auto parsed = j.get(); + ASSERT_TRUE(parsed.headers.has_value()); + EXPECT_EQ(parsed.headers->at("X-Test"), "value"); + EXPECT_EQ(parsed.model_id, std::optional("gpt-4.1")); + EXPECT_EQ(parsed.wire_model, std::optional("deployment-name")); + EXPECT_EQ(parsed.max_input_tokens, std::optional(64000)); + EXPECT_EQ(parsed.max_output_tokens, std::optional(4096)); +} + TEST(TypesTest, McpLocalServerConfig) { McpLocalServerConfig config; @@ -157,6 +254,18 @@ TEST(TypesTest, CustomAgentConfigOmitsModelWhenUnset) EXPECT_FALSE(j.contains("skills")); } +TEST(TypesTest, DefaultAgentConfigRoundTrip) +{ + DefaultAgentConfig config{.excluded_tools = std::vector{"bash", "write_file"}}; + + json j = config; + EXPECT_EQ(j["excludedTools"], json::array({"bash", "write_file"})); + + auto parsed = j.get(); + ASSERT_TRUE(parsed.excluded_tools.has_value()); + EXPECT_EQ(*parsed.excluded_tools, (std::vector{"bash", "write_file"})); +} + TEST(TypesTest, MessageOptions) { MessageOptions opts{ @@ -174,6 +283,21 @@ TEST(TypesTest, MessageOptions) EXPECT_EQ(j["mode"], "chat"); } +TEST(TypesTest, MessageOptionsRequestHeadersRoundTrip) +{ + MessageOptions opts{ + .prompt = "Hello with headers", + .request_headers = std::map{{"X-Trace-Id", "trace-123"}} + }; + + json j = opts; + EXPECT_EQ(j["requestHeaders"]["X-Trace-Id"], "trace-123"); + + auto parsed = j.get(); + ASSERT_TRUE(parsed.request_headers.has_value()); + EXPECT_EQ(parsed.request_headers->at("X-Trace-Id"), "trace-123"); +} + TEST(TypesTest, PingResponse) { json input = {{"message", "pong"}, {"timestamp", 1234567890}, {"protocolVersion", 3}}; @@ -1588,6 +1712,34 @@ TEST(RequestBuilderTest, CreateSessionWithoutHooksOmitsField) EXPECT_FALSE(request.contains("reasoningEffort")); } +TEST(RequestBuilderTest, SessionConfigNewDataFieldsSerialize) +{ + SessionConfig config; + config.model_capabilities = json{{"supports", {{"vision", true}}}}; + config.commands = std::vector{json{{"name", "deploy"}, {"description", "Deploy app"}}}; + config.system_message = SystemMessageConfig{ + .mode = SystemMessageMode::Customize, + .content = "after sections", + .sections = std::map{ + {"tone", SectionOverride{.action = SectionOverrideAction::Replace, .content = "Be terse."}} + } + }; + config.default_agent = DefaultAgentConfig{.excluded_tools = std::vector{"bash"}}; + config.agent = "reviewer"; + config.github_token = "ghs_test"; + + auto request = build_session_create_request(config); + EXPECT_EQ(request["modelCapabilities"]["supports"]["vision"], true); + ASSERT_TRUE(request["commands"].is_array()); + EXPECT_EQ(request["commands"][0]["name"], "deploy"); + EXPECT_EQ(request["systemMessage"]["mode"], "customize"); + EXPECT_EQ(request["systemMessage"]["sections"]["tone"]["action"], "replace"); + EXPECT_EQ(request["systemMessage"]["sections"]["tone"]["content"], "Be terse."); + EXPECT_EQ(request["defaultAgent"]["excludedTools"][0], "bash"); + EXPECT_EQ(request["agent"], "reviewer"); + EXPECT_EQ(request["githubToken"], "ghs_test"); +} + TEST(RequestBuilderTest, ResumeSessionAllNewFields) { ResumeSessionConfig config; @@ -1621,6 +1773,29 @@ TEST(RequestBuilderTest, ResumeSessionAllNewFields) EXPECT_TRUE(request["hooks"].get()); } +TEST(RequestBuilderTest, ResumeSessionConfigNewDataFieldsSerialize) +{ + ResumeSessionConfig config; + config.model_capabilities = json{{"supports", {{"reasoningEffort", true}}}}; + config.commands = std::vector{json{{"name", "summarize"}}}; + config.default_agent = DefaultAgentConfig{.excluded_tools = std::vector{"web_search"}}; + config.agent = "planner"; + config.system_message = SystemMessageConfig{ + .mode = SystemMessageMode::Customize, + .sections = std::map{ + {"identity", SectionOverride{.action = SectionOverrideAction::Append, .content = "Stay in role."}} + } + }; + + auto request = build_session_resume_request("sess-1", config); + EXPECT_EQ(request["modelCapabilities"]["supports"]["reasoningEffort"], true); + EXPECT_EQ(request["commands"][0]["name"], "summarize"); + EXPECT_EQ(request["defaultAgent"]["excludedTools"][0], "web_search"); + EXPECT_EQ(request["agent"], "planner"); + EXPECT_EQ(request["systemMessage"]["mode"], "customize"); + EXPECT_EQ(request["systemMessage"]["sections"]["identity"]["action"], "append"); +} + TEST(RequestBuilderTest, EmptyHooksNotSent) { SessionConfig config; From 083664f25e5b2dadef09a2e375cbfcb8ee20ba44 Mon Sep 17 00:00:00 2001 From: Elias Bachaalany Date: Mon, 8 Jun 2026 19:33:02 -0700 Subject: [PATCH 14/15] feat: add elicitation, exit-plan-mode, auto-mode-switch handlers and conformance tests Add callback handler types (ElicitationContext/Result, ExitPlanModeRequest/Result, AutoModeSwitchRequest/Response) with registration, dispatch, and default behaviors. Wire up new handlers in Client request dispatch and SessionConfig. Add comprehensive conformance tests for all new handler types and parity types. --- include/copilot/client.hpp | 4 + include/copilot/session.hpp | 47 ++++ include/copilot/types.hpp | 277 ++++++++++++++++++++++ src/client.cpp | 81 +++++++ src/session.cpp | 83 +++++++ tests/test_conformance.cpp | 457 +++++++++++++++++++++++++++++++++++- 6 files changed, 946 insertions(+), 3 deletions(-) diff --git a/include/copilot/client.hpp b/include/copilot/client.hpp index 6731dc7..fb6624a 100644 --- a/include/copilot/client.hpp +++ b/include/copilot/client.hpp @@ -258,6 +258,10 @@ class Client /// Handle incoming user input requests json handle_user_input_request(const json& params); + json handle_elicitation_request(const json& params); + json handle_exit_plan_mode_request(const json& params); + json handle_auto_mode_switch_request(const json& params); + /// Handle incoming hook invocations json handle_hooks_invoke(const json& params); diff --git a/include/copilot/session.hpp b/include/copilot/session.hpp index 0d62c65..8cf6c29 100644 --- a/include/copilot/session.hpp +++ b/include/copilot/session.hpp @@ -169,6 +169,9 @@ class Session : public std::enable_shared_from_this /// @return Subscription handle (unsubscribes on destruction) Subscription on(EventHandler handler); + /// Register an event handler that remains active for the session lifetime. + void register_persistent_event_handler(EventHandler handler); + /// Dispatch an event to all subscribers (called by Client) void dispatch_event(const SessionEvent& event); @@ -210,6 +213,36 @@ class Session : public std::enable_shared_from_this /// Handle a user input request (called by Client) UserInputResponse handle_user_input_request(const UserInputRequest& request); + // ========================================================================= + // Elicitation Handling + // ========================================================================= + + /// Register a handler for elicitation requests + void register_elicitation_handler(ElicitationHandler handler); + + /// Handle an elicitation request (called by Client) + ElicitationResult handle_elicitation_request(const ElicitationContext& context); + + // ========================================================================= + // Exit Plan Mode Handling + // ========================================================================= + + /// Register a handler for exit-plan-mode requests + void register_exit_plan_mode_handler(ExitPlanModeHandler handler); + + /// Handle an exit-plan-mode request (called by Client) + ExitPlanModeResult handle_exit_plan_mode_request(const ExitPlanModeRequest& request); + + // ========================================================================= + // Auto Mode Switch Handling + // ========================================================================= + + /// Register a handler for auto-mode-switch requests + void register_auto_mode_switch_handler(AutoModeSwitchHandler handler); + + /// Handle an auto-mode-switch request (called by Client) + AutoModeSwitchResponse handle_auto_mode_switch_request(const AutoModeSwitchRequest& request); + // ========================================================================= // Hooks // ========================================================================= @@ -282,6 +315,8 @@ class Session : public std::enable_shared_from_this mutable std::mutex handlers_mutex_; std::vector> event_handlers_; int next_handler_id_ = 0; + std::mutex owned_event_subscriptions_mutex_; + std::vector owned_event_subscriptions_; // Tools mutable std::mutex tools_mutex_; @@ -294,6 +329,18 @@ class Session : public std::enable_shared_from_this std::mutex user_input_mutex_; UserInputHandler user_input_handler_; + // Elicitation handler + std::mutex elicitation_mutex_; + ElicitationHandler elicitation_handler_; + + // Exit plan mode handler + std::mutex exit_plan_mode_mutex_; + ExitPlanModeHandler exit_plan_mode_handler_; + + // Auto mode switch handler + std::mutex auto_mode_switch_mutex_; + AutoModeSwitchHandler auto_mode_switch_handler_; + // Hooks std::mutex hooks_mutex_; std::optional hooks_; diff --git a/include/copilot/types.hpp b/include/copilot/types.hpp index 1b6156e..2cd3303 100644 --- a/include/copilot/types.hpp +++ b/include/copilot/types.hpp @@ -27,6 +27,7 @@ using json = nlohmann::json; // Forward declarations class Session; struct SessionEvent; +using EventHandler = std::function; // ============================================================================= // Protocol Version @@ -380,6 +381,258 @@ struct UserInputInvocation /// Handler for user input requests from the agent using UserInputHandler = std::function; +// ============================================================================= +// Elicitation Types +// ============================================================================= + +/// Elicitation display mode +enum class ElicitationRequestedMode +{ + Form, + Url +}; + +NLOHMANN_JSON_SERIALIZE_ENUM( + ElicitationRequestedMode, + { + {ElicitationRequestedMode::Form, "form"}, + {ElicitationRequestedMode::Url, "url"}, + } +) + +/// JSON Schema for elicitation form fields +struct ElicitationSchema +{ + std::string type = "object"; + std::optional> properties; + std::optional> required; +}; + +inline void to_json(json& j, const ElicitationSchema& s) +{ + j = json{{"type", s.type}}; + if (s.properties) + j["properties"] = *s.properties; + if (s.required) + j["required"] = *s.required; +} + +inline void from_json(const json& j, ElicitationSchema& s) +{ + if (j.contains("type")) + j.at("type").get_to(s.type); + if (j.contains("properties")) + s.properties = j.at("properties").get>(); + if (j.contains("required")) + s.required = j.at("required").get>(); +} + +/// User action for elicitation response +enum class ElicitationAction +{ + Accept, + Decline, + Cancel +}; + +NLOHMANN_JSON_SERIALIZE_ENUM( + ElicitationAction, + { + {ElicitationAction::Accept, "accept"}, + {ElicitationAction::Decline, "decline"}, + {ElicitationAction::Cancel, "cancel"}, + } +) + +/// Context for an elicitation request from the server +struct ElicitationContext +{ + std::string session_id; + std::string message; + std::optional requested_schema; + std::optional mode; + std::optional elicitation_source; + std::optional url; +}; + +inline void from_json(const json& j, ElicitationContext& c) +{ + if (j.contains("sessionId")) + j.at("sessionId").get_to(c.session_id); + j.at("message").get_to(c.message); + if (j.contains("requestedSchema") && !j["requestedSchema"].is_null()) + c.requested_schema = j.at("requestedSchema").get(); + if (j.contains("mode") && !j["mode"].is_null()) + c.mode = j.at("mode").get(); + if (j.contains("elicitationSource") && !j["elicitationSource"].is_null()) + c.elicitation_source = j.at("elicitationSource").get(); + if (j.contains("url") && !j["url"].is_null()) + c.url = j.at("url").get(); +} + +inline void to_json(json& j, const ElicitationContext& c) +{ + j = json{{"message", c.message}}; + if (!c.session_id.empty()) + j["sessionId"] = c.session_id; + if (c.requested_schema) + j["requestedSchema"] = *c.requested_schema; + if (c.mode) + j["mode"] = *c.mode; + if (c.elicitation_source) + j["elicitationSource"] = *c.elicitation_source; + if (c.url) + j["url"] = *c.url; +} + +/// Result returned from an elicitation dialog +struct ElicitationResult +{ + ElicitationAction action = ElicitationAction::Cancel; + std::optional> content; +}; + +inline void to_json(json& j, const ElicitationResult& r) +{ + j = json{{"action", r.action}}; + if (r.content) + j["content"] = *r.content; +} + +inline void from_json(const json& j, ElicitationResult& r) +{ + j.at("action").get_to(r.action); + if (j.contains("content") && !j["content"].is_null()) + r.content = j.at("content").get>(); +} + +/// Elicitation handler function type +using ElicitationHandler = std::function; + +// ============================================================================= +// Exit Plan Mode Types +// ============================================================================= + +/// Request to exit plan mode +struct ExitPlanModeRequest +{ + std::string summary; + std::optional plan_content; + std::vector actions; + std::string recommended_action = "autopilot"; +}; + +inline void from_json(const json& j, ExitPlanModeRequest& r) +{ + j.at("summary").get_to(r.summary); + if (j.contains("planContent") && !j["planContent"].is_null()) + r.plan_content = j.at("planContent").get(); + if (j.contains("actions")) + r.actions = j.at("actions").get>(); + if (j.contains("recommendedAction")) + j.at("recommendedAction").get_to(r.recommended_action); +} + +inline void to_json(json& j, const ExitPlanModeRequest& r) +{ + j = json{{"summary", r.summary}, {"recommendedAction", r.recommended_action}}; + if (r.plan_content) + j["planContent"] = *r.plan_content; + if (!r.actions.empty()) + j["actions"] = r.actions; +} + +/// Response to an exit-plan-mode request +struct ExitPlanModeResult +{ + bool approved = true; + std::optional selected_action; + std::optional feedback; +}; + +inline void to_json(json& j, const ExitPlanModeResult& r) +{ + j = json{{"approved", r.approved}}; + if (r.selected_action) + j["selectedAction"] = *r.selected_action; + if (r.feedback) + j["feedback"] = *r.feedback; +} + +inline void from_json(const json& j, ExitPlanModeResult& r) +{ + j.at("approved").get_to(r.approved); + if (j.contains("selectedAction") && !j["selectedAction"].is_null()) + r.selected_action = j.at("selectedAction").get(); + if (j.contains("feedback") && !j["feedback"].is_null()) + r.feedback = j.at("feedback").get(); +} + +/// Context for exit-plan-mode invocation +struct ExitPlanModeInvocation +{ + std::string session_id; +}; + +/// Exit plan mode handler function type +using ExitPlanModeHandler = + std::function; + +// ============================================================================= +// Auto Mode Switch Types +// ============================================================================= + +/// Request to switch to auto mode after a rate limit +struct AutoModeSwitchRequest +{ + std::optional error_code; + std::optional retry_after_seconds; +}; + +inline void from_json(const json& j, AutoModeSwitchRequest& r) +{ + if (j.contains("errorCode") && !j["errorCode"].is_null()) + r.error_code = j.at("errorCode").get(); + if (j.contains("retryAfterSeconds") && !j["retryAfterSeconds"].is_null()) + r.retry_after_seconds = j.at("retryAfterSeconds").get(); +} + +inline void to_json(json& j, const AutoModeSwitchRequest& r) +{ + j = json::object(); + if (r.error_code) + j["errorCode"] = *r.error_code; + if (r.retry_after_seconds) + j["retryAfterSeconds"] = *r.retry_after_seconds; +} + +/// Response to auto-mode-switch request +enum class AutoModeSwitchResponse +{ + Yes, + YesAlways, + No +}; + +NLOHMANN_JSON_SERIALIZE_ENUM( + AutoModeSwitchResponse, + { + {AutoModeSwitchResponse::Yes, "yes"}, + {AutoModeSwitchResponse::YesAlways, "yes_always"}, + {AutoModeSwitchResponse::No, "no"}, + } +) + +/// Context for auto-mode-switch invocation +struct AutoModeSwitchInvocation +{ + std::string session_id; +}; + +/// Auto mode switch handler function type +using AutoModeSwitchHandler = + std::function; + // ============================================================================= // Hook Handler Types // ============================================================================= @@ -1150,6 +1403,18 @@ struct SessionConfig /// Handler for user input requests from the agent (enables ask_user tool). std::optional on_user_input_request; + /// Handler for elicitation requests from the server. + std::optional on_elicitation_request; + + /// Handler for exit-plan-mode requests from the agent. + std::optional on_exit_plan_mode; + + /// Handler for auto-mode-switch requests. + std::optional on_auto_mode_switch; + + /// Pre-registered event handler - wired up when session is created. + std::optional on_event; + /// Hook handlers for session lifecycle events. std::optional hooks; @@ -1237,6 +1502,18 @@ struct ResumeSessionConfig /// Handler for user input requests from the agent (enables ask_user tool). std::optional on_user_input_request; + /// Handler for elicitation requests from the server. + std::optional on_elicitation_request; + + /// Handler for exit-plan-mode requests from the agent. + std::optional on_exit_plan_mode; + + /// Handler for auto-mode-switch requests. + std::optional on_auto_mode_switch; + + /// Pre-registered event handler - wired up when session is created. + std::optional on_event; + /// Hook handlers for session lifecycle events. std::optional hooks; diff --git a/src/client.cpp b/src/client.cpp index de2a75d..1e4fc9e 100644 --- a/src/client.cpp +++ b/src/client.cpp @@ -708,6 +708,12 @@ void Client::connect_to_server() return handle_permission_request(params); else if (method == "userInput.request") return handle_user_input_request(params); + else if (method == "elicitation.request") + return handle_elicitation_request(params); + else if (method == "exitPlanMode.request") + return handle_exit_plan_mode_request(params); + else if (method == "autoModeSwitch.request") + return handle_auto_mode_switch_request(params); else if (method == "hooks.invoke") return handle_hooks_invoke(params); throw JsonRpcError(JsonRpcErrorCode::MethodNotFound, "Unknown method: " + method); @@ -794,6 +800,15 @@ std::future> Client::create_session(SessionConfig confi if (config.on_user_input_request.has_value()) session->register_user_input_handler(*config.on_user_input_request); + if (config.on_elicitation_request.has_value()) + session->register_elicitation_handler(*config.on_elicitation_request); + if (config.on_exit_plan_mode.has_value()) + session->register_exit_plan_mode_handler(*config.on_exit_plan_mode); + if (config.on_auto_mode_switch.has_value()) + session->register_auto_mode_switch_handler(*config.on_auto_mode_switch); + if (config.on_event.has_value()) + session->register_persistent_event_handler(*config.on_event); + // Register hooks locally (server will call hooks.invoke) if (config.hooks.has_value()) session->register_hooks(*config.hooks); @@ -846,6 +861,15 @@ Client::resume_session(const std::string& session_id, ResumeSessionConfig config if (config.on_user_input_request.has_value()) session->register_user_input_handler(*config.on_user_input_request); + if (config.on_elicitation_request.has_value()) + session->register_elicitation_handler(*config.on_elicitation_request); + if (config.on_exit_plan_mode.has_value()) + session->register_exit_plan_mode_handler(*config.on_exit_plan_mode); + if (config.on_auto_mode_switch.has_value()) + session->register_auto_mode_switch_handler(*config.on_auto_mode_switch); + if (config.on_event.has_value()) + session->register_persistent_event_handler(*config.on_event); + // Register hooks locally (server will call hooks.invoke) if (config.hooks.has_value()) session->register_hooks(*config.hooks); @@ -1264,6 +1288,63 @@ json Client::handle_user_input_request(const json& params) } } +json Client::handle_elicitation_request(const json& params) +{ + std::string session_id = params["sessionId"].get(); + + auto session = get_session(session_id); + if (!session) + throw JsonRpcError(JsonRpcErrorCode::InvalidParams, "Unknown session " + session_id); + + try + { + auto context = params.get(); + return json(session->handle_elicitation_request(context)); + } + catch (const std::exception& e) + { + throw JsonRpcError(JsonRpcErrorCode::InternalError, e.what()); + } +} + +json Client::handle_exit_plan_mode_request(const json& params) +{ + std::string session_id = params["sessionId"].get(); + + auto session = get_session(session_id); + if (!session) + throw JsonRpcError(JsonRpcErrorCode::InvalidParams, "Unknown session " + session_id); + + try + { + auto request = params.get(); + return json(session->handle_exit_plan_mode_request(request)); + } + catch (const std::exception& e) + { + throw JsonRpcError(JsonRpcErrorCode::InternalError, e.what()); + } +} + +json Client::handle_auto_mode_switch_request(const json& params) +{ + std::string session_id = params["sessionId"].get(); + + auto session = get_session(session_id); + if (!session) + throw JsonRpcError(JsonRpcErrorCode::InvalidParams, "Unknown session " + session_id); + + try + { + auto request = params.get(); + return json(session->handle_auto_mode_switch_request(request)); + } + catch (const std::exception& e) + { + throw JsonRpcError(JsonRpcErrorCode::InternalError, e.what()); + } +} + json Client::handle_hooks_invoke(const json& params) { std::string session_id = params["sessionId"].get(); diff --git a/src/session.cpp b/src/session.cpp index 45271eb..1efc35b 100644 --- a/src/session.cpp +++ b/src/session.cpp @@ -174,6 +174,13 @@ Subscription Session::on(EventHandler handler) ); } +void Session::register_persistent_event_handler(EventHandler handler) +{ + auto subscription = on(std::move(handler)); + std::lock_guard lock(owned_event_subscriptions_mutex_); + owned_event_subscriptions_.push_back(std::move(subscription)); +} + void Session::dispatch_event(const SessionEvent& event) { std::vector handlers_copy; @@ -269,6 +276,82 @@ UserInputResponse Session::handle_user_input_request(const UserInputRequest& req return handler(request, invocation); } +// ============================================================================= +// Elicitation Handling +// ============================================================================= + +void Session::register_elicitation_handler(ElicitationHandler handler) +{ + std::lock_guard lock(elicitation_mutex_); + elicitation_handler_ = std::move(handler); +} + +ElicitationResult Session::handle_elicitation_request(const ElicitationContext& context) +{ + ElicitationHandler handler; + { + std::lock_guard lock(elicitation_mutex_); + handler = elicitation_handler_; + } + + if (!handler) + return ElicitationResult{ElicitationAction::Cancel}; + + return handler(context); +} + +// ============================================================================= +// Exit Plan Mode Handling +// ============================================================================= + +void Session::register_exit_plan_mode_handler(ExitPlanModeHandler handler) +{ + std::lock_guard lock(exit_plan_mode_mutex_); + exit_plan_mode_handler_ = std::move(handler); +} + +ExitPlanModeResult Session::handle_exit_plan_mode_request(const ExitPlanModeRequest& request) +{ + ExitPlanModeHandler handler; + { + std::lock_guard lock(exit_plan_mode_mutex_); + handler = exit_plan_mode_handler_; + } + + if (!handler) + return ExitPlanModeResult{}; + + ExitPlanModeInvocation invocation; + invocation.session_id = session_id_; + return handler(request, invocation); +} + +// ============================================================================= +// Auto Mode Switch Handling +// ============================================================================= + +void Session::register_auto_mode_switch_handler(AutoModeSwitchHandler handler) +{ + std::lock_guard lock(auto_mode_switch_mutex_); + auto_mode_switch_handler_ = std::move(handler); +} + +AutoModeSwitchResponse Session::handle_auto_mode_switch_request(const AutoModeSwitchRequest& request) +{ + AutoModeSwitchHandler handler; + { + std::lock_guard lock(auto_mode_switch_mutex_); + handler = auto_mode_switch_handler_; + } + + if (!handler) + return AutoModeSwitchResponse::No; + + AutoModeSwitchInvocation invocation; + invocation.session_id = session_id_; + return handler(request, invocation); +} + // ============================================================================= // Hooks // ============================================================================= diff --git a/tests/test_conformance.cpp b/tests/test_conformance.cpp index b1b854b..00cdf27 100644 --- a/tests/test_conformance.cpp +++ b/tests/test_conformance.cpp @@ -19,7 +19,8 @@ /// * Omission tests for v0.1.49 SessionConfig / ResumeSessionConfig fields /// (no field on the wire when the option is not set). /// * Pending lifecycle: server-side `tool.call`, `permission.request`, -/// `userInput.request` requests dispatched through a stub RPC peer, with +/// `userInput.request`, `elicitation.request`, `exitPlanMode.request`, +/// and `autoModeSwitch.request` dispatched through a stub RPC peer, with /// reply payload assertions. /// * Session-event fixture parsing through `parse_session_event` / /// `json::get()` for major event families. @@ -573,7 +574,326 @@ TEST(ConformanceSessionPayload, ResumeRequestOmitsAllV0149FieldsByDefault) } // ============================================================================= -// Section C. Pending lifecycle: tool.call / permission.request / userInput.request +// Section C. Callback + parity type serialization +// ============================================================================= + +TEST(ConformanceTypes, ElicitationRequestedModeEnumRoundTrip) +{ + const std::vector> cases = { + {ElicitationRequestedMode::Form, "form"}, + {ElicitationRequestedMode::Url, "url"}, + }; + + for (const auto& [value, wire] : cases) + { + json j = value; + EXPECT_EQ(j, wire); + EXPECT_EQ(j.get(), value); + } +} + +TEST(ConformanceTypes, ElicitationActionEnumRoundTrip) +{ + const std::vector> cases = { + {ElicitationAction::Accept, "accept"}, + {ElicitationAction::Decline, "decline"}, + {ElicitationAction::Cancel, "cancel"}, + }; + + for (const auto& [value, wire] : cases) + { + json j = value; + EXPECT_EQ(j, wire); + EXPECT_EQ(j.get(), value); + } +} + +TEST(ConformanceTypes, AutoModeSwitchResponseEnumRoundTrip) +{ + const std::vector> cases = { + {AutoModeSwitchResponse::Yes, "yes"}, + {AutoModeSwitchResponse::YesAlways, "yes_always"}, + {AutoModeSwitchResponse::No, "no"}, + }; + + for (const auto& [value, wire] : cases) + { + json j = value; + EXPECT_EQ(j, wire); + EXPECT_EQ(j.get(), value); + } +} + +TEST(ConformanceTypes, ElicitationSchemaRoundTrip) +{ + ElicitationSchema schema; + schema.properties = std::map{ + {"name", json{{"type", "string"}}}, + {"age", json{{"type", "number"}, {"minimum", 0}}}, + }; + schema.required = std::vector{"name"}; + + json j = schema; + EXPECT_EQ(j["type"], "object"); + EXPECT_EQ(j["properties"]["name"]["type"], "string"); + EXPECT_EQ(j["required"][0], "name"); + + auto parsed = j.get(); + EXPECT_EQ(parsed.type, "object"); + ASSERT_TRUE(parsed.properties.has_value()); + ASSERT_TRUE(parsed.required.has_value()); + EXPECT_EQ(parsed.properties->at("age")["minimum"], 0); + EXPECT_EQ((*parsed.required)[0], "name"); +} + +TEST(ConformanceTypes, ElicitationContextFromJsonAllFields) +{ + json input = { + {"sessionId", "sess-elicit"}, + {"message", "Need more info"}, + {"requestedSchema", + {{"type", "object"}, + {"properties", {{"email", {{"type", "string"}, {"format", "email"}}}}}, + {"required", json::array({"email"})}}}, + {"mode", "form"}, + {"elicitationSource", "tool"}, + {"url", "https://example.invalid/form"}, + }; + + auto context = input.get(); + EXPECT_EQ(context.session_id, "sess-elicit"); + EXPECT_EQ(context.message, "Need more info"); + ASSERT_TRUE(context.requested_schema.has_value()); + ASSERT_TRUE(context.requested_schema->properties.has_value()); + EXPECT_EQ(context.requested_schema->properties->at("email")["format"], "email"); + EXPECT_EQ(context.mode, std::optional(ElicitationRequestedMode::Form)); + EXPECT_EQ(context.elicitation_source, std::optional("tool")); + EXPECT_EQ(context.url, std::optional("https://example.invalid/form")); +} + +TEST(ConformanceTypes, ElicitationResultRoundTripVariants) +{ + ElicitationResult accepted; + accepted.action = ElicitationAction::Accept; + accepted.content = std::map{ + {"choice", "allow"}, + {"confidence", 0.9}, + }; + + json accepted_json = accepted; + EXPECT_EQ(accepted_json["action"], "accept"); + EXPECT_EQ(accepted_json["content"]["choice"], "allow"); + + auto accepted_back = accepted_json.get(); + EXPECT_EQ(accepted_back.action, ElicitationAction::Accept); + ASSERT_TRUE(accepted_back.content.has_value()); + EXPECT_EQ(accepted_back.content->at("confidence"), 0.9); + + ElicitationResult cancelled; + cancelled.action = ElicitationAction::Cancel; + json cancelled_json = cancelled; + EXPECT_EQ(cancelled_json, (json{{"action", "cancel"}})); + + auto cancelled_back = cancelled_json.get(); + EXPECT_EQ(cancelled_back.action, ElicitationAction::Cancel); + EXPECT_FALSE(cancelled_back.content.has_value()); +} + +TEST(ConformanceTypes, ExitPlanModeRequestRoundTrip) +{ + ExitPlanModeRequest request; + request.summary = "Plan complete"; + request.plan_content = "1. Build\n2. Test"; + request.actions = {"autopilot", "stay_in_plan", "cancel"}; + request.recommended_action = "stay_in_plan"; + + json j = request; + EXPECT_EQ(j["summary"], "Plan complete"); + EXPECT_EQ(j["planContent"], "1. Build\n2. Test"); + EXPECT_EQ(j["actions"][1], "stay_in_plan"); + EXPECT_EQ(j["recommendedAction"], "stay_in_plan"); + + auto parsed = j.get(); + EXPECT_EQ(parsed.summary, request.summary); + EXPECT_EQ(parsed.plan_content, request.plan_content); + EXPECT_EQ(parsed.actions, request.actions); + EXPECT_EQ(parsed.recommended_action, request.recommended_action); +} + +TEST(ConformanceTypes, ExitPlanModeResultRoundTripVariants) +{ + ExitPlanModeResult approved; + approved.approved = true; + approved.selected_action = "autopilot"; + approved.feedback = "Proceed"; + + json approved_json = approved; + EXPECT_TRUE(approved_json["approved"].get()); + EXPECT_EQ(approved_json["selectedAction"], "autopilot"); + EXPECT_EQ(approved_json["feedback"], "Proceed"); + + auto approved_back = approved_json.get(); + EXPECT_TRUE(approved_back.approved); + EXPECT_EQ(approved_back.selected_action, std::optional("autopilot")); + EXPECT_EQ(approved_back.feedback, std::optional("Proceed")); + + ExitPlanModeResult defaults; + json default_json = defaults; + EXPECT_EQ(default_json, (json{{"approved", true}})); + + auto defaults_back = default_json.get(); + EXPECT_TRUE(defaults_back.approved); + EXPECT_FALSE(defaults_back.selected_action.has_value()); + EXPECT_FALSE(defaults_back.feedback.has_value()); +} + +TEST(ConformanceTypes, AutoModeSwitchRequestRoundTrip) +{ + AutoModeSwitchRequest request; + request.error_code = "rate_limit"; + request.retry_after_seconds = 12.5; + + json j = request; + EXPECT_EQ(j["errorCode"], "rate_limit"); + EXPECT_DOUBLE_EQ(j["retryAfterSeconds"].get(), 12.5); + + auto parsed = j.get(); + EXPECT_EQ(parsed.error_code, std::optional("rate_limit")); + ASSERT_TRUE(parsed.retry_after_seconds.has_value()); + EXPECT_DOUBLE_EQ(*parsed.retry_after_seconds, 12.5); +} + +TEST(ConformanceSessionPayload, CreateRequestOmitsClientOnlyHandlerFields) +{ + SessionConfig cfg; + cfg.on_permission_request = [](const PermissionRequest&) { return PermissionRequestResult{}; }; + cfg.on_user_input_request = [](const UserInputRequest&, const UserInputInvocation&) { + return UserInputResponse{.answer = "ok"}; + }; + cfg.on_elicitation_request = [](const ElicitationContext&) { + return ElicitationResult{.action = ElicitationAction::Accept}; + }; + cfg.on_exit_plan_mode = [](const ExitPlanModeRequest&, const ExitPlanModeInvocation&) { + return ExitPlanModeResult{}; + }; + cfg.on_auto_mode_switch = [](const AutoModeSwitchRequest&, const AutoModeSwitchInvocation&) { + return AutoModeSwitchResponse::Yes; + }; + cfg.on_event = [](const SessionEvent&) {}; + + auto req = build_session_create_request(cfg); + EXPECT_TRUE(req["requestPermission"].get()); + EXPECT_TRUE(req["requestUserInput"].get()); + + for (const char* key : {"onPermissionRequest", + "onUserInputRequest", + "onElicitationRequest", + "onExitPlanMode", + "onAutoModeSwitch", + "onEvent", + "requestElicitation", + "requestExitPlanMode", + "requestAutoModeSwitch"}) + { + EXPECT_FALSE(req.contains(key)) << key; + } +} + +TEST(ConformanceTypes, SectionOverrideActionEnumRoundTrip) +{ + const std::vector> cases = { + {SectionOverrideAction::Replace, "replace"}, + {SectionOverrideAction::Remove, "remove"}, + {SectionOverrideAction::Append, "append"}, + {SectionOverrideAction::Prepend, "prepend"}, + {SectionOverrideAction::Transform, "transform"}, + }; + + for (const auto& [value, wire] : cases) + { + json j = value; + EXPECT_EQ(j, wire); + EXPECT_EQ(j.get(), value); + } +} + +TEST(ConformanceSessionPayload, SessionConfigDefaultAgentUsesExpectedWireShape) +{ + SessionConfig cfg; + cfg.default_agent = DefaultAgentConfig{.excluded_tools = std::vector{"bash", "write_file"}}; + + auto req = build_session_create_request(cfg); + ASSERT_TRUE(req.contains("defaultAgent")); + EXPECT_EQ(req["defaultAgent"]["excludedTools"], json::array({"bash", "write_file"})); +} + +TEST(ConformanceTypes, ProviderConfigRoundTripWithNewFields) +{ + ProviderConfig config{ + .type = "azure", + .headers = std::map{{"X-Test", "value"}}, + .model_id = "gpt-4.1", + .wire_model = "deployment-name", + .max_input_tokens = 12345, + .max_output_tokens = 678, + }; + + json j = config; + EXPECT_EQ(j["headers"]["X-Test"], "value"); + EXPECT_EQ(j["modelId"], "gpt-4.1"); + EXPECT_EQ(j["wireModel"], "deployment-name"); + EXPECT_EQ(j["maxPromptTokens"], 12345); + EXPECT_EQ(j["maxOutputTokens"], 678); + + auto parsed = j.get(); + ASSERT_TRUE(parsed.headers.has_value()); + EXPECT_EQ(parsed.headers->at("X-Test"), "value"); + EXPECT_EQ(parsed.model_id, std::optional("gpt-4.1")); + EXPECT_EQ(parsed.wire_model, std::optional("deployment-name")); + EXPECT_EQ(parsed.max_input_tokens, std::optional(12345)); + EXPECT_EQ(parsed.max_output_tokens, std::optional(678)); +} + +TEST(ConformanceTypes, SystemMessageModeCustomizeSerializes) +{ + json j = SystemMessageMode::Customize; + EXPECT_EQ(j, "customize"); + EXPECT_EQ(j.get(), SystemMessageMode::Customize); +} + +TEST(ConformanceTypes, McpHttpServerConfigOauthGrantTypeEnumRoundTrip) +{ + const std::vector> cases = { + {McpHttpServerConfigOauthGrantType::AuthorizationCode, "authorization_code"}, + {McpHttpServerConfigOauthGrantType::ClientCredentials, "client_credentials"}, + }; + + for (const auto& [value, wire] : cases) + { + json j = value; + EXPECT_EQ(j, wire); + EXPECT_EQ(j.get(), value); + } +} + +TEST(ConformanceDefaults, SessionCallbackDefaultsAreSafe) +{ + auto session = std::make_shared("sess-defaults", nullptr); + + auto elicitation = session->handle_elicitation_request(ElicitationContext{.message = "Need input"}); + EXPECT_EQ(elicitation.action, ElicitationAction::Cancel); + EXPECT_FALSE(elicitation.content.has_value()); + + auto exit_plan = session->handle_exit_plan_mode_request(ExitPlanModeRequest{.summary = "done"}); + EXPECT_TRUE(exit_plan.approved); + EXPECT_FALSE(exit_plan.selected_action.has_value()); + EXPECT_FALSE(exit_plan.feedback.has_value()); + + EXPECT_EQ(session->handle_auto_mode_switch_request(AutoModeSwitchRequest{}), AutoModeSwitchResponse::No); +} + +// ============================================================================= +// Section D. Pending lifecycle: tool.call / permission.request / userInput.request // ============================================================================= TEST(ConformancePending, ToolCallInvokesHandlerAndReturnsResultPayload) @@ -694,7 +1014,138 @@ TEST(ConformancePending, UserInputRequestDispatchesToHandlerAndReturnsAnswer) } // ============================================================================= -// Section D. Session-event fixture parsing +// Section E. New pending callback handlers +// ============================================================================= + +TEST(ConformancePending, ElicitationRequestDispatchesToConfiguredHandler) +{ + ConnectedHarness h; + SessionConfig cfg; + cfg.on_elicitation_request = [](const ElicitationContext& context) -> ElicitationResult + { + EXPECT_EQ(context.session_id, "sess-conf-1"); + EXPECT_EQ(context.message, "Need approval"); + EXPECT_EQ(context.mode, std::optional(ElicitationRequestedMode::Form)); + + ElicitationResult result; + result.action = ElicitationAction::Accept; + result.content = std::map{{"approved", true}, {"reason", "looks good"}}; + return result; + }; + + auto session = h.client->create_session(cfg).get(); + auto fut = h.peer->inject_request( + "elicitation.request", + json{ + {"sessionId", session->session_id()}, + {"message", "Need approval"}, + {"requestedSchema", + {{"type", "object"}, + {"properties", {{"approved", {{"type", "boolean"}}}}}, + {"required", json::array({"approved"})}}}, + {"mode", "form"}, + {"elicitationSource", "tool"}, + }); + + ASSERT_EQ(fut.wait_for(std::chrono::seconds(5)), std::future_status::ready); + json reply = fut.get(); + EXPECT_EQ(reply["action"], "accept"); + EXPECT_TRUE(reply["content"]["approved"].get()); + EXPECT_EQ(reply["content"]["reason"], "looks good"); +} + +TEST(ConformancePending, ExitPlanModeRequestDispatchesToConfiguredHandler) +{ + ConnectedHarness h; + SessionConfig cfg; + cfg.on_exit_plan_mode = + [](const ExitPlanModeRequest& request, const ExitPlanModeInvocation& invocation) -> ExitPlanModeResult + { + EXPECT_EQ(invocation.session_id, "sess-conf-1"); + EXPECT_EQ(request.summary, "Plan ready"); + EXPECT_EQ(request.recommended_action, "autopilot"); + + ExitPlanModeResult result; + result.approved = false; + result.selected_action = "stay_in_plan"; + result.feedback = "Need another review"; + return result; + }; + + auto session = h.client->create_session(cfg).get(); + auto fut = h.peer->inject_request( + "exitPlanMode.request", + json{ + {"sessionId", session->session_id()}, + {"summary", "Plan ready"}, + {"planContent", "1. Do the work"}, + {"actions", json::array({"autopilot", "stay_in_plan"})}, + {"recommendedAction", "autopilot"}, + }); + + ASSERT_EQ(fut.wait_for(std::chrono::seconds(5)), std::future_status::ready); + json reply = fut.get(); + EXPECT_FALSE(reply["approved"].get()); + EXPECT_EQ(reply["selectedAction"], "stay_in_plan"); + EXPECT_EQ(reply["feedback"], "Need another review"); +} + +TEST(ConformancePending, AutoModeSwitchRequestDispatchesToConfiguredHandler) +{ + ConnectedHarness h; + SessionConfig cfg; + cfg.on_auto_mode_switch = + [](const AutoModeSwitchRequest& request, const AutoModeSwitchInvocation& invocation) { + EXPECT_EQ(invocation.session_id, "sess-conf-1"); + EXPECT_EQ(request.error_code, std::optional("rate_limit")); + EXPECT_TRUE(request.retry_after_seconds.has_value()); + if (!request.retry_after_seconds.has_value()) + return AutoModeSwitchResponse::No; + EXPECT_DOUBLE_EQ(*request.retry_after_seconds, 30.0); + return AutoModeSwitchResponse::YesAlways; + }; + + auto session = h.client->create_session(cfg).get(); + auto fut = h.peer->inject_request( + "autoModeSwitch.request", + json{ + {"sessionId", session->session_id()}, + {"errorCode", "rate_limit"}, + {"retryAfterSeconds", 30.0}, + }); + + ASSERT_EQ(fut.wait_for(std::chrono::seconds(5)), std::future_status::ready); + EXPECT_EQ(fut.get(), "yes_always"); +} + +TEST(ConformancePending, ResumeSessionPreRegistersOnEventHandler) +{ + ConnectedHarness h; + ResumeSessionConfig cfg; + auto ready = std::make_shared>(); + auto seen = std::make_shared>(false); + auto ready_future = ready->get_future(); + + cfg.on_event = [ready, seen](const SessionEvent& event) + { + if (!seen->exchange(true)) + ready->set_value(event.type); + }; + + auto session = h.client->resume_session("sess-resume-on-event", cfg).get(); + ASSERT_TRUE(h.peer->send_notification( + "session.event", + json{ + {"sessionId", session->session_id()}, + {"event", envelope("session.idle", json::object())}, + })); + + ASSERT_EQ(ready_future.wait_for(std::chrono::seconds(5)), std::future_status::ready); + EXPECT_EQ(ready_future.get(), SessionEventType::SessionIdle); +} + +// ============================================================================= +// Section F. Session-event fixture parsing // ============================================================================= TEST(ConformanceEvents, ParsesSessionIdleFromWireEnvelope) From 4cf7c40286f0287bf287a2a58330e8a901e3dfb0 Mon Sep 17 00:00:00 2001 From: Elias Bachaalany Date: Mon, 8 Jun 2026 20:11:28 -0700 Subject: [PATCH 15/15] feat: add fluent setters for Tool skip_permission and overrides_built_in_tool Add ref-qualified with_skip_permission() and with_overrides_built_in_tool() fluent setters enabling chaining on make_tool() and ToolBuilder results. Closes #11 Co-authored-by: Tilak Patel --- include/copilot/types.hpp | 8 +++++ tests/test_tool_builder.cpp | 59 +++++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+) diff --git a/include/copilot/types.hpp b/include/copilot/types.hpp index 2cd3303..58d14d0 100644 --- a/include/copilot/types.hpp +++ b/include/copilot/types.hpp @@ -1291,6 +1291,14 @@ struct Tool /// When true, the tool can execute without a permission prompt. /// (Upstream v0.1.49+) bool skip_permission = false; + + /// Fluent setter — mark this tool as not requiring permission prompts. + Tool& with_skip_permission(bool value = true) & { skip_permission = value; return *this; } + Tool with_skip_permission(bool value = true) && { skip_permission = value; return std::move(*this); } + + /// Fluent setter — mark this tool as replacing a built-in CLI tool. + Tool& with_overrides_built_in_tool(bool value = true) & { overrides_built_in_tool = value; return *this; } + Tool with_overrides_built_in_tool(bool value = true) && { overrides_built_in_tool = value; return std::move(*this); } }; // ============================================================================= diff --git a/tests/test_tool_builder.cpp b/tests/test_tool_builder.cpp index e7ad9b5..7115a9f 100644 --- a/tests/test_tool_builder.cpp +++ b/tests/test_tool_builder.cpp @@ -440,3 +440,62 @@ TEST(MakeToolTest, IntAndBoolParams) auto result = tool.handler(inv); EXPECT_EQ(result.text_result_for_llm, "port=8080,enabled=true"); } + +// ============================================================================= +// Fluent Setter Tests (PR #11 contribution by @tilakpatell) +// ============================================================================= + +TEST(ToolFluentSetterTest, SkipPermissionOnLvalue) +{ + Tool tool; + tool.name = "safe_read"; + tool.description = "Read-only lookup"; + EXPECT_FALSE(tool.skip_permission); + + tool.with_skip_permission(); + EXPECT_TRUE(tool.skip_permission); + + tool.with_skip_permission(false); + EXPECT_FALSE(tool.skip_permission); +} + +TEST(ToolFluentSetterTest, OverridesBuiltInOnLvalue) +{ + Tool tool; + tool.name = "edit_file"; + tool.description = "Custom editor"; + EXPECT_FALSE(tool.overrides_built_in_tool); + + tool.with_overrides_built_in_tool(); + EXPECT_TRUE(tool.overrides_built_in_tool); + + tool.with_overrides_built_in_tool(false); + EXPECT_FALSE(tool.overrides_built_in_tool); +} + +TEST(ToolFluentSetterTest, ChainingOnMakeToolResult) +{ + auto tool = copilot::make_tool( + "safe_lookup", "Read-only lookup", + [](std::string query) { return "result: " + query; }, + {"query"}) + .with_skip_permission() + .with_overrides_built_in_tool(); + + EXPECT_EQ(tool.name, "safe_lookup"); + EXPECT_TRUE(tool.skip_permission); + EXPECT_TRUE(tool.overrides_built_in_tool); +} + +TEST(ToolFluentSetterTest, ChainingOnToolBuilder) +{ + auto tool = ToolBuilder("my_tool", "A tool") + .param("input", "Input text") + .handler([](const std::string& s) { return s; }) + .with_skip_permission() + .with_overrides_built_in_tool(); + + EXPECT_TRUE(tool.skip_permission); + EXPECT_TRUE(tool.overrides_built_in_tool); + EXPECT_EQ(tool.name, "my_tool"); +}