Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
57 changes: 56 additions & 1 deletion src/github/copilot_sdk/client.clj
Original file line number Diff line number Diff line change
Expand Up @@ -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/handle-command!
client session-id command-name command args))
conn (:connection-io @(:state client))]
(when conn
(<! (proto/send-request conn "session.commands.handlePendingCommand"
(cond-> {: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
(<! (proto/send-request conn "session.commands.handlePendingCommand"
{:session-id session-id
:request-id request-id
:error (ex-message e)}))))
(catch Exception _ nil))))))))

(defn- handle-v3-broadcast-event!
"Protocol v3: intercept broadcast events for external tools and permissions.
"Protocol v3: intercept broadcast events for external tools, permissions, and commands.
In v3, tool.call and permission.request server→client RPC methods are replaced
by broadcast events that the SDK handles and responds to via new RPC methods."
[client session-id event]
Expand All @@ -313,6 +344,9 @@
:copilot/permission.requested
(handle-v3-permission-requested! client session-id event)

:copilot/command.execute
(handle-v3-command-execute! client session-id event)

nil)))

(defn- start-notification-router!
Expand Down Expand Up @@ -1170,6 +1204,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)]
Expand All @@ -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))
Expand Down Expand Up @@ -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)]
Expand All @@ -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))
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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]
Expand All @@ -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
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 <resume-session
Expand Down Expand Up @@ -1481,6 +1535,7 @@
{:error err :session-id session-id}))
(let [result (:result response)]
(session/set-workspace-path! client session-id (:workspace-path result))
(session/set-capabilities! client session-id (:capabilities result))
session)))))))
(defn join-session
"Join the current foreground session from an extension running as a child process.
Expand Down
28 changes: 28 additions & 0 deletions src/github/copilot_sdk/instrument.clj
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,26 @@
:args (s/cat :session ::specs/session :params map?)
:ret map?)

(s/fdef github.copilot-sdk.session/capabilities
:args (s/cat :session ::specs/session)
:ret ::specs/session-capabilities)

(s/fdef github.copilot-sdk.session/ui-confirm!
:args (s/cat :session ::specs/session :message string?)
:ret boolean?)

(s/fdef github.copilot-sdk.session/ui-select!
:args (s/cat :session ::specs/session
:message string?
:options (s/coll-of string?))
:ret (s/nilable string?))

(s/fdef github.copilot-sdk.session/ui-input!
:args (s/cat :session ::specs/session
:message string?
:opts (s/? (s/nilable ::specs/input-options)))
:ret (s/nilable string?))

;; -----------------------------------------------------------------------------
;; Function specs for helpers namespace
;; -----------------------------------------------------------------------------
Expand Down Expand Up @@ -389,6 +409,10 @@
github.copilot-sdk.session/shell-exec!
github.copilot-sdk.session/shell-kill!
github.copilot-sdk.session/ui-elicitation!
github.copilot-sdk.session/capabilities
github.copilot-sdk.session/ui-confirm!
github.copilot-sdk.session/ui-select!
github.copilot-sdk.session/ui-input!
github.copilot-sdk.session/events
github.copilot-sdk.session/subscribe-events
github.copilot-sdk.session/unsubscribe-events
Expand Down Expand Up @@ -457,6 +481,10 @@
github.copilot-sdk.session/shell-exec!
github.copilot-sdk.session/shell-kill!
github.copilot-sdk.session/ui-elicitation!
github.copilot-sdk.session/capabilities
github.copilot-sdk.session/ui-confirm!
github.copilot-sdk.session/ui-select!
github.copilot-sdk.session/ui-input!
github.copilot-sdk.session/events
github.copilot-sdk.session/subscribe-events
github.copilot-sdk.session/unsubscribe-events
Expand Down
108 changes: 105 additions & 3 deletions src/github/copilot_sdk/session.clj
Original file line number Diff line number Diff line change
Expand Up @@ -50,21 +50,24 @@
If :on-event is provided, taps a subscriber that forwards events to the handler
on a dedicated thread. Uses a sliding buffer, so events may be dropped under
extreme backpressure if the handler cannot keep up with the event rate."
[client session-id {:keys [tools on-permission-request on-user-input-request hooks workspace-path on-event config]}]
[client session-id {:keys [tools commands on-permission-request on-user-input-request hooks workspace-path on-event config]}]
(log/debug "Creating session: " session-id)
(let [event-chan (chan (async/sliding-buffer 4096))
event-mult (mult event-chan)
send-lock (doto (chan 1) (>!! :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})
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) (<!! result))]
{:ok true})
(catch Exception e
(log/error "Command handler error for session " session-id ", command " command-name ": " (ex-message e))
{:error (ex-message e)})))))
:io))

;; -----------------------------------------------------------------------------
;; Public API - functions that take CopilotSession handle
;; -----------------------------------------------------------------------------
Expand Down Expand Up @@ -1023,12 +1056,81 @@

;; -- UI Elicitation ----------------------------------------------------------

(defn capabilities
"Return the host capabilities reported when the session was created or resumed.
Returns a map with optional :ui key. Check :ui/:elicitation before calling UI methods.

Example:
(when (get-in (session/capabilities session) [:ui :elicitation])
(session/ui-confirm! session \"Deploy to production?\"))"
[session]
(let [{:keys [session-id client]} session]
(:capabilities (session-state client session-id))))

(defn ^:experimental 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 elicitation configuration (schema, message, etc.).
Throws if the host does not support elicitation (check capabilities first)."
[session params]
(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 ^: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])))))
Loading