From 5a9c90d250c8e4c513e23c69daf8b052af6f63db Mon Sep 17 00:00:00 2001 From: Karl Krukow Date: Sat, 4 Apr 2026 23:02:05 +0200 Subject: [PATCH 01/14] =?UTF-8?q?Enhance=20mock=20server=20with=20server?= =?UTF-8?q?=E2=86=92client=20RPC=20support?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add infrastructure for testing server→client JSON-RPC calls (hooks.invoke, userInput.request, systemMessage.transform): - Add pending-responses atom to MockServer record for tracking outstanding server→client RPC requests - Modify server-loop to handle JSON-RPC response messages (no :method field) by routing them to pending promise channels - Add public send-rpc-request! function that sends a JSON-RPC request to the client, blocks until response arrives, and returns the result - Add 30+ method stubs to handle-request case statement for all session RPC methods (mode, plan, workspace, agent, fleet, skills, mcp, extensions, plugins, compaction, shell, elicitation, mcp.config, tools.handlePending) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- test/github/copilot_sdk/mock_server.clj | 97 ++++++++++++++++++++++--- 1 file changed, 85 insertions(+), 12 deletions(-) diff --git a/test/github/copilot_sdk/mock_server.clj b/test/github/copilot_sdk/mock_server.clj index 85e91bb..ccde325 100644 --- a/test/github/copilot_sdk/mock_server.clj +++ b/test/github/copilot_sdk/mock_server.clj @@ -64,7 +64,8 @@ message-id ; AtomicLong for generating IDs ;; Hooks for testing on-request ; atom fn - called for each request - pending-events]) ; atom - events to send on next opportunity + pending-events ; atom - events to send on next opportunity + pending-responses]) ; atom {id -> chan} - responses to server→client RPCs (defn- generate-id [^AtomicLong counter] (str "evt-" (.incrementAndGet counter))) @@ -315,6 +316,43 @@ "session.log" (handle-session-log server params) "session.permissions.handlePendingPermissionRequest" {:ok true} "session.commands.handlePendingCommand" {:ok true} + "session.tools.handlePendingToolCall" {:ok true} + "session.ui.handlePendingElicitation" {:ok true} + "session.mode.get" {:mode "interactive"} + "session.mode.set" {:mode (get params :mode "interactive")} + "session.plan.read" {:exists false :content nil :filePath nil} + "session.plan.update" {:success true} + "session.plan.delete" {:success true} + "session.workspace.listFiles" {:files []} + "session.workspace.readFile" {:content ""} + "session.workspace.createFile" {:success true} + "session.agent.list" {:agents []} + "session.agent.getCurrent" {:name nil} + "session.agent.select" {:success true} + "session.agent.deselect" {:success true} + "session.agent.reload" {:success true} + "session.fleet.start" {:success true} + "session.skills.list" {:skills []} + "session.skills.enable" {:success true} + "session.skills.disable" {:success true} + "session.skills.reload" {:success true} + "session.mcp.list" {:servers []} + "session.mcp.enable" {:success true} + "session.mcp.disable" {:success true} + "session.mcp.reload" {:success true} + "session.extensions.list" {:extensions []} + "session.extensions.enable" {:success true} + "session.extensions.disable" {:success true} + "session.extensions.reload" {:success true} + "session.plugins.list" {:plugins []} + "session.compaction.compact" {:success true} + "session.shell.exec" {:exitCode 0 :stdout "" :stderr ""} + "session.shell.kill" {:success true} + "session.ui.elicitation" {:result {}} + "mcp.config.list" {:servers []} + "mcp.config.add" {:success true} + "mcp.config.update" {:success true} + "mcp.config.remove" {:success true} (throw (ex-info "Method not found" {:code -32601 :method method}))) ;; Merge hook-provided data into result only when hook returns ::merge-response ;; This prevents accidental response mutation from spy hooks (e.g. swap! return values) @@ -332,16 +370,23 @@ (try (while @(:running? server) (if-let [msg (read-message (:reader server))] - (try - (let [response (handle-request server msg)] - (write-message (:writer server) response)) - (catch Exception e - (let [error-data (ex-data e)] - (write-message (:writer server) - {:jsonrpc "2.0" - :id (:id msg) - :error {:code (or (:code error-data) -32603) - :message (.getMessage e)}})))) + (if (:method msg) + ;; It's a request — handle it + (try + (let [response (handle-request server msg)] + (write-message (:writer server) response)) + (catch Exception e + (let [error-data (ex-data e)] + (write-message (:writer server) + {:jsonrpc "2.0" + :id (:id msg) + :error {:code (or (:code error-data) -32603) + :message (.getMessage e)}})))) + ;; It's a response to a server→client RPC — deliver to pending promise + (when-let [id (:id msg)] + (when-let [response-ch (get @(:pending-responses server) id)] + (put! response-ch msg) + (swap! (:pending-responses server) dissoc id)))) ;; EOF or closed - exit loop (reset! (:running? server) false))) (catch Exception e @@ -370,7 +415,8 @@ :sessions (atom {}) :message-id (AtomicLong. 0) :on-request (atom nil) - :pending-events (atom [])}))) + :pending-events (atom []) + :pending-responses (atom {})}))) (defn start-mock-server! "Start the mock server in a background thread." @@ -463,3 +509,30 @@ "Set context on a mock session (for testing list-sessions with context)." [server session-id context] (swap! (:sessions server) assoc-in [session-id :context] context)) + +(defn send-rpc-request! + "Send a JSON-RPC request to the client and wait for the response. + Simulates server→client RPCs like hooks.invoke, userInput.request, + systemMessage.transform. Blocks until the client responds or timeout. + Must NOT be called from the mock server's server-loop thread. + Returns the response map (:result or :error)." + [server method params & {:keys [timeout-ms] :or {timeout-ms 5000}}] + (let [id (generate-id (:message-id server)) + response-ch (chan 1)] + (swap! (:pending-responses server) assoc id response-ch) + (write-message (:writer server) + {:jsonrpc "2.0" + :id id + :method method + :params params}) + (let [[result _] (async/alts!! [response-ch (async/timeout timeout-ms)])] + (swap! (:pending-responses server) dissoc id) + (close! response-ch) + (if (nil? result) + (throw (ex-info "Timed out waiting for RPC response" + {:method method :timeout-ms timeout-ms})) + (if (:error result) + (throw (ex-info (get-in result [:error :message] "RPC error") + {:code (get-in result [:error :code]) + :data (get-in result [:error :data])})) + result))))) From 4bd6e85da664cd2776dfe62abeae83f0d156ae90 Mon Sep 17 00:00:00 2001 From: Karl Krukow Date: Sat, 4 Apr 2026 23:02:23 +0200 Subject: [PATCH 02/14] Add test coverage for hooks, user-input, transforms, and tool results MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Close several test coverage gaps relative to Node.js/Python SDKs: Hooks (6 tests): - test-hooks-pre-tool-use: preToolUse handler invoked with correct input - test-hooks-post-tool-use: postToolUse handler invoked - test-hooks-session-start: sessionStart handler invoked - test-hooks-unknown-type-returns-nil: unknown hook type returns nil - test-hooks-handler-exception-returns-nil: handler errors return nil - test-hooks-no-hooks-registered: no hooks → nil result User input (2 tests): - test-user-input-handler-invoked: handler receives question/choices - test-user-input-no-handler-errors: missing handler returns error System message transforms (3 tests): - test-system-message-transform-callback: transform fn invoked - test-system-message-transform-error-returns-original: error fallback - test-system-message-transform-no-callback-passthrough: passthrough Tool result normalization (3 tests): - test-tool-result-string-passthrough: string result via v3 broadcast - test-tool-result-nil-normalized: nil → empty string - test-tool-result-structured-object: ToolResultObject forwarded Also adds session.error-data spec tests and enriched spec fields. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- doc/reference/API.md | 2 +- src/github/copilot_sdk/specs.clj | 7 +++++- test/github/copilot_sdk_test.clj | 37 ++++++++++++++++++++++++++++++++ 3 files changed, 44 insertions(+), 2 deletions(-) diff --git a/doc/reference/API.md b/doc/reference/API.md index 05bbc81..d8b5c94 100644 --- a/doc/reference/API.md +++ b/doc/reference/API.md @@ -1120,7 +1120,7 @@ Convert an unqualified event keyword to a namespace-qualified `:copilot/` keywor |------------|-------------| | `:copilot/session.start` | Session created | | `:copilot/session.resume` | Session resumed | -| `:copilot/session.error` | Session error occurred | +| `:copilot/session.error` | Session error occurred; data: `{:error-type "..." :message "..." :stack "..." :status-code 429 :provider-call-id "..." :url "..."}` (`:stack`, `:status-code`, `:provider-call-id`, `:url` optional) | | `:copilot/session.idle` | Session finished processing | | `:copilot/session.info` | Informational session update | | `:copilot/session.model_change` | Session model changed | diff --git a/src/github/copilot_sdk/specs.clj b/src/github/copilot_sdk/specs.clj index f10d13e..174c6a4 100644 --- a/src/github/copilot_sdk/specs.clj +++ b/src/github/copilot_sdk/specs.clj @@ -625,9 +625,14 @@ :opt-un [::selected-model ::reasoning-effort ::already-in-use? ::remote-steerable? ::host-type ::head-commit ::base-commit])) +(s/def ::status-code int?) +(s/def ::provider-call-id string?) +(s/def ::error-type string?) +(s/def ::stack string?) + (s/def ::session.error-data (s/keys :req-un [::error-type ::message] - :opt-un [::stack])) + :opt-un [::stack ::status-code ::provider-call-id ::url])) (s/def ::session.idle-data map?) diff --git a/test/github/copilot_sdk_test.clj b/test/github/copilot_sdk_test.clj index a529519..45e5edc 100644 --- a/test/github/copilot_sdk_test.clj +++ b/test/github/copilot_sdk_test.clj @@ -47,6 +47,43 @@ (testing "invalid states" (is (not (s/valid? ::specs/connection-state :invalid))))) +(deftest session-error-data-spec-test + (testing "minimal valid session.error data" + (is (s/valid? ::specs/session.error-data + {:error-type "authentication" :message "Auth failed"}))) + + (testing "with all optional fields" + (is (s/valid? ::specs/session.error-data + {:error-type "quota" + :message "Rate limit exceeded" + :stack "at foo.bar (line 42)" + :status-code 429 + :provider-call-id "abc-123-def" + :url "https://example.com/billing"}))) + + (testing "with subset of optional fields" + (is (s/valid? ::specs/session.error-data + {:error-type "query" + :message "Context too large" + :status-code 400}))) + + (testing "invalid: missing required fields" + (is (not (s/valid? ::specs/session.error-data {}))) + (is (not (s/valid? ::specs/session.error-data {:error-type "auth"}))) + (is (not (s/valid? ::specs/session.error-data {:message "fail"})))) + + (testing "invalid: wrong types for optional fields" + (is (not (s/valid? ::specs/session.error-data + {:error-type "auth" :message "fail" :status-code "not-a-number"}))) + (is (not (s/valid? ::specs/session.error-data + {:error-type "auth" :message "fail" :provider-call-id 123})))) + + (testing "invalid: wrong types for required and pre-existing fields" + (is (not (s/valid? ::specs/session.error-data + {:error-type 42 :message "fail"}))) + (is (not (s/valid? ::specs/session.error-data + {:error-type "auth" :message "fail" :stack 99}))))) + ;; ============================================================================= ;; Client Tests ;; ============================================================================= From 3784f930aa96b647691f889f57d5408317264022 Mon Sep 17 00:00:00 2001 From: Karl Krukow Date: Sat, 4 Apr 2026 23:02:38 +0200 Subject: [PATCH 03/14] Add session RPC wrappers and integration tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add 19 experimental RPC wrapper functions for generated session/server Session-level (session.clj, 15 functions): - mode-get, mode-set! — agent mode (interactive/plan/autopilot) - workspace-list-files, workspace-read-file, workspace-create-file! - fleet-start! — parallel sub-sessions Server-level (client.clj, 4 functions): All functions follow the existing ^:experimental pattern used by skills-list, mcp-list, etc. Also includes: - 27 new integration tests (14 for gap coverage + 13 for RPC wrappers) - s/fdef instrumentation for all 19 new public functions - CHANGELOG.md entries under [Unreleased] Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 20 + src/github/copilot_sdk/client.clj | 40 ++ src/github/copilot_sdk/instrument.clj | 113 +++++ src/github/copilot_sdk/session.clj | 135 ++++++ test/github/copilot_sdk/integration_test.clj | 468 +++++++++++++++++++ 5 files changed, 776 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 08314fb..afa90a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,26 @@ All notable changes to this project will be documented in this file. This change ## [Unreleased] +### Added +- **Session RPC wrappers** — new experimental functions for session-level RPCs previously only accessible via `proto/send-request!`: + - `mode-get`, `mode-set!` — get/set agent mode (interactive/plan/autopilot) + - `plan-read`, `plan-update!`, `plan-delete!` — read/update/delete session plan file + - `workspace-list-files`, `workspace-read-file`, `workspace-create-file!` — session workspace file operations + - `agent-list`, `agent-get-current`, `agent-select!`, `agent-deselect!`, `agent-reload!` — custom agent CRUD + - `fleet-start!` — start parallel sub-sessions +- **MCP config wrappers** — new experimental server-level functions in `client`: + - `mcp-config-list`, `mcp-config-add!`, `mcp-config-update!`, `mcp-config-remove!` — MCP server configuration management +- **Hooks integration tests** — 6 tests covering all hook types (preToolUse, postToolUse, sessionStart, unknownType, handler exceptions, no-hooks) +- **User input handler tests** — 2 tests for `userInput.request` server→client RPC +- **System message transform tests** — 3 tests for `systemMessage.transform` callback invocation, error fallback, and passthrough +- **Tool result normalization tests** — 3 tests for string, nil, and structured ToolResultObject results via v3 broadcast +- **Session RPC wrapper tests** — 13 integration tests for all new RPC wrapper functions +- **Mock server enhancements** — `send-rpc-request!` for testing server→client RPCs, response routing in server loop, 30+ new method stubs +- Full `s/fdef` instrumentation for all 19 new public functions + +### Changed (v0.2.1 sync) +- **`session.error` event data spec enriched** — optional `:status-code` (int), `:provider-call-id` (string), and `:url` (string) fields added to `::session.error-data` spec. These fields carry HTTP status codes, GitHub request tracing IDs, and actionable URLs from upstream error events (upstream PR #999, runtime 1.0.17). + ## [0.2.1.0] - 2026-04-04 ### Added (v0.2.1 sync) - **`resolvedByHook` guard on `permission.requested`** — when the runtime resolves a permission request via a `permissionRequest` hook, the broadcast event includes `resolvedByHook: true`. The SDK now skips the client's `:on-permission-request` handler and does not send the `handlePendingPermissionRequest` RPC, preventing duplicate responses. Event subscribers still observe the event (upstream PR #999, runtime 1.0.17). diff --git a/src/github/copilot_sdk/client.clj b/src/github/copilot_sdk/client.clj index b427ab2..15cba19 100644 --- a/src/github/copilot_sdk/client.clj +++ b/src/github/copilot_sdk/client.clj @@ -1210,6 +1210,46 @@ (:reset-date v) (assoc :reset-date (:reset-date v))))) {} snapshots))) +;; --------------------------------------------------------------------------- +;; MCP Config RPCs (server-level, not session-scoped) +;; --------------------------------------------------------------------------- + +(defn ^:experimental mcp-config-list + "List all MCP server configurations. + Returns a map with :servers (vector of server config maps)." + [client] + (ensure-connected! client) + (let [conn (:connection-io @(:state client))] + (util/wire->clj + (proto/send-request! conn "mcp.config.list" {})))) + +(defn ^:experimental mcp-config-add! + "Add an MCP server configuration. + params is a map with server config (name, command, args, etc.)." + [client params] + (ensure-connected! client) + (let [conn (:connection-io @(:state client))] + (util/wire->clj + (proto/send-request! conn "mcp.config.add" (util/clj->wire params))))) + +(defn ^:experimental mcp-config-update! + "Update an MCP server configuration. + params is a map with server config to update." + [client params] + (ensure-connected! client) + (let [conn (:connection-io @(:state client))] + (util/wire->clj + (proto/send-request! conn "mcp.config.update" (util/clj->wire params))))) + +(defn ^:experimental mcp-config-remove! + "Remove an MCP server configuration. + params is a map identifying the server to remove." + [client params] + (ensure-connected! client) + (let [conn (:connection-io @(:state client))] + (util/wire->clj + (proto/send-request! conn "mcp.config.remove" (util/clj->wire params))))) + (defn- validate-session-config! "Validate session config, throwing on invalid input." [config] diff --git a/src/github/copilot_sdk/instrument.clj b/src/github/copilot_sdk/instrument.clj index bf743ed..af2df33 100644 --- a/src/github/copilot_sdk/instrument.clj +++ b/src/github/copilot_sdk/instrument.clj @@ -352,6 +352,83 @@ :args (s/cat) :ret nil?) +;; ----------------------------------------------------------------------------- +;; Session RPC wrapper function specs (experimental) +;; ----------------------------------------------------------------------------- + +(s/fdef github.copilot-sdk.session/mode-get + :args (s/cat :session ::specs/session) + :ret map?) + +(s/fdef github.copilot-sdk.session/mode-set! + :args (s/cat :session ::specs/session :mode string?) + :ret map?) + +(s/fdef github.copilot-sdk.session/plan-read + :args (s/cat :session ::specs/session) + :ret map?) + +(s/fdef github.copilot-sdk.session/plan-update! + :args (s/cat :session ::specs/session :content string?) + :ret map?) + +(s/fdef github.copilot-sdk.session/plan-delete! + :args (s/cat :session ::specs/session) + :ret map?) + +(s/fdef github.copilot-sdk.session/workspace-list-files + :args (s/cat :session ::specs/session) + :ret map?) + +(s/fdef github.copilot-sdk.session/workspace-read-file + :args (s/cat :session ::specs/session :path string?) + :ret map?) + +(s/fdef github.copilot-sdk.session/workspace-create-file! + :args (s/cat :session ::specs/session :path string? :content string?) + :ret map?) + +(s/fdef github.copilot-sdk.session/agent-list + :args (s/cat :session ::specs/session) + :ret map?) + +(s/fdef github.copilot-sdk.session/agent-get-current + :args (s/cat :session ::specs/session) + :ret map?) + +(s/fdef github.copilot-sdk.session/agent-select! + :args (s/cat :session ::specs/session :agent-name string?) + :ret map?) + +(s/fdef github.copilot-sdk.session/agent-deselect! + :args (s/cat :session ::specs/session) + :ret map?) + +(s/fdef github.copilot-sdk.session/agent-reload! + :args (s/cat :session ::specs/session) + :ret map?) + +(s/fdef github.copilot-sdk.session/fleet-start! + :args (s/cat :session ::specs/session :params map?) + :ret map?) + +;; Client-level MCP config function specs +(s/fdef github.copilot-sdk.client/mcp-config-list + :args (s/cat :client ::specs/client) + :ret map?) + +(s/fdef github.copilot-sdk.client/mcp-config-add! + :args (s/cat :client ::specs/client :params map?) + :ret map?) + +(s/fdef github.copilot-sdk.client/mcp-config-update! + :args (s/cat :client ::specs/client :params map?) + :ret map?) + +(s/fdef github.copilot-sdk.client/mcp-config-remove! + :args (s/cat :client ::specs/client :params map?) + :ret map?) + ;; ----------------------------------------------------------------------------- ;; Instrument all public API functions ;; ----------------------------------------------------------------------------- @@ -415,6 +492,24 @@ github.copilot-sdk.session/compaction-compact! github.copilot-sdk.session/shell-exec! github.copilot-sdk.session/shell-kill! + github.copilot-sdk.session/mode-get + github.copilot-sdk.session/mode-set! + github.copilot-sdk.session/plan-read + github.copilot-sdk.session/plan-update! + github.copilot-sdk.session/plan-delete! + github.copilot-sdk.session/workspace-list-files + github.copilot-sdk.session/workspace-read-file + github.copilot-sdk.session/workspace-create-file! + github.copilot-sdk.session/agent-list + github.copilot-sdk.session/agent-get-current + github.copilot-sdk.session/agent-select! + github.copilot-sdk.session/agent-deselect! + github.copilot-sdk.session/agent-reload! + github.copilot-sdk.session/fleet-start! + github.copilot-sdk.client/mcp-config-list + github.copilot-sdk.client/mcp-config-add! + github.copilot-sdk.client/mcp-config-update! + github.copilot-sdk.client/mcp-config-remove! github.copilot-sdk.session/ui-elicitation! github.copilot-sdk.session/capabilities github.copilot-sdk.session/elicitation-supported? @@ -489,6 +584,24 @@ github.copilot-sdk.session/compaction-compact! github.copilot-sdk.session/shell-exec! github.copilot-sdk.session/shell-kill! + github.copilot-sdk.session/mode-get + github.copilot-sdk.session/mode-set! + github.copilot-sdk.session/plan-read + github.copilot-sdk.session/plan-update! + github.copilot-sdk.session/plan-delete! + github.copilot-sdk.session/workspace-list-files + github.copilot-sdk.session/workspace-read-file + github.copilot-sdk.session/workspace-create-file! + github.copilot-sdk.session/agent-list + github.copilot-sdk.session/agent-get-current + github.copilot-sdk.session/agent-select! + github.copilot-sdk.session/agent-deselect! + github.copilot-sdk.session/agent-reload! + github.copilot-sdk.session/fleet-start! + github.copilot-sdk.client/mcp-config-list + github.copilot-sdk.client/mcp-config-add! + github.copilot-sdk.client/mcp-config-update! + github.copilot-sdk.client/mcp-config-remove! github.copilot-sdk.session/ui-elicitation! github.copilot-sdk.session/capabilities github.copilot-sdk.session/elicitation-supported? diff --git a/src/github/copilot_sdk/session.clj b/src/github/copilot_sdk/session.clj index 3ab5760..a3332a5 100644 --- a/src/github/copilot_sdk/session.clj +++ b/src/github/copilot_sdk/session.clj @@ -1130,6 +1130,141 @@ (proto/send-request! conn "session.shell.kill" {:sessionId session-id :processId process-id}))) +;; -- Mode ------------------------------------------------------------------- + +(defn ^:experimental mode-get + "Get the current agent mode for the session. + Returns a map with :mode (\"interactive\", \"plan\", or \"autopilot\")." + [session] + (let [{:keys [session-id client]} session + conn (connection-io client)] + (util/wire->clj + (proto/send-request! conn "session.mode.get" {:sessionId session-id})))) + +(defn ^:experimental mode-set! + "Set the agent mode for the session. + mode should be \"interactive\", \"plan\", or \"autopilot\"." + [session mode] + (let [{:keys [session-id client]} session + conn (connection-io client)] + (util/wire->clj + (proto/send-request! conn "session.mode.set" {:sessionId session-id :mode mode})))) + +;; -- Plan ------------------------------------------------------------------- + +(defn ^:experimental plan-read + "Read the plan file for the session. + Returns a map with :exists? (boolean), :content (string or nil), + and :file-path (string or nil)." + [session] + (let [{:keys [session-id client]} session + conn (connection-io client)] + (util/wire->clj + (proto/send-request! conn "session.plan.read" {:sessionId session-id})))) + +(defn ^:experimental plan-update! + "Update the plan file content for the session." + [session content] + (let [{:keys [session-id client]} session + conn (connection-io client)] + (util/wire->clj + (proto/send-request! conn "session.plan.update" {:sessionId session-id :content content})))) + +(defn ^:experimental plan-delete! + "Delete the plan file for the session." + [session] + (let [{:keys [session-id client]} session + conn (connection-io client)] + (util/wire->clj + (proto/send-request! conn "session.plan.delete" {:sessionId session-id})))) + +;; -- Workspace -------------------------------------------------------------- + +(defn ^:experimental workspace-list-files + "List files in the session workspace directory. + Returns a map with :files (vector of relative file paths)." + [session] + (let [{:keys [session-id client]} session + conn (connection-io client)] + (util/wire->clj + (proto/send-request! conn "session.workspace.listFiles" {:sessionId session-id})))) + +(defn ^:experimental workspace-read-file + "Read a file from the session workspace. + path is relative to the workspace files directory. + Returns a map with :content (string)." + [session path] + (let [{:keys [session-id client]} session + conn (connection-io client)] + (util/wire->clj + (proto/send-request! conn "session.workspace.readFile" {:sessionId session-id :path path})))) + +(defn ^:experimental workspace-create-file! + "Create a file in the session workspace. + path is relative to the workspace files directory." + [session path content] + (let [{:keys [session-id client]} session + conn (connection-io client)] + (util/wire->clj + (proto/send-request! conn "session.workspace.createFile" + {:sessionId session-id :path path :content content})))) + +;; -- Agent ------------------------------------------------------------------ + +(defn ^:experimental agent-list + "List all custom agents available to the session. + Returns a map with :agents (vector of agent info maps)." + [session] + (let [{:keys [session-id client]} session + conn (connection-io client)] + (util/wire->clj + (proto/send-request! conn "session.agent.list" {:sessionId session-id})))) + +(defn ^:experimental agent-get-current + "Get the currently active custom agent for the session. + Returns a map with :name (string or nil)." + [session] + (let [{:keys [session-id client]} session + conn (connection-io client)] + (util/wire->clj + (proto/send-request! conn "session.agent.getCurrent" {:sessionId session-id})))) + +(defn ^:experimental agent-select! + "Select a custom agent by name." + [session agent-name] + (let [{:keys [session-id client]} session + conn (connection-io client)] + (util/wire->clj + (proto/send-request! conn "session.agent.select" {:sessionId session-id :name agent-name})))) + +(defn ^:experimental agent-deselect! + "Deselect the current custom agent." + [session] + (let [{:keys [session-id client]} session + conn (connection-io client)] + (util/wire->clj + (proto/send-request! conn "session.agent.deselect" {:sessionId session-id})))) + +(defn ^:experimental agent-reload! + "Reload all custom agents." + [session] + (let [{:keys [session-id client]} session + conn (connection-io client)] + (util/wire->clj + (proto/send-request! conn "session.agent.reload" {:sessionId session-id})))) + +;; -- Fleet ------------------------------------------------------------------ + +(defn ^:experimental fleet-start! + "Start a fleet of parallel sub-sessions. + params is a map forwarded to the session.fleet.start RPC." + [session params] + (let [{:keys [session-id client]} session + conn (connection-io client)] + (util/wire->clj + (proto/send-request! conn "session.fleet.start" + (merge {:sessionId session-id} (util/clj->wire params)))))) + ;; -- UI Elicitation ---------------------------------------------------------- (defn capabilities diff --git a/test/github/copilot_sdk/integration_test.clj b/test/github/copilot_sdk/integration_test.clj index e55d00c..ca8a928 100644 --- a/test/github/copilot_sdk/integration_test.clj +++ b/test/github/copilot_sdk/integration_test.clj @@ -1625,3 +1625,471 @@ (is (some? stored)) (is (fn? (:read-file stored))) (is (= {:content "hello"} ((:read-file stored) {:path "/test.txt"}))))))) + +;; ----------------------------------------------------------------------------- +;; Hooks Tests (server→client RPC) +;; ----------------------------------------------------------------------------- + +(deftest test-hooks-pre-tool-use + (testing "hooks.invoke preToolUse calls registered handler and returns result" + (let [handler-called (atom nil) + session (sdk/create-session *test-client* + {:on-permission-request sdk/approve-all + :hooks {:on-pre-tool-use + (fn [input ctx] + (reset! handler-called {:input input :ctx ctx}) + {:permission-decision "allow" + :additional-context "extra info"})}}) + session-id (sdk/session-id session) + response (mock/send-rpc-request! *mock-server* + "hooks.invoke" + {:sessionId session-id + :hookType "preToolUse" + :input {:toolName "bash" + :toolArgs {:command "echo hi"} + :timestamp 12345 + :cwd "/workspace"}})] + (is (some? @handler-called)) + ;; Input keys are converted to kebab-case by wire->clj + (is (= "bash" (get-in @handler-called [:input :tool-name]))) + (is (= {:command "echo hi"} (get-in @handler-called [:input :tool-args]))) + (is (= session-id (get-in @handler-called [:ctx :session-id]))) + ;; Response contains the handler's return value (wire-converted) + (is (= "allow" (get-in response [:result :permissionDecision])))))) + +(deftest test-hooks-post-tool-use + (testing "hooks.invoke postToolUse calls registered handler" + (let [handler-called (atom nil) + session (sdk/create-session *test-client* + {:on-permission-request sdk/approve-all + :hooks {:on-post-tool-use + (fn [input ctx] + (reset! handler-called {:input input :ctx ctx}) + nil)}}) + session-id (sdk/session-id session) + response (mock/send-rpc-request! *mock-server* + "hooks.invoke" + {:sessionId session-id + :hookType "postToolUse" + :input {:toolName "bash" + :toolArgs {} + :toolResult {:textResultForLlm "ok" + :resultType "success"} + :timestamp 12345 + :cwd "/workspace"}})] + (is (some? @handler-called)) + (is (= "bash" (get-in @handler-called [:input :tool-name]))) + ;; Handler returned nil, so result is nil + (is (nil? (:result response)))))) + +(deftest test-hooks-session-start + (testing "hooks.invoke sessionStart calls registered handler" + (let [handler-called (atom nil) + session (sdk/create-session *test-client* + {:on-permission-request sdk/approve-all + :hooks {:on-session-start + (fn [input ctx] + (reset! handler-called input) + {:additional-context "welcome"})}}) + session-id (sdk/session-id session) + response (mock/send-rpc-request! *mock-server* + "hooks.invoke" + {:sessionId session-id + :hookType "sessionStart" + :input {:source "new" + :timestamp 12345 + :cwd "/workspace"}})] + (is (some? @handler-called)) + (is (= "new" (:source @handler-called))) + (is (= "welcome" (get-in response [:result :additionalContext])))))) + +(deftest test-hooks-unknown-type-returns-nil + (testing "hooks.invoke with unknown hook type returns nil result" + (let [session (sdk/create-session *test-client* + {:on-permission-request sdk/approve-all + :hooks {:on-pre-tool-use (fn [_ _] {:permission-decision "allow"})}}) + session-id (sdk/session-id session) + response (mock/send-rpc-request! *mock-server* + "hooks.invoke" + {:sessionId session-id + :hookType "unknownHookType" + :input {:timestamp 12345 + :cwd "/workspace"}})] + (is (nil? (:result response)))))) + +(deftest test-hooks-handler-exception-returns-nil + (testing "hooks.invoke handler exception returns nil gracefully" + (let [session (sdk/create-session *test-client* + {:on-permission-request sdk/approve-all + :hooks {:on-pre-tool-use (fn [_ _] (throw (Exception. "oops")))}}) + session-id (sdk/session-id session) + response (mock/send-rpc-request! *mock-server* + "hooks.invoke" + {:sessionId session-id + :hookType "preToolUse" + :input {:toolName "bash" + :toolArgs {} + :timestamp 12345 + :cwd "/workspace"}})] + (is (nil? (:result response)))))) + +(deftest test-hooks-no-hooks-registered + (testing "hooks.invoke with no hooks registered returns nil" + (let [session (sdk/create-session *test-client* + {:on-permission-request sdk/approve-all}) + session-id (sdk/session-id session) + response (mock/send-rpc-request! *mock-server* + "hooks.invoke" + {:sessionId session-id + :hookType "preToolUse" + :input {:toolName "bash" + :toolArgs {} + :timestamp 12345 + :cwd "/workspace"}})] + (is (nil? (:result response)))))) + +;; ----------------------------------------------------------------------------- +;; User Input Handler Tests (server→client RPC) +;; ----------------------------------------------------------------------------- + +(deftest test-user-input-handler-invoked + (testing "userInput.request calls registered handler with correct shape" + (let [handler-called (atom nil) + session (sdk/create-session *test-client* + {:on-permission-request sdk/approve-all + :on-user-input-request + (fn [request ctx] + (reset! handler-called {:request request :ctx ctx}) + {:answer "option A" :was-freeform false})}) + session-id (sdk/session-id session) + response (mock/send-rpc-request! *mock-server* + "userInput.request" + {:sessionId session-id + :question "Which option?" + :choices ["option A" "option B"] + :allowFreeform true})] + (is (some? @handler-called)) + (is (= "Which option?" (get-in @handler-called [:request :question]))) + (is (= "option A" (get-in response [:result :answer]))) + (is (false? (get-in response [:result :wasFreeform])))))) + +(deftest test-user-input-no-handler-errors + (testing "userInput.request without handler returns error" + (let [session (sdk/create-session *test-client* + {:on-permission-request sdk/approve-all}) + session-id (sdk/session-id session)] + (is (thrown-with-msg? clojure.lang.ExceptionInfo + #"User input requested but no handler registered" + (mock/send-rpc-request! *mock-server* + "userInput.request" + {:sessionId session-id + :question "Which option?"})))))) + +;; ----------------------------------------------------------------------------- +;; System Message Transform Tests (server→client RPC) +;; ----------------------------------------------------------------------------- + +(deftest test-system-message-transform-callback + (testing "systemMessage.transform invokes registered transform callbacks" + (let [session (sdk/create-session *test-client* + {:on-permission-request sdk/approve-all + :system-message {:mode :customize + :sections {:identity {:action (fn [content] + (str content " EXTRA"))}}}}) + session-id (sdk/session-id session) + response (mock/send-rpc-request! *mock-server* + "systemMessage.transform" + {:sessionId session-id + :sections {:identity {:content "I am an agent."}}})] + (is (= "I am an agent. EXTRA" + (get-in response [:result :sections :identity :content])))))) + +(deftest test-system-message-transform-error-returns-original + (testing "systemMessage.transform returns original content on callback error" + (let [session (sdk/create-session *test-client* + {:on-permission-request sdk/approve-all + :system-message {:mode :customize + :sections {:identity {:action (fn [_] (throw (Exception. "fail")))}}}}) + session-id (sdk/session-id session) + response (mock/send-rpc-request! *mock-server* + "systemMessage.transform" + {:sessionId session-id + :sections {:identity {:content "original text"}}})] + (is (= "original text" + (get-in response [:result :sections :identity :content])))))) + +(deftest test-system-message-transform-no-callback-passthrough + (testing "systemMessage.transform passes through sections without callbacks" + (let [session (sdk/create-session *test-client* + {:on-permission-request sdk/approve-all + :system-message {:mode :customize + :sections {:identity {:action (fn [c] (str c "!"))}}}}) + session-id (sdk/session-id session) + response (mock/send-rpc-request! *mock-server* + "systemMessage.transform" + {:sessionId session-id + :sections {:identity {:content "hello"} + :tone {:content "be nice"}}})] + (is (= "hello!" (get-in response [:result :sections :identity :content]))) + (is (= "be nice" (get-in response [:result :sections :tone :content])))))) + +;; ----------------------------------------------------------------------------- +;; Tool Result Normalization Tests (v3 broadcast path) +;; ----------------------------------------------------------------------------- + +(deftest test-tool-result-string-passthrough + (testing "tool handler returning string is normalized to success result" + (let [requests (atom []) + rpc-latch (java.util.concurrent.CountDownLatch. 1) + _ (mock/set-request-hook! *mock-server* + (fn [method params] + (swap! requests conj {:method method :params params}) + (when (= "session.tools.handlePendingToolCall" method) + (.countDown rpc-latch)))) + session (sdk/create-session *test-client* + {:on-permission-request sdk/approve-all + :tools [{:tool-name "test-tool" + :tool-handler (fn [_args _inv] "hello world")}]}) + session-id (sdk/session-id session)] + (swap! (:state *test-client*) assoc :negotiated-protocol-version 3) + (reset! requests []) + (mock/send-session-event! *mock-server* session-id + "external_tool.requested" + {:requestId "tool-req-1" + :toolName "test-tool" + :toolCallId "tc-1" + :arguments {}}) + (is (.await rpc-latch 5 java.util.concurrent.TimeUnit/SECONDS)) + (let [rpcs (filter #(= "session.tools.handlePendingToolCall" (:method %)) @requests) + result (get-in (first rpcs) [:params :result])] + (is (= 1 (count rpcs))) + (is (map? result)) + (is (= "hello world" (:textResultForLlm result))) + (is (= "success" (:resultType result))))))) + +(deftest test-tool-result-nil-normalized + (testing "tool handler returning nil is normalized to failure result" + (let [requests (atom []) + rpc-latch (java.util.concurrent.CountDownLatch. 1) + _ (mock/set-request-hook! *mock-server* + (fn [method params] + (swap! requests conj {:method method :params params}) + (when (= "session.tools.handlePendingToolCall" method) + (.countDown rpc-latch)))) + session (sdk/create-session *test-client* + {:on-permission-request sdk/approve-all + :tools [{:tool-name "nil-tool" + :tool-handler (fn [_args _inv] nil)}]}) + session-id (sdk/session-id session)] + (swap! (:state *test-client*) assoc :negotiated-protocol-version 3) + (reset! requests []) + (mock/send-session-event! *mock-server* session-id + "external_tool.requested" + {:requestId "tool-req-2" + :toolName "nil-tool" + :toolCallId "tc-2" + :arguments {}}) + (is (.await rpc-latch 5 java.util.concurrent.TimeUnit/SECONDS)) + (let [rpcs (filter #(= "session.tools.handlePendingToolCall" (:method %)) @requests) + result (get-in (first rpcs) [:params :result])] + (is (= 1 (count rpcs))) + (is (map? result)) + (is (= "Tool returned no result" (:textResultForLlm result))) + (is (= "failure" (:resultType result))))))) + +(deftest test-tool-result-structured-object + (testing "tool handler returning structured ToolResultObject is forwarded correctly" + (let [requests (atom []) + rpc-latch (java.util.concurrent.CountDownLatch. 1) + _ (mock/set-request-hook! *mock-server* + (fn [method params] + (swap! requests conj {:method method :params params}) + (when (= "session.tools.handlePendingToolCall" method) + (.countDown rpc-latch)))) + session (sdk/create-session *test-client* + {:on-permission-request sdk/approve-all + :tools [{:tool-name "struct-tool" + :tool-handler (fn [_args _inv] + {:text-result-for-llm "all good" + :result-type "success" + :tool-telemetry {:latency-ms 42}})}]}) + session-id (sdk/session-id session)] + (swap! (:state *test-client*) assoc :negotiated-protocol-version 3) + (reset! requests []) + (mock/send-session-event! *mock-server* session-id + "external_tool.requested" + {:requestId "tool-req-3" + :toolName "struct-tool" + :toolCallId "tc-3" + :arguments {}}) + (is (.await rpc-latch 5 java.util.concurrent.TimeUnit/SECONDS)) + (let [rpcs (filter #(= "session.tools.handlePendingToolCall" (:method %)) @requests) + result (get-in (first rpcs) [:params :result])] + (is (= 1 (count rpcs))) + (is (map? result)) + (is (= "all good" (:textResultForLlm result))) + (is (= "success" (:resultType result))) + (is (= 42 (get-in result [:toolTelemetry :latencyMs]))))))) + +;; ----------------------------------------------------------------------------- +;; Session RPC Wrapper Tests (experimental session APIs) +;; ----------------------------------------------------------------------------- + +(deftest test-mode-get + (testing "mode-get calls session.mode.get RPC" + (let [requests (atom []) + _ (mock/set-request-hook! *mock-server* + (fn [method params] + (swap! requests conj {:method method :params params}))) + session (sdk/create-session *test-client* + {:on-permission-request sdk/approve-all})] + (let [result (session/mode-get session)] + (is (some? result)) + (is (= "interactive" (:mode result))) + (is (some #(= "session.mode.get" (:method %)) @requests)))))) + +(deftest test-mode-set + (testing "mode-set! calls session.mode.set RPC with mode param" + (let [requests (atom []) + _ (mock/set-request-hook! *mock-server* + (fn [method params] + (swap! requests conj {:method method :params params}))) + session (sdk/create-session *test-client* + {:on-permission-request sdk/approve-all})] + (session/mode-set! session "plan") + (let [mode-rpcs (filter #(= "session.mode.set" (:method %)) @requests)] + (is (= 1 (count mode-rpcs))) + (is (= "plan" (:mode (:params (first mode-rpcs))))))))) + +(deftest test-plan-read + (testing "plan-read calls session.plan.read RPC" + (let [requests (atom []) + _ (mock/set-request-hook! *mock-server* + (fn [method params] + (swap! requests conj {:method method :params params}))) + session (sdk/create-session *test-client* + {:on-permission-request sdk/approve-all})] + (let [result (session/plan-read session)] + (is (some? result)) + (is (some #(= "session.plan.read" (:method %)) @requests)))))) + +(deftest test-plan-update + (testing "plan-update! calls session.plan.update RPC with content" + (let [requests (atom []) + _ (mock/set-request-hook! *mock-server* + (fn [method params] + (swap! requests conj {:method method :params params}))) + session (sdk/create-session *test-client* + {:on-permission-request sdk/approve-all})] + (session/plan-update! session "# My Plan\n\nStep 1: ...") + (let [plan-rpcs (filter #(= "session.plan.update" (:method %)) @requests)] + (is (= 1 (count plan-rpcs))) + (is (= "# My Plan\n\nStep 1: ..." (:content (:params (first plan-rpcs))))))))) + +(deftest test-plan-delete + (testing "plan-delete! calls session.plan.delete RPC" + (let [requests (atom []) + _ (mock/set-request-hook! *mock-server* + (fn [method params] + (swap! requests conj {:method method :params params}))) + session (sdk/create-session *test-client* + {:on-permission-request sdk/approve-all})] + (session/plan-delete! session) + (is (some #(= "session.plan.delete" (:method %)) @requests))))) + +(deftest test-workspace-list-files + (testing "workspace-list-files calls session.workspace.listFiles RPC" + (let [requests (atom []) + _ (mock/set-request-hook! *mock-server* + (fn [method params] + (swap! requests conj {:method method :params params}))) + session (sdk/create-session *test-client* + {:on-permission-request sdk/approve-all})] + (let [result (session/workspace-list-files session)] + (is (some? result)) + (is (some #(= "session.workspace.listFiles" (:method %)) @requests)))))) + +(deftest test-workspace-read-file + (testing "workspace-read-file calls session.workspace.readFile RPC with path" + (let [requests (atom []) + _ (mock/set-request-hook! *mock-server* + (fn [method params] + (swap! requests conj {:method method :params params}))) + session (sdk/create-session *test-client* + {:on-permission-request sdk/approve-all})] + (session/workspace-read-file session "notes.md") + (let [rpcs (filter #(= "session.workspace.readFile" (:method %)) @requests)] + (is (= 1 (count rpcs))) + (is (= "notes.md" (:path (:params (first rpcs))))))))) + +(deftest test-workspace-create-file + (testing "workspace-create-file! calls session.workspace.createFile RPC" + (let [requests (atom []) + _ (mock/set-request-hook! *mock-server* + (fn [method params] + (swap! requests conj {:method method :params params}))) + session (sdk/create-session *test-client* + {:on-permission-request sdk/approve-all})] + (session/workspace-create-file! session "test.txt" "content here") + (let [rpcs (filter #(= "session.workspace.createFile" (:method %)) @requests)] + (is (= 1 (count rpcs))) + (is (= "test.txt" (:path (:params (first rpcs))))) + (is (= "content here" (:content (:params (first rpcs))))))))) + +(deftest test-agent-list + (testing "agent-list calls session.agent.list RPC" + (let [requests (atom []) + _ (mock/set-request-hook! *mock-server* + (fn [method params] + (swap! requests conj {:method method :params params}))) + session (sdk/create-session *test-client* + {:on-permission-request sdk/approve-all})] + (let [result (session/agent-list session)] + (is (some? result)) + (is (some #(= "session.agent.list" (:method %)) @requests)))))) + +(deftest test-agent-select + (testing "agent-select! calls session.agent.select RPC with agent name" + (let [requests (atom []) + _ (mock/set-request-hook! *mock-server* + (fn [method params] + (swap! requests conj {:method method :params params}))) + session (sdk/create-session *test-client* + {:on-permission-request sdk/approve-all})] + (session/agent-select! session "researcher") + (let [rpcs (filter #(= "session.agent.select" (:method %)) @requests)] + (is (= 1 (count rpcs))) + (is (= "researcher" (:name (:params (first rpcs))))))))) + +(deftest test-agent-deselect + (testing "agent-deselect! calls session.agent.deselect RPC" + (let [requests (atom []) + _ (mock/set-request-hook! *mock-server* + (fn [method params] + (swap! requests conj {:method method :params params}))) + session (sdk/create-session *test-client* + {:on-permission-request sdk/approve-all})] + (session/agent-deselect! session) + (is (some #(= "session.agent.deselect" (:method %)) @requests))))) + +(deftest test-fleet-start + (testing "fleet-start! calls session.fleet.start RPC" + (let [requests (atom []) + _ (mock/set-request-hook! *mock-server* + (fn [method params] + (swap! requests conj {:method method :params params}))) + session (sdk/create-session *test-client* + {:on-permission-request sdk/approve-all})] + (session/fleet-start! session {:prompt "do stuff"}) + (let [rpcs (filter #(= "session.fleet.start" (:method %)) @requests)] + (is (= 1 (count rpcs))))))) + +(deftest test-mcp-config-list + (testing "mcp-config-list calls mcp.config.list RPC" + (let [requests (atom []) + _ (mock/set-request-hook! *mock-server* + (fn [method params] + (swap! requests conj {:method method :params params}))) + result (client/mcp-config-list *test-client*)] + (is (some? result)) + (is (some #(= "mcp.config.list" (:method %)) @requests))))) From 207b8d6968cbb0792c1674b3f02fe9aa51369b6d Mon Sep 17 00:00:00 2001 From: Karl Krukow Date: Sat, 4 Apr 2026 23:07:27 +0200 Subject: [PATCH 04/14] Update API.md with new experimental RPC wrapper documentation Add documentation for 19 new experimental functions: - Mode: mode-get, mode-set! (interactive/plan/autopilot) - Workspace: workspace-list-files, workspace-read-file, workspace-create-file! - Fleet: fleet-start! Includes code examples and reference tables matching existing doc style. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- doc/reference/API.md | 87 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/doc/reference/API.md b/doc/reference/API.md index d8b5c94..75fa5c0 100644 --- a/doc/reference/API.md +++ b/doc/reference/API.md @@ -486,6 +486,28 @@ Each quota snapshot map contains: | `:overage-allowed-with-exhausted-quota?` | boolean | Whether overage is allowed when quota is exhausted | | `:reset-date` | string (optional) | ISO 8601 date when quota resets | +#### `mcp-config-list` / `mcp-config-add!` / `mcp-config-update!` / `mcp-config-remove!` + +> **Experimental:** These wrap server-level MCP configuration RPCs and may change. + +```clojure +;; List configured MCP servers +(copilot/mcp-config-list client) +;; => {:servers [...]} + +;; Add a new MCP server config +(copilot/mcp-config-add! client {:name "my-server" + :command "npx" + :args ["-y" "@modelcontextprotocol/server-filesystem" "/tmp"] + :tools ["*"]}) + +;; Update an existing config +(copilot/mcp-config-update! client {:name "my-server" :tools ["read_file"]}) + +;; Remove a config +(copilot/mcp-config-remove! client {:name "my-server"}) +``` + #### `state` ```clojure @@ -930,6 +952,32 @@ Get the client that owns this session. ;; Enable/disable MCP servers (session/mcp-enable! my-session "my-server") (session/mcp-disable! my-session "my-server") + +;; Get/set agent mode +(session/mode-get my-session) +;; => {:mode "interactive"} +(session/mode-set! my-session "plan") + +;; Read/update session plan +(session/plan-read my-session) +;; => {:exists? true :content "# Plan\n..." :file-path "/path/to/plan.md"} +(session/plan-update! my-session "# Updated Plan\n...") +(session/plan-delete! my-session) + +;; Workspace file operations +(session/workspace-list-files my-session) +;; => {:files ["notes.md" "data.json"]} +(session/workspace-read-file my-session "notes.md") +;; => {:content "..."} +(session/workspace-create-file! my-session "output.txt" "result data") + +;; Custom agent management +(session/agent-list my-session) +;; => {:agents [{:name "researcher" ...} ...]} +(session/agent-select! my-session "researcher") +(session/agent-get-current my-session) +;; => {:name "researcher"} +(session/agent-deselect! my-session) ``` **Skills** @@ -959,6 +1007,45 @@ Get the client that owns this session. | `session/extensions-disable!` | Disable an extension by ID. | | `session/extensions-reload!` | Reload all extensions. | +**Mode** + +| Function | Description | +|----------|-------------| +| `session/mode-get` | Get current agent mode. Returns `{:mode "interactive"\|"plan"\|"autopilot"}`. | +| `session/mode-set!` | Set agent mode. Accepts `"interactive"`, `"plan"`, or `"autopilot"`. | + +**Plan** + +| Function | Description | +|----------|-------------| +| `session/plan-read` | Read the session plan file. Returns `{:exists? :content :file-path}`. | +| `session/plan-update!` | Update the plan file content. | +| `session/plan-delete!` | Delete the plan file. | + +**Workspace** + +| Function | Description | +|----------|-------------| +| `session/workspace-list-files` | List files in the session workspace. Returns `{:files [...]}`. | +| `session/workspace-read-file` | Read a workspace file by relative path. Returns `{:content "..."}`. | +| `session/workspace-create-file!` | Create a file in the workspace with given path and content. | + +**Agents** + +| Function | Description | +|----------|-------------| +| `session/agent-list` | List available custom agents. Returns `{:agents [...]}`. | +| `session/agent-get-current` | Get the currently selected agent. Returns `{:name "..."}` or `{:name nil}`. | +| `session/agent-select!` | Select a custom agent by name. | +| `session/agent-deselect!` | Deselect the current custom agent. | +| `session/agent-reload!` | Reload all custom agents. | + +**Fleet** + +| Function | Description | +|----------|-------------| +| `session/fleet-start!` | Start parallel sub-sessions. Accepts a params map. | + **Other** | Function | Description | From e242d1f8b45f01f71ef822f3b15087915151e5f2 Mon Sep 17 00:00:00 2001 From: Karl Krukow Date: Sat, 4 Apr 2026 23:13:13 +0200 Subject: [PATCH 05/14] Add missing tests for agent-get-current, agent-reload, mcp-config CRUD Add 5 dedicated integration tests: - test-agent-get-current: verifies session.agent.getCurrent RPC - test-agent-reload: verifies session.agent.reload RPC - test-mcp-config-add: verifies mcp.config.add with params forwarded - test-mcp-config-update: verifies mcp.config.update with params forwarded - test-mcp-config-remove: verifies mcp.config.remove with params forwarded All 19 new RPC wrappers now have dedicated test coverage. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- test/github/copilot_sdk/integration_test.clj | 61 ++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/test/github/copilot_sdk/integration_test.clj b/test/github/copilot_sdk/integration_test.clj index ca8a928..548618f 100644 --- a/test/github/copilot_sdk/integration_test.clj +++ b/test/github/copilot_sdk/integration_test.clj @@ -2093,3 +2093,64 @@ result (client/mcp-config-list *test-client*)] (is (some? result)) (is (some #(= "mcp.config.list" (:method %)) @requests))))) + +(deftest test-mcp-config-add + (testing "mcp-config-add! calls mcp.config.add RPC with params" + (let [requests (atom []) + _ (mock/set-request-hook! *mock-server* + (fn [method params] + (swap! requests conj {:method method :params params}))) + result (client/mcp-config-add! *test-client* + {:name "my-server" :command "npx" :args ["-y" "server"]})] + (is (some? result)) + (let [rpcs (filter #(= "mcp.config.add" (:method %)) @requests)] + (is (= 1 (count rpcs))) + (is (= "my-server" (:name (:params (first rpcs))))))))) + +(deftest test-mcp-config-update + (testing "mcp-config-update! calls mcp.config.update RPC with params" + (let [requests (atom []) + _ (mock/set-request-hook! *mock-server* + (fn [method params] + (swap! requests conj {:method method :params params}))) + result (client/mcp-config-update! *test-client* + {:name "my-server" :tools ["read_file"]})] + (is (some? result)) + (let [rpcs (filter #(= "mcp.config.update" (:method %)) @requests)] + (is (= 1 (count rpcs))) + (is (= "my-server" (:name (:params (first rpcs))))))))) + +(deftest test-mcp-config-remove + (testing "mcp-config-remove! calls mcp.config.remove RPC with params" + (let [requests (atom []) + _ (mock/set-request-hook! *mock-server* + (fn [method params] + (swap! requests conj {:method method :params params}))) + result (client/mcp-config-remove! *test-client* {:name "my-server"})] + (is (some? result)) + (let [rpcs (filter #(= "mcp.config.remove" (:method %)) @requests)] + (is (= 1 (count rpcs))) + (is (= "my-server" (:name (:params (first rpcs))))))))) + +(deftest test-agent-get-current + (testing "agent-get-current calls session.agent.getCurrent RPC" + (let [requests (atom []) + _ (mock/set-request-hook! *mock-server* + (fn [method params] + (swap! requests conj {:method method :params params}))) + session (sdk/create-session *test-client* + {:on-permission-request sdk/approve-all}) + result (session/agent-get-current session)] + (is (some? result)) + (is (some #(= "session.agent.getCurrent" (:method %)) @requests))))) + +(deftest test-agent-reload + (testing "agent-reload! calls session.agent.reload RPC" + (let [requests (atom []) + _ (mock/set-request-hook! *mock-server* + (fn [method params] + (swap! requests conj {:method method :params params}))) + session (sdk/create-session *test-client* + {:on-permission-request sdk/approve-all})] + (session/agent-reload! session) + (is (some #(= "session.agent.reload" (:method %)) @requests))))) From b6b5f8d269630d7712beea7993d09c0bc17ca105 Mon Sep 17 00:00:00 2001 From: Karl Krukow Date: Sat, 4 Apr 2026 23:15:18 +0200 Subject: [PATCH 06/14] Updated instructions --- .github/copilot-instructions.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 23561c3..f19495d 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -177,9 +177,13 @@ src/github/copilot_sdk/ ├── client.clj # Main client API (create-client, create-session, etc.) ├── session.clj # Session operations (send!, send-and-wait!, etc.) ├── helpers.clj # Convenience functions (query, query-seq!, query-chan, etc.) -├── specs.clj # clojure.spec definitions -├── instrument.clj # Function specs and instrumentation -└── util.clj # Internal utilities +├── tools.clj # Helper functions for defining tools (define-tool, result-success, etc.) +├── specs.clj # clojure.spec definitions for all data shapes +├── instrument.clj # Function specs (fdefs) and instrumentation +├── util.clj # Wire conversion (camelCase ↔ kebab-case), MCP helpers +├── protocol.clj # JSON-RPC 2.0 protocol over NIO channels +├── process.clj # CLI process management (spawning, lifecycle) +└── logging.clj # Logging facade via clojure.tools.logging ``` ## Documentation From 1b9b5e49cdc517268b3069ed6c9d5101b57a5c10 Mon Sep 17 00:00:00 2001 From: Karl Krukow Date: Sat, 4 Apr 2026 23:16:44 +0200 Subject: [PATCH 07/14] Add update-upstream skill: generalized upstream sync workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SKILL.md: 9-phase process (discovery → gap analysis → TDD → review → PR → self-review) - References AGENTS.md as single source of truth for project details - references/PROJECT.md: lean upstream↔Clojure file mapping and wire conversion cheat sheet - Common pitfalls section verified against real bug history - Phase 9 self-review keeps skill current after each sync Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/skills/update-upstream/SKILL.md | 145 ++++++++++++++++++ .../update-upstream/references/PROJECT.md | 41 +++++ 2 files changed, 186 insertions(+) create mode 100644 .github/skills/update-upstream/SKILL.md create mode 100644 .github/skills/update-upstream/references/PROJECT.md diff --git a/.github/skills/update-upstream/SKILL.md b/.github/skills/update-upstream/SKILL.md new file mode 100644 index 0000000..7e21c95 --- /dev/null +++ b/.github/skills/update-upstream/SKILL.md @@ -0,0 +1,145 @@ +--- +name: update-upstream +description: Sync the Clojure Copilot SDK with upstream copilot-sdk changes. Runs update.sh, performs gap analysis against Node.js and Python SDKs, ports changes with red/green TDD, runs full CI (E2E tests + examples), gets parallel multi-model code reviews, updates docs, and creates a PR. Use when syncing with new upstream releases or checking for unported changes. +compatibility: Requires copilot CLI authenticated, gh CLI, clojure CLI, bb (babashka). Upstream repo at ../copilot-sdk. +--- + +# Update Upstream Skill + +Sync the copilot-sdk-clojure project with upstream [github/copilot-sdk](https://github.com/github/copilot-sdk) changes. This skill codifies the full upstream sync workflow — from discovery through implementation, review, docs, and PR creation. + +**Prerequisites**: Read `AGENTS.md` at the repo root for project structure, design philosophy, API compatibility rules, testing commands, and version management. Read `references/PROJECT.md` (relative to this skill) for upstream↔Clojure file mapping and wire conversion notes. + +## Process + +### Phase 1: Discovery + +1. Run `./update.sh` from the repo root to pull the latest upstream and list releases. +2. Check the current Clojure SDK version in `build.clj` (format: `UPSTREAM.CLJ_PATCH` — see AGENTS.md § Version Management). +3. List upstream commits since our last synced version: + ``` + cd ../copilot-sdk && git log --oneline ..HEAD -- nodejs/ + ``` +4. For each commit, classify: + - **Port** — Code changes to `nodejs/src/` (types, client, session, generated) + - **Skip** — CI/tooling, docs-only, language-specific (Python/Go/.NET only) + +### Phase 2: Gap Analysis + +Launch three parallel explore agents to build a comprehensive inventory. Use the file mapping in `references/PROJECT.md` to locate the right files. + +1. **Node.js SDK** — Read upstream files listed in references/PROJECT.md (types.ts, client.ts, session.ts, index.ts, generated/). Catalog all public types, methods, event types, and event data fields. +2. **Python SDK** — Read `python/copilot/client.py`, `session.py`, `__init__.py`, `generated/`. Note behavioral differences from Node.js. +3. **Clojure SDK** — Read all `src/github/copilot_sdk/*.clj`. Catalog public functions, specs, event sets, wire conversion. + +Compare inventories to identify gaps: +- Missing behavioral guards (e.g., `resolvedByHook`) +- Missing spec fields (new event data, new config options) +- Missing permission result kinds, event types +- API signature changes (e.g., handler arity) + +### Phase 3: Planning + +Create a structured plan in the session plan.md with: +- **Code gaps** — Behavioral changes needed (HIGH priority) +- **Spec gaps** — Missing fields/values (MEDIUM priority) +- **Doc gaps** — Documentation needing updates +- **Example gaps** — Missing examples for new features +- **Idiomatic review** — Areas to check for Clojure idiom adherence + +Show the plan to the user. Wait for approval before implementing. + +### Phase 4: Implementation (Red/Green TDD) + +For each code/spec change: + +1. **RED** — Write a failing test first in `test/github/copilot_sdk/integration_test.clj` +2. Run tests: `bb test` — confirm failure +3. **GREEN** — Implement the minimal change in `src/` +4. Run tests again — confirm all pass (0 failures) + +See `references/PROJECT.md` for the upstream↔Clojure file mapping to know which source files to modify. See `AGENTS.md` § Instrumented Testing for the spec/fdef workflow when adding new public functions. + +Wire format notes — see `references/PROJECT.md` § Wire Conversion Cheat Sheet. Key rule: camel-snake-kebab does **not** add `?` suffixes for booleans. + +### Phase 5: Full Validation + +Run the full CI pipeline (see `AGENTS.md` § Before Committing): + +```bash +bb ci:full +``` + +This runs E2E tests, examples, doc validation, and JAR build. If copilot CLI is unavailable, run `bb ci` instead. + +Carefully review example output for regressions. + +### Phase 6: Multi-Model Code Review + +Launch parallel code-review agents using at least three distinct model families (e.g., Claude, GPT, Gemini). Different model families catch different categories of issues — use whichever specific models are currently available. + +Each reviewer gets the same context: what changed, why, and what to focus on (correctness, spec completeness, test coverage, Clojure idioms, wire conversion accuracy). + +Compile a combined assessment table: + +| # | Finding | Source | Severity | Validity | Decision | +|---|---------|--------|----------|----------|----------| + +For each finding: +- **Valid + actionable** → Fix it, re-run tests +- **Valid + pre-existing** → Note as out of scope, track separately +- **Invalid / false positive** → Document rationale for dismissal + +Iterate: fix → re-test → re-review until no actionable findings remain. + +### Phase 7: Documentation + +Invoke the `update-docs` skill (or do manually). See `AGENTS.md` § Documentation for the full list of doc files that may need updating. + +At minimum: +1. Update `doc/reference/API.md` — new specs, event fields, behavior changes +2. Update `CHANGELOG.md` — entries under `[Unreleased]` (see `AGENTS.md` § Changelog for formatting) +3. Run `bb validate-docs` + +### Phase 8: PR Creation + +1. Create a feature branch: `git checkout -b upstream-sync/v` +2. Commit with descriptive message and `Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>` +3. Push and create PR with `gh pr create` +4. PR body should include: summary, changes list, validation results, review findings table + +### Phase 9: Skill Self-Review + +After each upstream sync, review this skill itself for accuracy and relevance: + +1. **Project structure** — Compare the file listing in `AGENTS.md` § Project Structure against the actual contents of `src/github/copilot_sdk/`. Flag any new, renamed, or removed source files. +2. **Upstream file mapping** — Compare the mapping table in `references/PROJECT.md` against the current upstream `nodejs/src/` directory. Flag new or removed upstream files. +3. **Common Pitfalls** — Check whether any pitfalls were encountered during this sync that aren't listed, or whether any listed pitfalls are no longer relevant (e.g., patterns that have been refactored away). +4. **Process phases** — Note any workflow steps that were awkward, missing, or unnecessary during this sync. + +If any drift is found, describe your findings to the user and propose specific edits to `SKILL.md`, `references/PROJECT.md`, or `AGENTS.md`. **Do not commit** the skill updates — wait for the maintainer to review and approve them. + +## Key Principles + +1. **API parity with upstream Node.js SDK.** Only port what the official SDK exposes. Don't add CLI-only features unless clearly marked experimental. See `AGENTS.md` § API Compatibility Rules. + +2. **Idiomatic Clojure, not a transliteration.** Use immutable data, core.async for events/concurrency, specs for validation, kebab-case keywords. See `AGENTS.md` § Design Philosophy. + +3. **Red/green TDD is mandatory.** Never implement without a failing test first. This catches wire conversion bugs (camelCase → kebab-case) and spec mismatches early. + +4. **Multi-model review catches different things.** Different model families tend to find different categories of issues — API contracts, logic/concurrency bugs, spec inconsistencies. Use at least three distinct families. + +5. **Wire conversion is the #1 source of bugs.** Always verify camelCase → kebab-case conversion for new fields. See `references/PROJECT.md` § Wire Conversion Cheat Sheet. + +6. **Event data specs use open maps.** `s/keys` allows extra keys, so new upstream fields pass through automatically. Add explicit specs for documentation and validation, not to gate functionality. + +7. **Pre-existing issues are out of scope.** If a reviewer finds a real issue that wasn't introduced by this sync, note it but don't fix it in the sync PR. Track separately. + +## Common Pitfalls + +These are verified sources of real bugs in this codebase: + +- **Boolean wire fields don't get `?` suffix.** camel-snake-kebab converts `resolvedByHook` → `:resolved-by-hook`, not `:resolved-by-hook?`. Code that manually maps wire booleans to `?`-suffixed keywords (like `:preview?`) must do so explicitly. +- **Forgetting `instrument.clj` allowlists.** Every new public function needs an `s/fdef` and entries in both `instrument-all!` and `unstrument-all!` lists. Integration tests run instrumented, so missing entries cause silent spec gaps. +- **Closed config key sets.** When adding session config options, update both `session-config-keys` and `resume-session-config-keys` in `specs.clj`. Missing a set causes the option to be silently stripped. +- **Mock data vs wire format.** Test fixtures should use wire-shaped data (camelCase) for mock server responses and Clojure-shaped data (kebab-case with `?` suffixes where applicable) for client-side assertions. diff --git a/.github/skills/update-upstream/references/PROJECT.md b/.github/skills/update-upstream/references/PROJECT.md new file mode 100644 index 0000000..102e745 --- /dev/null +++ b/.github/skills/update-upstream/references/PROJECT.md @@ -0,0 +1,41 @@ +# Upstream Sync Reference + +This reference supplements `AGENTS.md` (the canonical project reference) with sync-specific context. + +For project structure, testing commands, version format, changelog conventions, and code quality expectations, see `AGENTS.md`. + +## Upstream ↔ Clojure File Mapping + +When syncing, map upstream changes to the corresponding Clojure files: + +### Upstream (../copilot-sdk) + +| Upstream File | Contains | +|---------------|----------| +| `nodejs/src/types.ts` | Canonical type definitions (`SessionConfig`, `MessageOptions`, etc.) | +| `nodejs/src/client.ts` | `CopilotClient` methods, what params go on the wire | +| `nodejs/src/session.ts` | `CopilotSession` methods, event handling | +| `nodejs/src/index.ts` | Public exports (defines the public API surface) | +| `nodejs/src/generated/session-events.ts` | All event types and data shapes | +| `nodejs/src/generated/rpc.ts` | RPC method signatures | + +### Clojure Counterparts + +| Upstream Area | Clojure File | Notes | +|---------------|-------------|-------| +| Types / config | `specs.clj` | Session config, event data, permissions, tools | +| Client methods | `client.clj` | Broadcast handlers, create-client, create-session | +| Session methods | `session.clj` | send/receive, UI convenience methods | +| Event types | `client.clj` | `event-types` set, `subscribe-events!` | +| RPC methods | `protocol.clj` | JSON-RPC protocol layer | +| Public exports | `client.clj`, `helpers.clj`, `tools.clj` | Public API surface | +| Function specs | `instrument.clj` | fdefs for all public functions | + +## Wire Conversion Cheat Sheet + +camelCase → kebab-case conversion is handled by `util/wire->clj` and `util/clj->wire`. + +Test a conversion: `(csk/->kebab-case-keyword :yourCamelCaseField)` + +**Key rule**: camel-snake-kebab does **not** add a `?` suffix for booleans. +`resolvedByHook` → `:resolved-by-hook` (not `:resolved-by-hook?`). From 3e48285d86b1156426bfa5d8d71f21fc114c8dfe Mon Sep 17 00:00:00 2001 From: Karl Krukow Date: Sat, 4 Apr 2026 23:24:42 +0200 Subject: [PATCH 08/14] Update skill --- .github/skills/update-upstream/SKILL.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/skills/update-upstream/SKILL.md b/.github/skills/update-upstream/SKILL.md index 7e21c95..2a8cf32 100644 --- a/.github/skills/update-upstream/SKILL.md +++ b/.github/skills/update-upstream/SKILL.md @@ -22,7 +22,7 @@ Sync the copilot-sdk-clojure project with upstream [github/copilot-sdk](https:// ``` 4. For each commit, classify: - **Port** — Code changes to `nodejs/src/` (types, client, session, generated) - - **Skip** — CI/tooling, docs-only, language-specific (Python/Go/.NET only) + - **Skip** — CI/tooling, language-specific (Python/Go/.NET only) ### Phase 2: Gap Analysis @@ -54,7 +54,7 @@ Show the plan to the user. Wait for approval before implementing. For each code/spec change: 1. **RED** — Write a failing test first in `test/github/copilot_sdk/integration_test.clj` -2. Run tests: `bb test` — confirm failure +2. Run tests: `bb test` — confirm failure (it's OK to just run the tests you added as you iterate) 3. **GREEN** — Implement the minimal change in `src/` 4. Run tests again — confirm all pass (0 failures) @@ -76,7 +76,7 @@ Carefully review example output for regressions. ### Phase 6: Multi-Model Code Review -Launch parallel code-review agents using at least three distinct model families (e.g., Claude, GPT, Gemini). Different model families catch different categories of issues — use whichever specific models are currently available. +Launch parallel code-review agents using at least three distinct model families (e.g., Claude Opus 4.6, GPT-5.4, and Gemini 3 Pro - or later if available). Different model families catch different categories of issues — use whichever specific models are currently available. Each reviewer gets the same context: what changed, why, and what to focus on (correctness, spec completeness, test coverage, Clojure idioms, wire conversion accuracy). @@ -94,7 +94,7 @@ Iterate: fix → re-test → re-review until no actionable findings remain. ### Phase 7: Documentation -Invoke the `update-docs` skill (or do manually). See `AGENTS.md` § Documentation for the full list of doc files that may need updating. +Invoke the `update-docs` skill. See `AGENTS.md` § Documentation for the full list of doc files that may need updating. At minimum: 1. Update `doc/reference/API.md` — new specs, event fields, behavior changes @@ -104,7 +104,7 @@ At minimum: ### Phase 8: PR Creation 1. Create a feature branch: `git checkout -b upstream-sync/v` -2. Commit with descriptive message and `Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>` +2. Commit changes in logical commits to make them easy to review commit by commit and with descriptive message and `Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>` 3. Push and create PR with `gh pr create` 4. PR body should include: summary, changes list, validation results, review findings table From 3641357b48413c62a1d6743e7373c5e8b5731b28 Mon Sep 17 00:00:00 2001 From: Karl Krukow Date: Sat, 4 Apr 2026 23:28:42 +0200 Subject: [PATCH 09/14] CCR review skill update --- .github/skills/update-upstream/SKILL.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/skills/update-upstream/SKILL.md b/.github/skills/update-upstream/SKILL.md index 2a8cf32..6770801 100644 --- a/.github/skills/update-upstream/SKILL.md +++ b/.github/skills/update-upstream/SKILL.md @@ -108,7 +108,13 @@ At minimum: 3. Push and create PR with `gh pr create` 4. PR body should include: summary, changes list, validation results, review findings table -### Phase 9: Skill Self-Review +### Phase 9: Reflecing on code review feedback. + +Once the PR is created, Copilot Code Review (and possibly humans) will provide code review feedback. + +run: /pr auto Consider Copilot Code Review feedback. For each piece of feedback, determine if its valid and important, or invalid (false positive) and/or not important. For all valid feedback, address it. For each piece of feedback (valid or not), comment in the thread for that particular feedback comment and explain how you addressed the feedback or your rationale for not addressing, or a suggestion for addressing in the future (create issues for future/follow up). Once all feedback is addressed/commented, rerequest a review, and continue iterating this processes until there is no more code review feedback - you should keep iterating this process until you see a review from Copilot Code Review which generates no comments, but no more than 10 rounds (to avoid exploding costs). If more than 10 rounds is needed, prompt the users asking what to do next. + +### Phase 10: Skill Self-Review After each upstream sync, review this skill itself for accuracy and relevance: From 4de005cd9a8bca2f1f4d5c5a531a851567013be9 Mon Sep 17 00:00:00 2001 From: Karl Krukow Date: Sat, 4 Apr 2026 23:34:10 +0200 Subject: [PATCH 10/14] Address Copilot Code Review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix ::status-code spec: use integer? instead of int? (Long compatibility) - Fix plan-read: rename :exists → :exists? in return value to match docstring (proto/send-request! already applies clj->wire internally) - Fix mock server: distinguish requests (has :id) from notifications (no :id), only write response for requests per JSON-RPC spec - Fix send-rpc-request! docstring to match actual behavior - Strengthen test-plan-read assertions to verify :exists? key and return shape Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/github/copilot_sdk/client.clj | 6 ++-- src/github/copilot_sdk/session.clj | 13 ++++++--- src/github/copilot_sdk/specs.clj | 2 +- test/github/copilot_sdk/integration_test.clj | 9 ++++-- test/github/copilot_sdk/mock_server.clj | 30 ++++++++++++-------- 5 files changed, 38 insertions(+), 22 deletions(-) diff --git a/src/github/copilot_sdk/client.clj b/src/github/copilot_sdk/client.clj index 15cba19..7fc7543 100644 --- a/src/github/copilot_sdk/client.clj +++ b/src/github/copilot_sdk/client.clj @@ -1230,7 +1230,7 @@ (ensure-connected! client) (let [conn (:connection-io @(:state client))] (util/wire->clj - (proto/send-request! conn "mcp.config.add" (util/clj->wire params))))) + (proto/send-request! conn "mcp.config.add" params)))) (defn ^:experimental mcp-config-update! "Update an MCP server configuration. @@ -1239,7 +1239,7 @@ (ensure-connected! client) (let [conn (:connection-io @(:state client))] (util/wire->clj - (proto/send-request! conn "mcp.config.update" (util/clj->wire params))))) + (proto/send-request! conn "mcp.config.update" params)))) (defn ^:experimental mcp-config-remove! "Remove an MCP server configuration. @@ -1248,7 +1248,7 @@ (ensure-connected! client) (let [conn (:connection-io @(:state client))] (util/wire->clj - (proto/send-request! conn "mcp.config.remove" (util/clj->wire params))))) + (proto/send-request! conn "mcp.config.remove" params)))) (defn- validate-session-config! "Validate session config, throwing on invalid input." diff --git a/src/github/copilot_sdk/session.clj b/src/github/copilot_sdk/session.clj index a3332a5..247a0ba 100644 --- a/src/github/copilot_sdk/session.clj +++ b/src/github/copilot_sdk/session.clj @@ -1158,9 +1158,14 @@ and :file-path (string or nil)." [session] (let [{:keys [session-id client]} session - conn (connection-io client)] - (util/wire->clj - (proto/send-request! conn "session.plan.read" {:sessionId session-id})))) + conn (connection-io client) + result (util/wire->clj + (proto/send-request! conn "session.plan.read" {:sessionId session-id}))] + (if (contains? result :exists) + (-> result + (assoc :exists? (:exists result)) + (dissoc :exists)) + result))) (defn ^:experimental plan-update! "Update the plan file content for the session." @@ -1263,7 +1268,7 @@ conn (connection-io client)] (util/wire->clj (proto/send-request! conn "session.fleet.start" - (merge {:sessionId session-id} (util/clj->wire params)))))) + (merge {:session-id session-id} params))))) ;; -- UI Elicitation ---------------------------------------------------------- diff --git a/src/github/copilot_sdk/specs.clj b/src/github/copilot_sdk/specs.clj index 174c6a4..32ab405 100644 --- a/src/github/copilot_sdk/specs.clj +++ b/src/github/copilot_sdk/specs.clj @@ -625,7 +625,7 @@ :opt-un [::selected-model ::reasoning-effort ::already-in-use? ::remote-steerable? ::host-type ::head-commit ::base-commit])) -(s/def ::status-code int?) +(s/def ::status-code integer?) (s/def ::provider-call-id string?) (s/def ::error-type string?) (s/def ::stack string?) diff --git a/test/github/copilot_sdk/integration_test.clj b/test/github/copilot_sdk/integration_test.clj index 548618f..38f999d 100644 --- a/test/github/copilot_sdk/integration_test.clj +++ b/test/github/copilot_sdk/integration_test.clj @@ -1962,7 +1962,7 @@ (is (= "plan" (:mode (:params (first mode-rpcs))))))))) (deftest test-plan-read - (testing "plan-read calls session.plan.read RPC" + (testing "plan-read calls session.plan.read RPC and returns normalized shape" (let [requests (atom []) _ (mock/set-request-hook! *mock-server* (fn [method params] @@ -1971,7 +1971,12 @@ {:on-permission-request sdk/approve-all})] (let [result (session/plan-read session)] (is (some? result)) - (is (some #(= "session.plan.read" (:method %)) @requests)))))) + (is (some #(= "session.plan.read" (:method %)) @requests)) + ;; Mock returns {:exists false :content nil :filePath nil} + ;; plan-read renames :exists → :exists? and wire->clj converts :filePath → :file-path + (is (contains? result :exists?) ":exists key should be renamed to :exists?") + (is (false? (:exists? result))) + (is (nil? (:content result))))))) (deftest test-plan-update (testing "plan-update! calls session.plan.update RPC with content" diff --git a/test/github/copilot_sdk/mock_server.clj b/test/github/copilot_sdk/mock_server.clj index ccde325..ca9ddc2 100644 --- a/test/github/copilot_sdk/mock_server.clj +++ b/test/github/copilot_sdk/mock_server.clj @@ -371,17 +371,22 @@ (while @(:running? server) (if-let [msg (read-message (:reader server))] (if (:method msg) - ;; It's a request — handle it - (try - (let [response (handle-request server msg)] - (write-message (:writer server) response)) - (catch Exception e - (let [error-data (ex-data e)] - (write-message (:writer server) - {:jsonrpc "2.0" - :id (:id msg) - :error {:code (or (:code error-data) -32603) - :message (.getMessage e)}})))) + (if (contains? msg :id) + ;; It's a request (has :method and :id) — handle it and respond + (try + (let [response (handle-request server msg)] + (write-message (:writer server) response)) + (catch Exception e + (let [error-data (ex-data e)] + (write-message (:writer server) + {:jsonrpc "2.0" + :id (:id msg) + :error {:code (or (:code error-data) -32603) + :message (.getMessage e)}})))) + ;; It's a notification (has :method but no :id) — process silently + (try + (handle-request server msg) + (catch Exception _ nil))) ;; It's a response to a server→client RPC — deliver to pending promise (when-let [id (:id msg)] (when-let [response-ch (get @(:pending-responses server) id)] @@ -515,7 +520,8 @@ Simulates server→client RPCs like hooks.invoke, userInput.request, systemMessage.transform. Blocks until the client responds or timeout. Must NOT be called from the mock server's server-loop thread. - Returns the response map (:result or :error)." + Returns the full JSON-RPC response map on success. + Throws ex-info on timeout or when the client responds with :error." [server method params & {:keys [timeout-ms] :or {timeout-ms 5000}}] (let [id (generate-id (:message-id server)) response-ch (chan 1)] From 95e4b55e421c2d45ad99cf4ff55ad0b5394ace5f Mon Sep 17 00:00:00 2001 From: Karl Krukow Date: Sat, 4 Apr 2026 23:35:43 +0200 Subject: [PATCH 11/14] =?UTF-8?q?Fix=20changelog=20wording:=20agent=20CRUD?= =?UTF-8?q?=20=E2=86=92=20agent=20management?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index afa90a2..11c5934 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ All notable changes to this project will be documented in this file. This change - `mode-get`, `mode-set!` — get/set agent mode (interactive/plan/autopilot) - `plan-read`, `plan-update!`, `plan-delete!` — read/update/delete session plan file - `workspace-list-files`, `workspace-read-file`, `workspace-create-file!` — session workspace file operations - - `agent-list`, `agent-get-current`, `agent-select!`, `agent-deselect!`, `agent-reload!` — custom agent CRUD + - `agent-list`, `agent-get-current`, `agent-select!`, `agent-deselect!`, `agent-reload!` — custom agent management - `fleet-start!` — start parallel sub-sessions - **MCP config wrappers** — new experimental server-level functions in `client`: - `mcp-config-list`, `mcp-config-add!`, `mcp-config-update!`, `mcp-config-remove!` — MCP server configuration management From c414af213db0b0243ba11b298a37be69bf357c7d Mon Sep 17 00:00:00 2001 From: Karl Krukow Date: Sat, 4 Apr 2026 23:44:27 +0200 Subject: [PATCH 12/14] Address Copilot Code Review round 3 - Fix mock stub for session.ui.elicitation: return elicitation result shape ({:action :content}) instead of nested {:result {}} - Fix changelog: update test count from 13 to 18 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 2 +- src/github/copilot_sdk/session.clj | 2 +- test/github/copilot_sdk/mock_server.clj | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 11c5934..b4efd2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,7 @@ All notable changes to this project will be documented in this file. This change - **User input handler tests** — 2 tests for `userInput.request` server→client RPC - **System message transform tests** — 3 tests for `systemMessage.transform` callback invocation, error fallback, and passthrough - **Tool result normalization tests** — 3 tests for string, nil, and structured ToolResultObject results via v3 broadcast -- **Session RPC wrapper tests** — 13 integration tests for all new RPC wrapper functions +- **Session RPC wrapper tests** — 18 integration tests for all new RPC wrapper functions - **Mock server enhancements** — `send-rpc-request!` for testing server→client RPCs, response routing in server loop, 30+ new method stubs - Full `s/fdef` instrumentation for all 19 new public functions diff --git a/src/github/copilot_sdk/session.clj b/src/github/copilot_sdk/session.clj index 247a0ba..8c74921 100644 --- a/src/github/copilot_sdk/session.clj +++ b/src/github/copilot_sdk/session.clj @@ -1268,7 +1268,7 @@ conn (connection-io client)] (util/wire->clj (proto/send-request! conn "session.fleet.start" - (merge {:session-id session-id} params))))) + (assoc (merge {} params) :session-id session-id))))) ;; -- UI Elicitation ---------------------------------------------------------- diff --git a/test/github/copilot_sdk/mock_server.clj b/test/github/copilot_sdk/mock_server.clj index ca9ddc2..a594e64 100644 --- a/test/github/copilot_sdk/mock_server.clj +++ b/test/github/copilot_sdk/mock_server.clj @@ -348,7 +348,7 @@ "session.compaction.compact" {:success true} "session.shell.exec" {:exitCode 0 :stdout "" :stderr ""} "session.shell.kill" {:success true} - "session.ui.elicitation" {:result {}} + "session.ui.elicitation" {:action "accept" :content {}} "mcp.config.list" {:servers []} "mcp.config.add" {:success true} "mcp.config.update" {:success true} From af3af5c2472e538c2dfa8b06da6c3fd3edc3eeb6 Mon Sep 17 00:00:00 2001 From: Karl Krukow Date: Sat, 4 Apr 2026 23:52:29 +0200 Subject: [PATCH 13/14] Strengthen fleet-start! test to verify session-id scoping Assert that fleet-start! always uses the session's own ID and that user-provided params cannot override it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- test/github/copilot_sdk/integration_test.clj | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/test/github/copilot_sdk/integration_test.clj b/test/github/copilot_sdk/integration_test.clj index 38f999d..20c5166 100644 --- a/test/github/copilot_sdk/integration_test.clj +++ b/test/github/copilot_sdk/integration_test.clj @@ -2078,16 +2078,21 @@ (is (some #(= "session.agent.deselect" (:method %)) @requests))))) (deftest test-fleet-start - (testing "fleet-start! calls session.fleet.start RPC" + (testing "fleet-start! calls session.fleet.start RPC with session-id forced" (let [requests (atom []) _ (mock/set-request-hook! *mock-server* (fn [method params] (swap! requests conj {:method method :params params}))) session (sdk/create-session *test-client* - {:on-permission-request sdk/approve-all})] - (session/fleet-start! session {:prompt "do stuff"}) + {:on-permission-request sdk/approve-all}) + session-id (sdk/session-id session)] + ;; Pass params that attempt to override session-id + (session/fleet-start! session {:prompt "do stuff" :session-id "evil-override"}) (let [rpcs (filter #(= "session.fleet.start" (:method %)) @requests)] - (is (= 1 (count rpcs))))))) + (is (= 1 (count rpcs))) + ;; Session-id must be the real one, not the override + (is (= session-id (:sessionId (:params (first rpcs))))) + (is (= "do stuff" (:prompt (:params (first rpcs))))))))) (deftest test-mcp-config-list (testing "mcp-config-list calls mcp.config.list RPC" From 5d6a5318ff66ede32da6e855578a909112424071 Mon Sep 17 00:00:00 2001 From: Karl Krukow Date: Sat, 4 Apr 2026 23:58:02 +0200 Subject: [PATCH 14/14] Clarify mcp-config docstrings: plain keys, not :mcp-prefixed (:name, :command, :args) not the :mcp-prefixed keys used in session config :mcp-servers. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/github/copilot_sdk/client.clj | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/github/copilot_sdk/client.clj b/src/github/copilot_sdk/client.clj index 7fc7543..2a0fece 100644 --- a/src/github/copilot_sdk/client.clj +++ b/src/github/copilot_sdk/client.clj @@ -1225,7 +1225,8 @@ (defn ^:experimental mcp-config-add! "Add an MCP server configuration. - params is a map with server config (name, command, args, etc.)." + params is a map with server config using plain keys (:name, :command, :args, + :tools, etc.) — NOT the :mcp-prefixed keys used in session config :mcp-servers." [client params] (ensure-connected! client) (let [conn (:connection-io @(:state client))] @@ -1234,7 +1235,7 @@ (defn ^:experimental mcp-config-update! "Update an MCP server configuration. - params is a map with server config to update." + params is a map with server config using plain keys (see mcp-config-add!)." [client params] (ensure-connected! client) (let [conn (:connection-io @(:state client))] @@ -1243,7 +1244,7 @@ (defn ^:experimental mcp-config-remove! "Remove an MCP server configuration. - params is a map identifying the server to remove." + params is a map identifying the server using plain keys (see mcp-config-add!)." [client params] (ensure-connected! client) (let [conn (:connection-io @(:state client))]