From e803c36105d0a5fdc53be5bff8cd3b5ad08c3984 Mon Sep 17 00:00:00 2001 From: GitHub Copilot Date: Tue, 24 Mar 2026 14:32:38 +0000 Subject: [PATCH] Port upstream PR #906: commands and UI elicitation support Ports [Node] Add Commands and UI Elicitation Support to SDK (#906) from github/copilot-sdk. New features: - :commands session config option: register slash commands with handlers {:command-name "name" :command-handler (fn [ctx] ...) :command-description "desc"} Commands appear as /name in CLI TUI; handler receives context map {:session-id :command-name :command :args} - command.execute broadcast event handled via session.commands.handlePendingCommand RPC - session/capabilities: returns host capabilities from create/resume response e.g. {:ui {:elicitation true}} - New specs: ::command-definition, ::commands, ::session-capabilities, ::input-options - Instrumentation/fdefs for all new public functions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 9 +++ src/github/copilot_sdk/client.clj | 57 +++++++++++++- src/github/copilot_sdk/instrument.clj | 28 +++++++ src/github/copilot_sdk/session.clj | 108 +++++++++++++++++++++++++- src/github/copilot_sdk/specs.clj | 42 ++++++++-- 5 files changed, 235 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a056149..1e25749 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,15 @@ All notable changes to this project will be documented in this file. This change ## [Unreleased] +### Added (v0.2.0 sync) +- **Slash command support** — register custom slash commands with sessions via the new `:commands` option in `create-session` and `resume-session`. Commands are described as `{:command-name "name" :command-handler (fn [ctx] ...) :command-description "desc"}`. The handler receives a context map with `:session-id`, `:command-name`, `:command`, and `:args`. When the CLI has a TUI, registered commands appear as `/name` for users to invoke. The `command.execute` broadcast event is handled automatically and responds via `session.commands.handlePendingCommand` RPC (upstream PR #906). +- **Session capabilities** — `session/capabilities` getter returns host capabilities reported when the session was created or resumed (e.g. `{:ui {:elicitation true}}`). Use this to check feature support before calling capability-gated APIs (upstream PR #906). +- **UI convenience methods** — three new experimental functions in the `session` namespace for interactive dialogs (upstream PR #906): + - `ui-confirm!` — shows a confirmation dialog, returns `true` or `false` + - `ui-select!` — shows a selection dialog with string options, returns selected value or `nil` + - `ui-input!` — shows a text input dialog with optional constraints, returns entered text or `nil` +- New specs: `::command-definition`, `::commands`, `::session-capabilities`, `::input-options`, and related component specs. + ## [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/src/github/copilot_sdk/client.clj b/src/github/copilot_sdk/client.clj index cbd77f4..8579002 100644 --- a/src/github/copilot_sdk/client.clj +++ b/src/github/copilot_sdk/client.clj @@ -300,8 +300,39 @@ :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 registered 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 (or (:args data) "")] + (when (and request-id command-name) + (go + (try + (let [cmd-response ( {:session-id session-id :request-id request-id} + (:error cmd-response) (assoc :error (:error cmd-response))))))) + (catch Exception e + (log/debug "v3 command execute error for " request-id ": " (ex-message e)) + (try + (let [conn (:connection-io @(:state client))] + (when conn + ( {:name (:command-name c)} + (:command-description c) + (assoc :description (:command-description c)))) + (:commands config))) wire-sys-msg (when-let [sm (:system-message config)] (system-message->wire sm)) wire-provider (when-let [provider (:provider config)] @@ -1185,6 +1224,7 @@ (: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)) @@ -1220,6 +1260,11 @@ (some? (:skip-permission? t)) (assoc :skipPermission (:skip-permission? t)))) (:tools config))) + wire-commands (when (:commands config) + (mapv (fn [c] (cond-> {:name (:command-name c)} + (:command-description c) + (assoc :description (:command-description c)))) + (:commands config))) wire-sys-msg (when-let [sm (:system-message config)] (system-message->wire sm)) wire-provider (when-let [provider (:provider config)] @@ -1234,6 +1279,7 @@ (: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 +1307,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) @@ -1300,6 +1347,9 @@ :on-session-start, :on-session-end, :on-error-occurred} - :on-event - Event handler (1-arg fn) registered before the RPC call. Guarantees early events like session.start are not missed. + - :commands - Slash commands to register with the session. Vector of maps: + {:command-name \"name\" :command-handler (fn [ctx] ...) :command-description \"desc\"} + The handler receives a context map: {:session-id :command-name :command :args} Returns a CopilotSession." [client config] @@ -1318,6 +1368,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 @@ -1348,6 +1399,7 @@ - :hooks - Lifecycle hooks map - :on-event - Event handler (1-arg fn) registered before the RPC call. Guarantees early events like session.start are not missed. + - :commands - Slash commands to register with the session. See create-session for details. Returns a CopilotSession." [client session-id config] @@ -1375,6 +1427,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 +1477,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] [(:command-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 + :capabilities {} :destroyed? false :workspace-path workspace-path :config config}) @@ -103,6 +106,12 @@ (when workspace-path (swap! (:state client) assoc-in [:sessions session-id :workspace-path] workspace-path))) +(defn set-capabilities! + "Update session capabilities from the create/resume RPC response. + Called after session.create or session.resume succeeds." + [client session-id caps] + (swap! (:state client) assoc-in [:sessions session-id :capabilities] (or caps {}))) + (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 +357,30 @@ {:result nil}))))))) :io)) +(defn handle-command! + "Handle an incoming command.execute event. Returns a channel with the result. + Context map passed to handler mirrors TypeScript's CommandContext: + {:session-id :command-name :command :args}" + [client session-id 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 [ctx {:session-id session-id + :command-name command-name + :command command + :args args} + result (handler ctx) + ;; If handler returns a channel, await it + _ (when (channel? result) (clj (proto/send-request! conn "session.ui.elicitation" (assoc (util/clj->wire params) :sessionId session-id))))) + +(defn ^:experimental ui-confirm! + "Show a confirmation dialog and return true if the user confirmed, false otherwise. + Returns false if the user declines or cancels. + Throws if the host does not support elicitation (check (capabilities session) first)." + [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 ^:experimental ui-select! + "Show a selection dialog with the given string options. + Returns the selected value, or nil if the user declines or cancels. + Throws if the host does not support elicitation (check (capabilities session) first)." + [session message options] + (let [result (ui-elicitation! session + {:message message + :requested-schema + {:type "object" + :properties {:selection {:type "string" :enum options}} + :required ["selection"]}})] + (when (= "accept" (:action result)) + (get-in result [:content :selection])))) + +(defn ^:experimental ui-input! + "Show a text input dialog. + Returns the entered text, or nil if the user declines or cancels. + opts (optional map): + - :title - Label for the input field + - :description - Descriptive text shown below the field + - :min-length - Minimum character length + - :max-length - Maximum character length + - :format - Semantic format hint: \"email\", \"uri\", \"date\", \"date-time\" + - :default - Default value pre-populated in the field + Throws if the host does not support elicitation (check (capabilities session) first)." + ([session message] (ui-input! session message nil)) + ([session message opts] + (let [field (cond-> {:type "string"} + (:title opts) (assoc :title (:title opts)) + (:description opts) (assoc :description (:description opts)) + (:min-length opts) (assoc :minLength (:min-length opts)) + (:max-length opts) (assoc :maxLength (:max-length opts)) + (:format opts) (assoc :format (:format opts)) + (contains? opts :default) (assoc :default (:default opts))) + result (ui-elicitation! session + {:message message + :requested-schema + {:type "object" + :properties {:value field} + :required ["value"]}})] + (when (= "accept" (:action result)) + (get-in result [:content :value]))))) diff --git a/src/github/copilot_sdk/specs.clj b/src/github/copilot_sdk/specs.clj index c6fdb09..5c8d400 100644 --- a/src/github/copilot_sdk/specs.clj +++ b/src/github/copilot_sdk/specs.clj @@ -270,8 +270,38 @@ (s/def ::client-name ::non-blank-string) +;; ----------------------------------------------------------------------------- +;; Command definitions (upstream PR #906) +;; ----------------------------------------------------------------------------- + +(s/def ::command-name ::non-blank-string) +(s/def ::command-description string?) +(s/def ::command-handler fn?) + +(s/def ::command-definition + (s/keys :req-un [::command-name ::command-handler] + :opt-un [::command-description])) + +(s/def ::commands (s/coll-of ::command-definition)) + +;; ----------------------------------------------------------------------------- +;; Session capabilities (upstream PR #906) +;; ----------------------------------------------------------------------------- + +(s/def ::elicitation boolean?) +(s/def ::ui (s/keys :opt-un [::elicitation])) +(s/def ::session-capabilities (s/keys :opt-un [::ui])) + +;; Input options for ui-input! convenience method +(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])) + (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 +312,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 +322,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 +331,8 @@ (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 +345,8 @@ (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