diff --git a/CHANGELOG.md b/CHANGELOG.md index a056149..caa8aa4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,20 @@ All notable changes to this project will be documented in this file. This change ## [Unreleased] +### Added (v0.2.1 sync) +- **Commands support** — register slash commands per-session via `:commands` option in session config. Each command definition has `:name`, optional `:description`, and a `:command-handler` function. Commands are sent on the wire (name + description) and executed via `command.execute` broadcast events with `session.commands.handlePendingCommand` RPC callback (upstream PR #906). +- **UI Elicitation convenience API** — new public functions `confirm!`, `select!`, `input!` wrap the existing `ui-elicitation!` with typed schemas. `capabilities` accessor returns host capabilities from session create/resume response. `elicitation-supported?` predicate checks if the host supports elicitation dialogs. All convenience methods throw with a clear error when elicitation is unsupported (upstream PR #906). +- **`COPILOT_CLI_PATH` env var fallback** — client constructor now checks `COPILOT_CLI_PATH` environment variable before defaulting to `"copilot"` when no explicit `:cli-path` or `:cli-url` is provided (upstream PR #906). +- **New event type** `session.custom_agents_updated` added to event type enum. +- **`:host` field on `session.handoff` events** — event data now includes optional `:host` field with the GitHub host URL. New `::session.handoff-data` spec documents the shape (upstream PR #900). +- New specs: `::command-definition`, `::commands`, `::session-capabilities`, `::elicitation-params`, `::elicitation-result`, `::input-options`. +- Function specs and instrumentation for `capabilities`, `elicitation-supported?`, `confirm!`, `select!`, `input!`. +- Integration tests for command wire format, command.execute routing, unknown command errors, handler errors, capabilities storage, and elicitation guards. + +### Changed (v0.2.1 sync) +- `ui-elicitation!` no longer marked `^:experimental` — now asserts elicitation support before calling. Updated fdef to use `::elicitation-params` spec. +- Mock server `handle-request` now supports `session.commands.handlePendingCommand` RPC and allows request hooks to merge additional data into responses. + ## [0.2.0.0] - 2026-03-23 ### Added (v0.2.0 sync) - **System message customize mode** — new `:customize` mode for `:system-message` enables section-level overrides of the Copilot system prompt. Ten configurable sections: `:identity`, `:tone`, `:tool-efficiency`, `:environment-context`, `:code-change-rules`, `:guidelines`, `:safety`, `:tool-instructions`, `:custom-instructions`, `:last-instructions`. Each section supports static actions (`:replace`, `:remove`, `:append`, `:prepend`) and transform callbacks (1-arity functions receiving current content, returning modified text). New `system-prompt-sections` constant exported from main namespace (upstream PR #816). diff --git a/README.md b/README.md index de396dd..0df781d 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ Add to your `deps.edn`: ```clojure ;; From Maven Central -io.github.copilot-community-sdk/copilot-sdk-clojure {:mvn/version "0.2.0.0"} +io.github.copilot-community-sdk/copilot-sdk-clojure {:mvn/version "0.2.1.0"} ;; Or git dependency io.github.copilot-community-sdk/copilot-sdk-clojure {:git/url "https://github.com/copilot-community-sdk/copilot-sdk-clojure.git" diff --git a/build.clj b/build.clj index 0b3f655..c1ffd85 100644 --- a/build.clj +++ b/build.clj @@ -7,7 +7,7 @@ (:import [java.io File])) (def lib 'io.github.copilot-community-sdk/copilot-sdk-clojure) -(def version "0.2.0.0") +(def version "0.2.1.0") (def class-dir "target/classes") (defn- try-sh diff --git a/doc/reference/API.md b/doc/reference/API.md index bc5ae16..9409814 100644 --- a/doc/reference/API.md +++ b/doc/reference/API.md @@ -113,7 +113,7 @@ Get information about the current shared client state. Returns `nil` if no share | Key | Type | Default | Description | |-----|------|---------|-------------| -| `:cli-path` | string | `"copilot"` | Path to CLI executable | +| `:cli-path` | string | `"copilot"` | Path to CLI executable. Falls back to `COPILOT_CLI_PATH` env var when not set | | `:cli-args` | vector | `[]` | Extra arguments prepended before SDK-managed flags | | `:cli-url` | string | nil | URL of existing CLI server (e.g., `"localhost:8080"`). When provided, no CLI process is spawned | | `:port` | number | `0` | Server port (0 = random) | @@ -238,6 +238,7 @@ Create a client and session together, ensuring both are cleaned up on exit. | `:excluded-tools` | vector | List of excluded tool names | | `:provider` | map | Provider config for BYOK (see [BYOK docs](../auth/byok.md)). Required key: `:base-url`. Optional: `:provider-type` (`:openai`/`:azure`/`:anthropic`), `:wire-api` (`:completions`/`:responses`), `:api-key`, `:bearer-token`, `:azure-options` | | `:mcp-servers` | map | MCP server configs keyed by server ID (see [MCP docs](../mcp/overview.md)). Local servers: `:mcp-command`, `:mcp-args`, `:mcp-tools`. Remote servers: `:mcp-server-type` (`:http`/`:sse`), `:mcp-url`, `:mcp-tools` | +| `:commands` | vector | Command definitions (slash commands). See [Commands](#commands) | | `:custom-agents` | vector | Custom agent configs | | `:on-permission-request` | fn | **Required.** Permission handler function. Use `copilot/approve-all` to approve everything. | | `:streaming?` | boolean | Enable streaming deltas | @@ -935,7 +936,106 @@ Get the client that owns this session. | `session/compaction-compact!` | Trigger manual context compaction. | | `session/shell-exec!` | Execute a shell command. | | `session/shell-kill!` | Kill a running shell process. | -| `session/ui-elicitation!` | Request structured user input. | + +--- + +## UI Elicitation + +Request structured user input via interactive dialogs. Check host support before calling. + +```clojure +(require '[github.copilot-sdk :as copilot]) +``` + +### `capabilities` + +```clojure +(copilot/capabilities session) +;; => {:ui {:elicitation true}} +``` + +Get the host capabilities map reported when the session was created or resumed. + +### `elicitation-supported?` + +```clojure +(copilot/elicitation-supported? session) +;; => true +``` + +Return `true` if the CLI host supports interactive elicitation dialogs. + +### `confirm!` + +```clojure +(copilot/confirm! session message) +``` + +Show a confirmation dialog. Returns `true` if the user confirms, `false` if they decline or cancel. Throws if elicitation is not supported. + +```clojure +(when (copilot/elicitation-supported? session) + (when (copilot/confirm! session "Deploy to production?") + (println "Deploying..."))) +``` + +### `select!` + +```clojure +(copilot/select! session message options) +``` + +Show a selection dialog with the given options. Returns the selected value as a string, or `nil` if the user declines or cancels. Throws if elicitation is not supported. + +```clojure +(when-let [env (copilot/select! session "Choose environment" ["staging" "production"])] + (println "Selected:" env)) +``` + +### `input!` + +```clojure +(copilot/input! session message) +(copilot/input! session message opts) +``` + +Show a text input dialog. Returns the entered text as a string, or `nil` if the user declines or cancels. Throws if elicitation is not supported. + +**Options:** + +| Key | Type | Description | +|-----|------|-------------| +| `:title` | string | Title for the input field | +| `:description` | string | Description text | +| `:min-length` | integer | Minimum input length | +| `:max-length` | integer | Maximum input length | +| `:format` | string | Input format (`"email"`, `"uri"`, `"date"`, `"date-time"`) | +| `:default` | string | Default value | + +```clojure +(when-let [name (copilot/input! session "Enter your name" + {:min-length 1 + :max-length 100})] + (println "Hello," name)) +``` + +### `ui-elicitation!` + +```clojure +(copilot/ui-elicitation! session params) +``` + +Raw elicitation request for custom JSON schemas. `params` is a map with `:message` and `:requested-schema` keys. Returns a map with `:action` (`"accept"`, `"decline"`, or `"cancel"`) and `:content`. Throws if elicitation is not supported. + +```clojure +(copilot/ui-elicitation! session + {:message "Configure deployment" + :requested-schema {:type "object" + :properties {"env" {:type "string" :enum ["staging" "production"]} + "replicas" {:type "number" :default 3}} + :required ["env"]}}) +;; => {:action "accept", :content {:env "staging", :replicas 3}} +``` --- @@ -993,7 +1093,7 @@ Convert an unqualified event keyword to a namespace-qualified `:copilot/` keywor | `:copilot/session.idle` | Session finished processing | | `:copilot/session.info` | Informational session update | | `:copilot/session.model_change` | Session model changed | -| `:copilot/session.handoff` | Session handed off to another agent | +| `:copilot/session.handoff` | Session handed off to another agent; data: `{:remote-session-id "..." :host "https://github.com"}` (both optional) | | `:copilot/session.usage_info` | Token usage information | | `:copilot/session.context_changed` | Session context (cwd, repo, branch) changed | | `:copilot/session.title_changed` | Session title updated | @@ -1056,6 +1156,7 @@ Convert an unqualified event keyword to a namespace-qualified `:copilot/` keywor | `:copilot/session.mcp_servers_loaded` | MCP servers loaded for the session | | `:copilot/session.mcp_server_status_changed` | MCP server status changed | | `:copilot/session.extensions_loaded` | Extensions loaded for the session | +| `:copilot/session.custom_agents_updated` | Custom agents list updated | ### Example: Handling Events @@ -1205,6 +1306,46 @@ Set `:overrides-built-in-tool true` to override a built-in tool (e.g., `grep`, ` (copilot/result-rejected "Invalid parameters") ``` +### Commands + +Register slash commands that users can invoke in the TUI. Define each command as a map with `:name`, `:description`, and `:command-handler`, then pass them via `:commands` in session config. + +```clojure +(def my-commands + [{:name "deploy" + :description "Deploy the current project" + :command-handler (fn [{:keys [session-id command-name args]}] + (println "Deploying with args:" args))} + {:name "status" + :description "Show project status" + :command-handler (fn [{:keys [session-id command-name args]}] + (println "All systems operational"))}]) + +(def session (copilot/create-session client + {:model "gpt-5.4" + :commands my-commands + :on-permission-request copilot/approve-all})) +``` + +**Command definition keys:** + +| Key | Type | Required | Description | +|-----|------|----------|-------------| +| `:name` | string | yes | Command name (without leading slash) | +| `:description` | string | no | Description shown in TUI command list | +| `:command-handler` | fn | yes | Handler function | + +The handler receives a context map: + +| Key | Description | +|-----|-------------| +| `:session-id` | The session ID | +| `:command` | Full command string | +| `:command-name` | Matched command name | +| `:args` | Arguments after the command name | + +The handler may return `nil` or a core.async channel (awaited automatically). + ### System Message Customization Control the system prompt: diff --git a/src/github/copilot_sdk.clj b/src/github/copilot_sdk.clj index 2eb1ecf..8f09df1 100644 --- a/src/github/copilot_sdk.clj +++ b/src/github/copilot_sdk.clj @@ -110,7 +110,8 @@ :copilot/session.skills_loaded :copilot/session.mcp_servers_loaded :copilot/session.mcp_server_status_changed - :copilot/session.extensions_loaded}) + :copilot/session.extensions_loaded + :copilot/session.custom_agents_updated}) (def session-events "Session lifecycle and state management events." @@ -139,7 +140,8 @@ :copilot/session.skills_loaded :copilot/session.mcp_servers_loaded :copilot/session.mcp_server_status_changed - :copilot/session.extensions_loaded}) + :copilot/session.extensions_loaded + :copilot/session.custom_agents_updated}) (def assistant-events "Assistant response events." @@ -834,6 +836,87 @@ [session] (session/workspace-path session)) +(defn capabilities + "Get the host capabilities reported when the session was created or resumed. + Returns a map, e.g. `{:ui {:elicitation true}}`. + + Example: + ```clojure + (copilot/capabilities session) + ;; => {:ui {:elicitation true}} + ```" + [session] + (session/capabilities session)) + +(defn elicitation-supported? + "Check if the CLI host supports interactive elicitation dialogs. + + Example: + ```clojure + (when (copilot/elicitation-supported? session) + (copilot/confirm! session \"Deploy to production?\")) + ```" + [session] + (session/elicitation-supported? session)) + +(defn ui-elicitation! + "Request structured user input via an elicitation prompt. + params is a map with :message and :requested-schema keys. + Throws if the host does not support elicitation. + + Example: + ```clojure + (copilot/ui-elicitation! session + {:message \"Configure deployment\" + :requested-schema {:type \"object\" + :properties {\"env\" {:type \"string\" :enum [\"staging\" \"production\"]}} + :required [\"env\"]}}) + ```" + [session params] + (session/ui-elicitation! session params)) + +(defn confirm! + "Show a confirmation dialog and return the user's boolean answer. + Returns false if the user declines or cancels. + Throws if the host does not support elicitation. + + Example: + ```clojure + (when (copilot/confirm! session \"Deploy to production?\") + (println \"Deploying...\")) + ```" + [session message] + (session/confirm! session message)) + +(defn select! + "Show a selection dialog with the given options. + Returns the selected value as a string, or nil if the user declines/cancels. + Throws if the host does not support elicitation. + + Example: + ```clojure + (when-let [env (copilot/select! session \"Choose environment\" [\"staging\" \"production\"])] + (println \"Selected:\" env)) + ```" + [session message options] + (session/select! session message options)) + +(defn input! + "Show a text input dialog. Returns the entered text, or nil if the user + declines/cancels. opts is an optional map with :title, :description, + :min-length, :max-length, :format, and :default keys. + Throws if the host does not support elicitation. + + Example: + ```clojure + (when-let [name (copilot/input! session \"Enter your name\")] + (println \"Hello,\" name)) + ```" + ([session message] + (session/input! session message)) + ([session message opts] + (session/input! session message opts))) + (defn get-current-model "Get the current model for this session. Returns the model ID string, or nil if none set. diff --git a/src/github/copilot_sdk/client.clj b/src/github/copilot_sdk/client.clj index cbd77f4..6d44681 100644 --- a/src/github/copilot_sdk/client.clj +++ b/src/github/copilot_sdk/client.clj @@ -184,6 +184,18 @@ (and (:github-token opts) (nil? (:use-logged-in-user? opts))) (assoc :use-logged-in-user? false)) merged (merge (default-options) opts-with-defaults) + ;; COPILOT_CLI_PATH env var fallback: when no explicit :cli-path or :cli-url, + ;; check effective env for COPILOT_CLI_PATH before using default "copilot" + ;; Merge system env with user overrides so COPILOT_CLI_PATH is visible + ;; even when :env provides additional overrides + effective-env (if (:env opts) + (merge (into {} (System/getenv)) (:env opts)) + (into {} (System/getenv))) + merged (if (and (not (:cli-path opts)) + (not (:cli-url opts)) + (get effective-env "COPILOT_CLI_PATH")) + (assoc merged :cli-path (get effective-env "COPILOT_CLI_PATH")) + merged) child-process? (:is-child-process? opts) cli-url? (boolean (:cli-url opts)) external? (or cli-url? child-process?) @@ -300,8 +312,47 @@ :result {:kind :denied-no-approval-rule-and-could-not-request-from-user}})))) (catch Exception _ nil)))))))) +(defn- handle-v3-command-execute! + "Handle v3 command.execute broadcast event. + Calls the session's command handler and responds via the + session.commands.handlePendingCommand RPC method." + [client session-id event] + (let [data (:data event) + request-id (:request-id data) + command-name (:command-name data) + command (:command data) + args (:args data)] + (when (and request-id command-name) + (go + (try + (let [cmd-response (wire agents)) wire-infinite-sessions (when-let [is (:infinite-sessions config)] - (util/clj->wire is))] + (util/clj->wire is)) + wire-commands (when-let [cmds (:commands config)] + (mapv (fn [c] + (cond-> {:name (:name c)} + (some? (:description c)) + (assoc :description (:description c)))) + cmds))] (cond-> {} (:session-id config) (assoc :session-id (:session-id config)) (:client-name config) (assoc :client-name (:client-name config)) (:model config) (assoc :model (:model config)) wire-tools (assoc :tools wire-tools) + wire-commands (assoc :commands wire-commands) wire-sys-msg (assoc :system-message wire-sys-msg) (:available-tools config) (assoc :available-tools (:available-tools config)) (:excluded-tools config) (assoc :excluded-tools (:excluded-tools config)) @@ -1229,11 +1290,18 @@ wire-custom-agents (when-let [agents (:custom-agents config)] (mapv util/clj->wire agents)) wire-infinite-sessions (when-let [is (:infinite-sessions config)] - (util/clj->wire is))] + (util/clj->wire is)) + wire-commands (when-let [cmds (:commands config)] + (mapv (fn [c] + (cond-> {:name (:name c)} + (some? (:description c)) + (assoc :description (:description c)))) + cmds))] (cond-> {:session-id session-id} (:client-name config) (assoc :client-name (:client-name config)) (:model config) (assoc :model (:model config)) wire-tools (assoc :tools wire-tools) + wire-commands (assoc :commands wire-commands) wire-sys-msg (assoc :system-message wire-sys-msg) (:available-tools config) (assoc :available-tools (:available-tools config)) (:excluded-tools config) (assoc :excluded-tools (:excluded-tools config)) @@ -1261,6 +1329,7 @@ [client session-id config] (session/create-session client session-id {:tools (:tools config) + :commands (:commands config) :on-permission-request (:on-permission-request config) :on-user-input-request (:on-user-input-request config) :hooks (:hooks config) @@ -1276,6 +1345,7 @@ - :client-name - Client name to identify the application (included in User-Agent header) - :model - Model to use (e.g., \"gpt-5.4\") - :tools - Vector of tool definitions + - :commands - Vector of command definitions (slash commands for TUI) - :system-message - System message config - :available-tools - List of allowed tool names - :excluded-tools - List of excluded tool names @@ -1318,6 +1388,7 @@ (try (let [result (proto/send-request! connection-io "session.create" params)] (session/set-workspace-path! client session-id (:workspace-path result)) + (session/set-capabilities! client session-id (:capabilities result)) (log/info "Session created: " session-id) session) (catch Throwable t @@ -1375,6 +1446,7 @@ (try (let [result (proto/send-request! connection-io "session.resume" params)] (session/set-workspace-path! client session-id (:workspace-path result)) + (session/set-capabilities! client session-id (:capabilities result)) session) (catch Throwable t (session/remove-session! client session-id) @@ -1424,6 +1496,7 @@ {:error err})) (let [result (:result response)] (session/set-workspace-path! client session-id (:workspace-path result)) + (session/set-capabilities! client session-id (:capabilities result)) (log/info "Session created (async): " session-id) session))))))) (defn !! :token)) - tool-handlers (into {} (map (fn [t] [(:tool-name t) (:tool-handler t)]) tools))] + tool-handlers (into {} (map (fn [t] [(:tool-name t) (:tool-handler t)]) tools)) + command-handlers (into {} (map (fn [c] [(:name c) (:command-handler c)]) commands))] ;; Store session state and IO in client's atom (swap! (:state client) (fn [state] (-> state (assoc-in [:sessions session-id] {:tool-handlers tool-handlers + :command-handlers command-handlers :permission-handler on-permission-request :user-input-handler on-user-input-request :hooks hooks :destroyed? false :workspace-path workspace-path + :capabilities {} :config config}) (assoc-in [:session-io session-id] {:event-chan event-chan @@ -103,6 +106,11 @@ (when workspace-path (swap! (:state client) assoc-in [:sessions session-id :workspace-path] workspace-path))) +(defn set-capabilities! + "Store host capabilities in session state. Called after session.create/session.resume RPC." + [client session-id capabilities] + (swap! (:state client) assoc-in [:sessions session-id :capabilities] (or capabilities {}))) + (defn register-transform-callbacks! "Store system message transform callbacks on a session. Callbacks is a map of wire section ID strings to 1-arity functions @@ -348,6 +356,35 @@ {:result nil}))))))) :io)) +(defn handle-command-execute! + "Handle an incoming command.execute event. Returns a channel with the result. + Looks up the command handler by name and calls it with a context map. + Returns {:result nil} on success or {:error message} on failure." + [client session-id {:keys [command-name command args]}] + (async/thread-call + (fn [] + (let [handler (get-in (session-state client session-id) [:command-handlers command-name])] + (if-not handler + {:error (str "Unknown command: " command-name)} + (try + (let [timeout-ms (or (:tool-timeout-ms (:options client)) 120000) + result (handler {:session-id session-id + :command command + :command-name command-name + :args args}) + ;; If handler returns a channel, await with timeout + _ (when (channel? result) + (let [timeout-ch (async/timeout timeout-ms) + [_ ch] (alts!! [result timeout-ch])] + (when (= ch timeout-ch) + (throw (ex-info "Command handler timeout" + {:timeout-ms timeout-ms + :command-name command-name})))))] + {:result nil}) + (catch Exception e + {:error (ex-message e)}))))) + :io)) + ;; ----------------------------------------------------------------------------- ;; Public API - functions that take CopilotSession handle ;; ----------------------------------------------------------------------------- @@ -1023,12 +1060,85 @@ ;; -- UI Elicitation ---------------------------------------------------------- -(defn ^:experimental ui-elicitation! +(defn capabilities + "Get the host capabilities reported when the session was created or resumed. + Returns a map, e.g. `{:ui {:elicitation true}}`." + [session] + (let [{:keys [session-id client]} session] + (:capabilities (session-state client session-id)))) + +(defn elicitation-supported? + "Check if the CLI host supports interactive elicitation dialogs." + [session] + (boolean (get-in (capabilities session) [:ui :elicitation]))) + +(defn- assert-elicitation! [session] + (when-not (elicitation-supported? session) + (throw (ex-info "Elicitation is not supported by the host. Check (elicitation-supported? session) before calling UI methods." + {:session-id (:session-id session) + :capabilities (capabilities session)})))) + +(defn ui-elicitation! "Request structured user input via an elicitation prompt. - params is a map with elicitation configuration (schema, message, etc.)." + params is a map with :message and :requested-schema keys. + Throws if the host does not support elicitation." [session params] + (assert-elicitation! session) (let [{:keys [session-id client]} session conn (connection-io client)] (util/wire->clj (proto/send-request! conn "session.ui.elicitation" (assoc (util/clj->wire params) :sessionId session-id))))) + +(defn confirm! + "Show a confirmation dialog and return the user's boolean answer. + Returns false if the user declines or cancels. + Throws if the host does not support elicitation." + [session message] + (let [result (ui-elicitation! session + {:message message + :requested-schema + {:type "object" + :properties {"confirmed" {:type "boolean" :default true}} + :required ["confirmed"]}})] + (and (= "accept" (:action result)) + (true? (get-in result [:content :confirmed]))))) + +(defn select! + "Show a selection dialog with the given options. + Returns the selected value as a string, or nil if the user declines/cancels. + Throws if the host does not support elicitation." + [session message options] + (let [result (ui-elicitation! session + {:message message + :requested-schema + {:type "object" + :properties {"selection" {:type "string" :enum (vec options)}} + :required ["selection"]}})] + (when (and (= "accept" (:action result)) + (some? (get-in result [:content :selection]))) + (get-in result [:content :selection])))) + +(defn input! + "Show a text input dialog. Returns the entered text, or nil if the user + declines/cancels. opts is an optional map with :title, :description, + :min-length, :max-length, :format, and :default keys. + Throws if the host does not support elicitation." + ([session message] (input! session message nil)) + ([session message opts] + (let [field (cond-> {:type "string"} + (:title opts) (assoc :title (:title opts)) + (:description opts) (assoc :description (:description opts)) + (some? (:min-length opts)) (assoc :minLength (:min-length opts)) + (some? (:max-length opts)) (assoc :maxLength (:max-length opts)) + (:format opts) (assoc :format (:format opts)) + (some? (:default opts)) (assoc :default (:default opts))) + result (ui-elicitation! session + {:message message + :requested-schema + {:type "object" + :properties {"value" field} + :required ["value"]}})] + (when (and (= "accept" (:action result)) + (some? (get-in result [:content :value]))) + (get-in result [:content :value]))))) diff --git a/src/github/copilot_sdk/specs.clj b/src/github/copilot_sdk/specs.clj index c6fdb09..02e52b5 100644 --- a/src/github/copilot_sdk/specs.clj +++ b/src/github/copilot_sdk/specs.clj @@ -1,7 +1,8 @@ (ns github.copilot-sdk.specs "Clojure specs for Copilot SDK data structures." (:require [clojure.spec.alpha :as s] - [clojure.set :as set])) + [clojure.set :as set] + [clojure.string :as str])) ;; ----------------------------------------------------------------------------- ;; Common specs @@ -268,10 +269,48 @@ ;; with ::on-permission-request and ::on-user-input-request specs. (s/def ::on-event fn?) +;; Command definitions — slash commands registered with a session +(s/def ::command-name ::non-blank-string) +(s/def ::command-handler fn?) +(s/def ::command-definition + (s/and (s/keys :req-un [::name ::command-handler] + :opt-un [::description]) + #(not (str/blank? (:name %))))) +(s/def ::commands (s/coll-of ::command-definition)) + +;; Session capabilities — reported by the CLI host +(s/def ::elicitation boolean?) +(s/def ::ui (s/keys :opt-un [::elicitation])) +(s/def ::session-capabilities (s/keys :opt-un [::ui])) + +;; Elicitation types — action values are strings on the wire +(s/def ::elicitation-action #{"accept" "decline" "cancel"}) +(s/def ::elicitation-field-value (s/or :string string? :number number? :boolean boolean? + :strings (s/coll-of string?))) +(s/def ::elicitation-content (s/map-of keyword? ::elicitation-field-value)) +(s/def ::action ::elicitation-action) +(s/def ::content (s/nilable ::elicitation-content)) +(s/def ::elicitation-result + (s/keys :req-un [::action] + :opt-un [::content])) +(s/def ::requested-schema map?) +(s/def ::message ::non-blank-string) +(s/def ::elicitation-params + (s/keys :req-un [::message ::requested-schema])) + +;; Input options for the input! convenience method +(s/def ::title string?) +(s/def ::min-length nat-int?) +(s/def ::max-length nat-int?) +(s/def ::format #{"email" "uri" "date" "date-time"}) +(s/def ::default string?) +(s/def ::input-options + (s/keys :opt-un [::title ::description ::min-length ::max-length ::format ::default])) + (s/def ::client-name ::non-blank-string) (def session-config-keys - #{:session-id :client-name :model :tools :system-message + #{:session-id :client-name :model :tools :commands :system-message :available-tools :excluded-tools :provider :on-permission-request :streaming? :mcp-servers :custom-agents :config-dir :skill-directories @@ -282,7 +321,7 @@ (s/def ::session-config (closed-keys (s/keys :req-un [::on-permission-request] - :opt-un [::session-id ::client-name ::model ::tools ::system-message + :opt-un [::session-id ::client-name ::model ::tools ::commands ::system-message ::available-tools ::excluded-tools ::provider ::streaming? ::mcp-servers ::custom-agents ::config-dir ::skill-directories @@ -292,7 +331,7 @@ session-config-keys)) (def ^:private resume-session-config-keys - #{:client-name :model :tools :system-message :available-tools :excluded-tools + #{:client-name :model :tools :commands :system-message :available-tools :excluded-tools :provider :streaming? :on-permission-request :mcp-servers :custom-agents :config-dir :skill-directories :disabled-skills :infinite-sessions :reasoning-effort @@ -301,7 +340,7 @@ (s/def ::resume-session-config (closed-keys (s/keys :req-un [::on-permission-request] - :opt-un [::client-name ::model ::tools ::system-message ::available-tools ::excluded-tools + :opt-un [::client-name ::model ::tools ::commands ::system-message ::available-tools ::excluded-tools ::provider ::streaming? ::mcp-servers ::custom-agents ::config-dir ::skill-directories ::disabled-skills ::infinite-sessions ::reasoning-effort @@ -314,7 +353,7 @@ (s/def ::join-session-config (closed-keys (s/keys :opt-un [::on-permission-request - ::client-name ::model ::tools ::system-message ::available-tools ::excluded-tools + ::client-name ::model ::tools ::commands ::system-message ::available-tools ::excluded-tools ::provider ::streaming? ::mcp-servers ::custom-agents ::config-dir ::skill-directories ::disabled-skills ::infinite-sessions ::reasoning-effort @@ -515,7 +554,8 @@ ;; Session status events :copilot/session.tools_updated :copilot/session.background_tasks_changed :copilot/session.skills_loaded :copilot/session.mcp_servers_loaded - :copilot/session.mcp_server_status_changed :copilot/session.extensions_loaded}) + :copilot/session.mcp_server_status_changed :copilot/session.extensions_loaded + :copilot/session.custom_agents_updated}) ;; Session events (s/def ::already-in-use? boolean?) @@ -540,6 +580,11 @@ (s/def ::session.idle-data map?) +(s/def ::remote-session-id string?) + +(s/def ::session.handoff-data + (s/keys :opt-un [::remote-session-id ::host])) + (s/def ::agent-mode #{:interactive :plan :autopilot :shell}) (s/def ::interaction-id string?) diff --git a/test/github/copilot_sdk/integration_test.clj b/test/github/copilot_sdk/integration_test.clj index f595094..b89ea7a 100644 --- a/test/github/copilot_sdk/integration_test.clj +++ b/test/github/copilot_sdk/integration_test.clj @@ -1190,3 +1190,173 @@ (sdk/destroy! s1) (sdk/destroy! s2) (sdk/destroy! s3)))) + +;; ----------------------------------------------------------------------------- +;; Command Tests +;; ----------------------------------------------------------------------------- + +(deftest test-commands-on-wire + (testing "commands are sent on wire as name+description only" + (let [seen (atom {}) + _ (mock/set-request-hook! *mock-server* (fn [method params] + (when (#{"session.create" "session.resume"} method) + (swap! seen assoc method params)))) + _ (sdk/create-session *test-client* + {:on-permission-request sdk/approve-all + :commands [{:name "deploy" + :description "Deploy the app" + :command-handler (fn [_ctx] nil)} + {:name "rollback" + :command-handler (fn [_ctx] nil)}]}) + session-id (sdk/get-last-session-id *test-client*) + _ (sdk/resume-session *test-client* session-id + {:on-permission-request sdk/approve-all + :commands [{:name "status" + :description "Check status" + :command-handler (fn [_ctx] nil)}]}) + create-params (get @seen "session.create") + resume-params (get @seen "session.resume")] + ;; Commands are sent with name and description only (no handler) + (is (= [{:name "deploy" :description "Deploy the app"} + {:name "rollback"}] + (:commands create-params))) + (is (= [{:name "status" :description "Check status"}] + (:commands resume-params)))))) + +(deftest test-command-execute-v3 + (testing "v3 command.execute event routes to handler and sends RPC response" + (let [requests (atom []) + handler-called (atom nil) + 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.commands.handlePendingCommand" method) + (.countDown rpc-latch)))) + session (sdk/create-session *test-client* + {:on-permission-request sdk/approve-all + :commands [{:name "deploy" + :command-handler (fn [ctx] + (reset! handler-called ctx))}]}) + session-id (sdk/session-id session)] + ;; Force protocol v3 + (swap! (:state *test-client*) assoc :negotiated-protocol-version 3) + (reset! requests []) + ;; Inject command.execute event + (mock/send-session-event! *mock-server* session-id + "command.execute" + {:requestId "cmd-req-1" + :command "/deploy production" + :commandName "deploy" + :args "production"}) + (is (.await rpc-latch 5 java.util.concurrent.TimeUnit/SECONDS)) + ;; Handler was called with context + (is (some? @handler-called)) + (is (= session-id (:session-id @handler-called))) + (is (= "/deploy production" (:command @handler-called))) + (is (= "deploy" (:command-name @handler-called))) + (is (= "production" (:args @handler-called))) + ;; handlePendingCommand RPC was sent + (let [cmd-rpcs (filter #(= "session.commands.handlePendingCommand" (:method %)) @requests)] + (is (= 1 (count cmd-rpcs))) + (when (seq cmd-rpcs) + (is (= "cmd-req-1" (:requestId (:params (first cmd-rpcs))))) + (is (nil? (:error (:params (first cmd-rpcs)))))))))) + +(deftest test-command-execute-unknown-command + (testing "unknown command sends error via RPC" + (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.commands.handlePendingCommand" method) + (.countDown rpc-latch)))) + session (sdk/create-session *test-client* + {:on-permission-request sdk/approve-all + :commands [{:name "deploy" + :command-handler (fn [_] 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 + "command.execute" + {:requestId "cmd-req-2" + :command "/unknown" + :commandName "unknown" + :args ""}) + (is (.await rpc-latch 5 java.util.concurrent.TimeUnit/SECONDS)) + (let [cmd-rpcs (filter #(= "session.commands.handlePendingCommand" (:method %)) @requests)] + (is (= 1 (count cmd-rpcs))) + (when (seq cmd-rpcs) + (is (string? (:error (:params (first cmd-rpcs))))) + (is (re-find #"Unknown command" (:error (:params (first cmd-rpcs)))))))))) + +(deftest test-command-handler-error + (testing "command handler exception sends error via RPC" + (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.commands.handlePendingCommand" method) + (.countDown rpc-latch)))) + session (sdk/create-session *test-client* + {:on-permission-request sdk/approve-all + :commands [{:name "fail" + :command-handler (fn [_] + (throw (Exception. "deploy failed")))}]}) + 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 + "command.execute" + {:requestId "cmd-req-3" + :command "/fail" + :commandName "fail" + :args ""}) + (is (.await rpc-latch 5 java.util.concurrent.TimeUnit/SECONDS)) + (let [cmd-rpcs (filter #(= "session.commands.handlePendingCommand" (:method %)) @requests)] + (is (= 1 (count cmd-rpcs))) + (when (seq cmd-rpcs) + (is (= "deploy failed" (:error (:params (first cmd-rpcs)))))))))) + +;; ----------------------------------------------------------------------------- +;; Capabilities and Elicitation Tests +;; ----------------------------------------------------------------------------- + +(deftest test-session-capabilities + (testing "capabilities default to empty map when not in response" + (let [session (sdk/create-session *test-client* + {:on-permission-request sdk/approve-all})] + (is (= {} (sdk/capabilities session))) + (is (false? (sdk/elicitation-supported? session)))))) + +(deftest test-session-capabilities-from-response + (testing "capabilities stored from session.create response" + (let [_ (mock/set-request-hook! *mock-server* + (fn [method _params] + (when (= "session.create" method) + {::mock/merge-response {:capabilities {:ui {:elicitation true}}}}))) + session (sdk/create-session *test-client* + {:on-permission-request sdk/approve-all})] + (is (= {:ui {:elicitation true}} (sdk/capabilities session))) + (is (true? (sdk/elicitation-supported? session)))))) + +(deftest test-elicitation-throws-when-unsupported + (testing "elicitation convenience methods throw when not supported" + (let [session (sdk/create-session *test-client* + {:on-permission-request sdk/approve-all})] + (is (thrown-with-msg? clojure.lang.ExceptionInfo #"not supported" + (sdk/confirm! session "test"))) + (is (thrown-with-msg? clojure.lang.ExceptionInfo #"not supported" + (sdk/select! session "test" ["a" "b"]))) + (is (thrown-with-msg? clojure.lang.ExceptionInfo #"not supported" + (sdk/input! session "test")))))) + +(deftest test-session-without-commands + (testing "session without commands has empty command handlers" + (let [session (sdk/create-session *test-client* + {:on-permission-request sdk/approve-all})] + (is (= {} (get-in @(:state *test-client*) + [:sessions (sdk/session-id session) :command-handlers])))))) diff --git a/test/github/copilot_sdk/mock_server.clj b/test/github/copilot_sdk/mock_server.clj index 6f3fff9..2a878e3 100644 --- a/test/github/copilot_sdk/mock_server.clj +++ b/test/github/copilot_sdk/mock_server.clj @@ -278,9 +278,10 @@ (defn- handle-request [server msg] (let [method (:method msg) params (:params msg) - ;; Call hook if set - _ (when-let [hook @(:on-request server)] - (hook method params)) + ;; Call hook if set — hooks can optionally return a map with ::merge-response + ;; whose value will be merged into the handler result (see merge logic below). + hook-result (when-let [hook @(:on-request server)] + (hook method params)) result (case method "ping" (handle-ping server params) "status.get" (handle-status-get server params) @@ -301,7 +302,16 @@ "session.model.switchTo" (handle-session-model-switch-to server params) "session.log" (handle-session-log server params) "session.permissions.handlePendingPermissionRequest" {:ok true} - (throw (ex-info "Method not found" {:code -32601 :method method})))] + "session.commands.handlePendingCommand" {:ok 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) + result (let [extra (when (map? hook-result) (::merge-response hook-result))] + (cond + (nil? extra) result + (map? extra) (merge result extra) + :else (throw (ex-info "::merge-response value must be a map" + {:code -32603 :method method :extra-value extra}))))] {:jsonrpc "2.0" :id (:id msg) :result result}))