diff --git a/CHANGELOG.md b/CHANGELOG.md index 4696e3d..47fb4d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,10 +4,21 @@ All notable changes to this project will be documented in this file. This change ## [Unreleased] ### Added (v0.2.1 sync) -- **`steerable` field on `session.start` events** — `session.start` event data now includes optional `:steerable?` boolean field indicating whether the session supports remote steering via Mission Control. New `::steerable?` spec added (upstream PR #927). +- **`remote-steerable?` field on `session.start` and `session.resume` events** — event data now includes optional `:remote-steerable?` boolean field indicating whether the session supports remote steering via Mission Control. Replaces previous `:steerable?` (upstream PRs #927, #908). - **`get-session-metadata`** — new function on client for efficient O(1) session lookup by ID. Returns session metadata map if found, or `nil` if not found. Sends `session.getMetadata` JSON-RPC call. Shared `wire->session-metadata` helper extracted from `list-sessions` to eliminate duplication (upstream PR #899). +- **Elicitation provider support** — new `:on-elicitation-request` handler on `SessionConfig` and `ResumeSessionConfig`. When provided, sends `requestElicitation: true` in the session create/resume RPC. The runtime routes `elicitation.requested` broadcast events to the handler, and results are sent back via `session.ui.handlePendingElicitation` RPC. Handler errors automatically send a cancel response. New `::elicitation-request` and `::on-elicitation-request` specs (upstream PR #908). +- **`capabilities.changed` event handling** — session capabilities are dynamically updated when `capabilities.changed` broadcast events are received, e.g. when another client joins with elicitation support (upstream PR #908). +- **New event types** — `sampling.requested`, `sampling.completed`, `session.remote_steerable_changed`, `capabilities.changed` added to event type enum and event sets (upstream PRs #908, #916). +- **Subagent event data fields** — `subagent.started`, `subagent.completed`, `subagent.failed` events now include optional `:model`, `:total-tool-calls`, `:total-tokens`, `:duration-ms` fields. New `::subagent.started-data`, `::subagent.completed-data`, `::subagent.failed-data` specs (upstream PR #916). +- **`skill.invoked` event `:description` field** — optional `:description` from SKILL.md frontmatter (upstream PR #916). +- **`session.custom_agents_updated` payload spec** — full `::session.custom_agents_updated-data` spec with `:agents` (array of agent metadata), `:warnings`, `:errors`. New `::custom-agent-info` spec (upstream PR #916). +- **SessionFs virtual filesystem** — new `:session-fs` client option with `:initial-cwd`, `:session-state-path`, `:conventions`. Client calls `sessionFs.setProvider` RPC on connect. New `:create-session-fs-handler` on session config provides a per-session FS handler factory. The SDK dispatches incoming `sessionFs.*` RPC requests (10 operations: `readFile`, `writeFile`, `appendFile`, `exists`, `stat`, `mkdir`, `readdir`, `readdirWithTypes`, `rm`, `rename`) to the session's handler. Enables custom session storage backends (upstream PR #917). +- **`aborted?` on `session.task_complete`** — optional boolean indicating the preceding agentic loop was cancelled via abort signal. New `::aborted?` spec (upstream PR #917). +- **`timeout` tool result type** — `::result-type` now accepts `:timeout` / `"timeout"` for tool calls that timed out (upstream PR #970). +- Integration tests for elicitation provider routing, handler error→cancel fallback, capabilities.changed updates, and requestElicitation wire flag. ### Changed (v0.2.1 sync) +- **BREAKING**: `::steerable?` renamed to `::remote-steerable?` on `session.start` and `session.resume` event data, matching upstream wire field rename from `steerable` to `remoteSteerable` (upstream PR #908). - **`session.idle` is now ephemeral** — the runtime no longer persists `session.idle` events in session history. `get-messages` will no longer return `session.idle` events. Live event listeners (used by `send-and-wait!` and `send!`) are unaffected and still receive it (upstream PR #927). ## [0.2.1.1-SNAPSHOT] - 2026-03-26 diff --git a/doc/reference/API.md b/doc/reference/API.md index 3e4c91a..e330997 100644 --- a/doc/reference/API.md +++ b/doc/reference/API.md @@ -130,6 +130,7 @@ Get information about the current shared client state. Returns `nil` if no share | `:use-logged-in-user?` | boolean | `true` | Use logged-in user auth. Defaults to `false` when `:github-token` is provided. Cannot be used with `:cli-url` | | `:on-list-models` | fn | nil | Zero-arg function returning model info maps. Bypasses `models.list` RPC; does not require `start!`. Results are cached the same way as RPC results | | `:is-child-process?` | boolean | `false` | When `true`, connect via own stdio to a parent Copilot CLI process (no process spawning). Requires `:use-stdio?` `true`; mutually exclusive with `:cli-url` | +| `:session-fs` | map | nil | Session filesystem provider config. Keys: `:initial-cwd` (string, required), `:session-state-path` (string, required), `:conventions` (`"windows"` or `"posix"`, required). When set, the client calls `sessionFs.setProvider` on connect and routes filesystem operations through per-session handlers. See [Session Filesystem](#session-filesystem) | ### Methods @@ -254,6 +255,8 @@ Create a client and session together, ensuring both are cleaned up on exit. | `:hooks` | map | Lifecycle hooks (see below) | | `:agent` | string | Name of a custom agent to activate at session start. Must match a name in `:custom-agents`. Equivalent to calling `agent.select` after creation. | | `:on-event` | fn | Event handler (1-arg fn receiving event maps). Registered before the RPC call, guaranteeing early events like `session.start` are not missed. | +| `:on-elicitation-request` | fn | Handler for elicitation requests from the agent. When provided, advertises `requestElicitation=true` and handles `elicitation.requested` broadcast events. Receives `(request ctx)` where request has `:message`, `:requested-schema`, `:mode`, `:elicitation-source`, `:url`. Returns an `ElicitationResult` map `{:action "accept"/"decline"/"cancel" :content {...}}`. See [Elicitation Provider](#elicitation-provider) | +| `:create-session-fs-handler` | fn | Factory for session filesystem handlers. Required when `:session-fs` is set on the client. Called as `(factory session)`, returns a map of FS handler functions. See [Session Filesystem](#session-filesystem) | #### `resume-session` @@ -1135,8 +1138,8 @@ Convert an unqualified event keyword to a namespace-qualified `:copilot/` keywor | `:copilot/session.mode_changed` | Session agent mode changed; data: `{:previous-mode "...", :new-mode "..."}` | | `:copilot/session.plan_changed` | Session plan created/updated/deleted; data: `{:operation "create"/"update"/"delete"}` | | `:copilot/session.workspace_file_changed` | Workspace file created or updated; data: `{:path "...", :operation "create"/"update"}` | -| `:copilot/session.task_complete` | Task completed by the session agent; data: `{:summary "..."}` (optional) | -| `:copilot/skill.invoked` | Skill invocation triggered | +| `:copilot/session.task_complete` | Task completed by the session agent; data: `{:summary "..." :aborted? false}` (both optional) | +| `:copilot/skill.invoked` | Skill invocation triggered; data includes :name, :path, :content, optional :description, :plugin-name, :plugin-version | | `:copilot/user.message` | User message added | | `:copilot/pending_messages.modified` | Pending message queue updated | | `:copilot/assistant.turn_start` | Assistant turn started | @@ -1154,9 +1157,9 @@ Convert an unqualified event keyword to a namespace-qualified `:copilot/` keywor | `:copilot/tool.execution_progress` | Tool execution progress update | | `:copilot/tool.execution_partial_result` | Tool execution partial result | | `:copilot/tool.execution_complete` | Tool execution completed | -| `:copilot/subagent.started` | Subagent started | -| `:copilot/subagent.completed` | Subagent completed | -| `:copilot/subagent.failed` | Subagent failed | +| `:copilot/subagent.started` | Subagent started; data includes :tool-call-id, :agent-name, :agent-display-name, :agent-description | +| `:copilot/subagent.completed` | Subagent completed; data includes :tool-call-id, :agent-name, :agent-display-name, optional :model, :total-tool-calls, :total-tokens, :duration-ms | +| `:copilot/subagent.failed` | Subagent failed; data includes :tool-call-id, :agent-name, :agent-display-name, :error, optional :model, :total-tool-calls, :total-tokens, :duration-ms | | `:copilot/subagent.selected` | Subagent selected | | `:copilot/subagent.deselected` | Subagent deselected | | `:copilot/hook.start` | Hook invocation started | @@ -1186,6 +1189,10 @@ Convert an unqualified event keyword to a namespace-qualified `:copilot/` keywor | `: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 | +| `:copilot/sampling.requested` | MCP sampling request initiated; ephemeral | +| `:copilot/sampling.completed` | MCP sampling request completed; ephemeral | +| `:copilot/session.remote_steerable_changed` | Session remote steering capability changed; data: `{:remote-steerable true/false}` | +| `:copilot/capabilities.changed` | Session capabilities dynamically changed (e.g., elicitation support); ephemeral. Data: `{:ui {:elicitation true/false}}` | ### Example: Handling Events @@ -1673,6 +1680,95 @@ The response map should include: - `:answer` - The user's answer (string, required). `:response` is also accepted for convenience. - `:was-freeform` - Whether the answer was freeform (boolean, defaults to true) +### Elicitation Provider + +Provide a handler for elicitation requests from the agent. This enables the SDK client to act as a UI provider for form-based dialogs. + +```clojure +(require '[github.copilot-sdk :as copilot]) + +(def session + (copilot/create-session client + {:on-permission-request copilot/approve-all + :on-elicitation-request + (fn [request {:keys [session-id]}] + ;; request keys: :message, :requested-schema, :mode, :elicitation-source, :url + (println "Elicitation:" (:message request)) + {:action "accept" + :content {:name "user-input"}})})) +``` + +The handler receives two arguments: + +| Argument | Description | +|----------|-------------| +| `request` | Map with `:message` (string), optional `:requested-schema` (JSON Schema map), `:mode` (`"form"` or `"url"`), `:elicitation-source` (string), `:url` (string) | +| `ctx` | Map with `:session-id` (string) | + +Return an `ElicitationResult` map: + +| Key | Type | Description | +|-----|------|-------------| +| `:action` | string | `"accept"`, `"decline"`, or `"cancel"` | +| `:content` | map | Field values when action is `"accept"` | + +If the handler throws, the SDK sends `{:action "cancel"}` to prevent the request from hanging. + +When `:on-elicitation-request` is set, the session advertises `requestElicitation=true` in the create/resume RPC. Capabilities are updated dynamically via `capabilities.changed` events. + +### Session Filesystem + +Virtualize per-session storage with custom filesystem handlers. The runtime routes all session-scoped file I/O (event logs, large outputs, checkpoints) through the provided callbacks. + +Configure the client with `:session-fs`: + +```clojure +(require '[github.copilot-sdk :as copilot]) + +(def client + (copilot/client {:session-fs {:initial-cwd "/home/user/project" + :session-state-path "/sessions" + :conventions "posix"}})) +``` + +Provide a handler factory per session: + +```clojure +(def session + (copilot/create-session client + {:on-permission-request copilot/approve-all + :create-session-fs-handler + (fn [session] + (let [store (atom {})] + {:read-file (fn [{:keys [path]}] {:content (get @store path "")}) + :write-file (fn [{:keys [path content]}] (swap! store assoc path content) nil) + :append-file (fn [{:keys [path content]}] (swap! store update path str content) nil) + :exists (fn [{:keys [path]}] {:exists (contains? @store path)}) + :stat (fn [{:keys [path]}] {:is-file true :is-directory false :size (count (get @store path "")) :mtime "2026-01-01T00:00:00Z"}) + :mkdir (fn [_] nil) + :readdir (fn [_] {:entries []}) + :readdir-with-types (fn [_] {:entries []}) + :rm (fn [{:keys [path]}] (swap! store dissoc path) nil) + :rename (fn [{:keys [old-path new-path]}] (swap! store (fn [s] (-> s (assoc new-path (get s old-path "")) (dissoc old-path)))) nil)}))})) +``` + +The handler map requires all 10 operations: + +| Key | Params | Returns | +|-----|--------|---------| +| `:read-file` | `{:session-id :path}` | `{:content "..."}` | +| `:write-file` | `{:session-id :path :content :mode}` | nil | +| `:append-file` | `{:session-id :path :content}` | nil | +| `:exists` | `{:session-id :path}` | `{:exists true/false}` | +| `:stat` | `{:session-id :path}` | `{:is-file :is-directory :size :mtime}` | +| `:mkdir` | `{:session-id :path :recursive}` | nil | +| `:readdir` | `{:session-id :path}` | `{:entries [...]}` | +| `:readdir-with-types` | `{:session-id :path}` | `{:entries [...]}` | +| `:rm` | `{:session-id :path :recursive :force}` | nil | +| `:rename` | `{:session-id :old-path :new-path}` | nil | + +Handler functions may return values directly or via core.async channels. + ### Session Hooks Lifecycle hooks allow custom logic at various points during the session: diff --git a/examples/README.md b/examples/README.md index 4213699..a6f1b75 100644 --- a/examples/README.md +++ b/examples/README.md @@ -812,6 +812,39 @@ clojure -A:examples -X ask-user-failure/run --- +## Example 18: Elicitation Provider (`elicitation_provider.clj`) + +**Difficulty:** Intermediate +**Concepts:** Elicitation requests, provider callbacks, MCP OAuth, capabilities + +Demonstrates how to act as an elicitation provider — handling form-based or URL-based input requests from MCP servers and sub-agents. + +### What It Demonstrates + +- Registering an `:on-elicitation-request` handler +- Inspecting elicitation mode (`"form"` vs `"url"`) +- Auto-filling form fields from a JSON Schema +- Observing `elicitation.requested` and `capabilities.changed` events + +### Usage + +```bash +clojure -A:examples -X elicitation-provider/run +``` + +### Code Walkthrough + +The handler receives a request map with `:message`, optional `:requested-schema` (JSON Schema), `:mode` (`"form"` or `"url"`), `:elicitation-source`, and `:url`. It returns an `ElicitationResult`: + +```clojure +{:action "accept" ;; or "decline" or "cancel" + :content {:field-name "value"}} +``` + +If the handler throws, the SDK sends `{:action "cancel"}` to prevent hanging. In a real application, the handler would render a UI dialog or open a browser for OAuth flows. + +--- + ## Clojure vs JavaScript Comparison Here's how common patterns compare between the Clojure and JavaScript SDKs: diff --git a/examples/elicitation_provider.clj b/examples/elicitation_provider.clj new file mode 100644 index 0000000..24fecd1 --- /dev/null +++ b/examples/elicitation_provider.clj @@ -0,0 +1,113 @@ +(ns elicitation-provider + "Example: Acting as an elicitation provider. + + When an MCP server or sub-agent needs user input (e.g., OAuth consent, + configuration choices), the runtime sends an elicitation request to the + SDK client. This example shows how to handle those requests. + + The example simulates a scenario where an MCP server triggers an OAuth + consent flow — the handler prints the request and auto-approves it." + (:require [clojure.core.async :refer [chan tap go-loop = (negotiated-protocol-version client) 3) + (when (= event-type :copilot/capabilities.changed) + (session/update-capabilities! client session-id (:data normalized-event)))) (when-let [{:keys [event-chan]} (get-in @(:state client) [:session-io session-id])] (>! event-chan normalized-event)) - ;; Protocol v3: handle broadcast events for tools and permissions + ;; Protocol v3: handle broadcast events for tools, permissions, elicitation (when (>= (negotiated-protocol-version client) 3) (handle-v3-broadcast-event! client session-id normalized-event)))) @@ -532,7 +582,7 @@ (str/join "\n" lines))))) (defn- setup-request-handler! - "Set up handler for incoming requests (tool calls, permission requests, hooks, user input)." + "Set up handler for incoming requests (tool calls, permission requests, hooks, user input, sessionFs)." [client] (let [{:keys [connection-io]} @(:state client)] (proto/set-request-handler! connection-io @@ -586,6 +636,17 @@ {:result (session/handle-system-message-transform client session-id sections)})) + ;; SessionFs operations (upstream PR #917) + ("sessionFs.readFile" "sessionFs.writeFile" "sessionFs.appendFile" + "sessionFs.exists" "sessionFs.stat" "sessionFs.mkdir" + "sessionFs.readdir" "sessionFs.readdirWithTypes" + "sessionFs.rm" "sessionFs.rename") + (let [{:keys [session-id]} params] + (if-not (get-in @(:state client) [:sessions session-id]) + {:error {:code -32001 :message (str "Unknown session: " session-id)}} + (handler-key + "Map RPC method names to handler map keys." + {"sessionFs.readFile" :read-file + "sessionFs.writeFile" :write-file + "sessionFs.appendFile" :append-file + "sessionFs.exists" :exists + "sessionFs.stat" :stat + "sessionFs.mkdir" :mkdir + "sessionFs.readdir" :readdir + "sessionFs.readdirWithTypes" :readdir-with-types + "sessionFs.rm" :rm + "sessionFs.rename" :rename}) + +(defn handle-session-fs-request! + "Handle an incoming sessionFs.* RPC request. Dispatches to the session's + FS handler and returns a channel with {:result ...} or {:error ...}." + [client session-id method params] + (async/thread-call + (fn [] + (let [handler-map (:session-fs-handler (session-state client session-id)) + handler-key (session-fs-method->handler-key method)] + (if-not handler-map + {:error {:code -32001 :message (str "No sessionFs handler for session: " session-id)}} + (if-let [handler-fn (get handler-map handler-key)] + (try + (let [result (handler-fn params) + result (if (channel? result) (