From 01f13430832f71c252c3324a4410677cad5b623f Mon Sep 17 00:00:00 2001 From: Karl Krukow Date: Thu, 12 Mar 2026 11:51:46 +0100 Subject: [PATCH 1/3] Port upstream PRs #664, #737: session pre-registration, on-event, join-session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Session pre-registration (upstream PR #664): - Sessions are now created and registered in client state BEFORE the session.create/session.resume RPC call, preventing early events (e.g. session.start) from being dropped - Session IDs are generated client-side via java.util.UUID/randomUUID when not explicitly provided - On RPC failure, pre-registered sessions are automatically cleaned up - CopilotSession record no longer includes workspace-path as a field; use (workspace-path session) accessor which reads from mutable state :on-event handler (upstream PR #664): - New optional :on-event config option for create-session and resume-session — a 1-arity function receiving event maps - Registered before the RPC call, guaranteeing early events like session.start are not missed - Runs on async/thread (not go-loop) to avoid blocking the core.async dispatch thread pool with user handlers join-session (upstream PR #737): - New convenience function for extensions running as child processes - Reads SESSION_ID from environment, creates child-process client, resumes session with :disable-resume? true - Returns {:client c :session s}; cleans up client on failure system.notification event (upstream PR #737): - Added :copilot/system.notification event type with :kind discriminator (agent_completed, shell_completed, shell_detached_completed) Async robustness fixes (from code review): - Async session functions ( --- CHANGELOG.md | 8 ++ README.md | 24 +++- doc/getting-started.md | 1 + doc/index.md | 2 +- doc/reference/API.md | 40 ++++++ examples/README.md | 56 ++++++++ src/github/copilot_sdk.clj | 28 ++++ src/github/copilot_sdk/client.clj | 139 +++++++++++++------ src/github/copilot_sdk/instrument.clj | 6 + src/github/copilot_sdk/session.clj | 44 +++++- src/github/copilot_sdk/specs.clj | 13 +- test/github/copilot_sdk/integration_test.clj | 19 ++- 12 files changed, 325 insertions(+), 55 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f1e5a68..ec093b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ All notable changes to this project will be documented in this file. This change log follows the conventions of [keepachangelog.com](http://keepachangelog.com/). ## [Unreleased] +### Added (upstream sync) +- Session pre-registration: sessions are now created and registered in client state **before** the RPC call, preventing early events (e.g. `session.start`) from being dropped. Session IDs are generated client-side via `java.util.UUID/randomUUID` when not explicitly provided. On RPC failure, sessions are automatically cleaned up (upstream PR #664). +- `:on-event` optional handler in `create-session` and `resume-session` configs — a 1-arity function receiving event maps, registered before the RPC call so no events are missed. Equivalent to calling `subscribe-events` immediately after creation, but executes earlier in the lifecycle (upstream PR #664). +- `join-session` function — convenience for extensions running as child processes of the Copilot CLI. Reads `SESSION_ID` from environment, creates a child-process client, and resumes the session with `:disable-resume? true`. Returns `{:client ... :session ...}` (upstream PR #737). +- `:copilot/system.notification` event type — structured notification events with `:kind` discriminator (`agent_completed`, `shell_completed`, `shell_detached_completed`) (upstream PR #737). + +### Changed +- `CopilotSession` record no longer includes `workspace-path` as a field. Use `(workspace-path session)` accessor which reads from mutable session state. This enables the pre-registration flow where workspace-path is set after the RPC response. ## [0.1.32.0] - 2026-03-10 ### Added (v0.1.32 sync) diff --git a/README.md b/README.md index ff08e6a..558e0a8 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,8 @@ Key features: - **Multi-session support** — Run multiple independent conversations concurrently - **Session hooks** — Lifecycle callbacks for pre/post tool use, prompts, errors - **User input handling** — Handle `ask_user` requests from the agent -- **Authentication options** — GitHub token auth or logged-in user +- **Event callbacks** — Register `:on-event` handlers to receive all session events +- **Child process mode** — Join existing sessions via `join-session` for extensions - **Authentication options** — GitHub token auth or logged-in user See [`examples/`](./examples/) for working code demonstrating common patterns. @@ -133,19 +134,32 @@ See the [`examples/`](./examples/) directory for complete working examples: | Example | Difficulty | Description | |---------|------------|-------------| | [`basic_chat.clj`](./examples/basic_chat.clj) | Beginner | Simple Q&A conversation with multi-turn context | +| [`helpers_query.clj`](./examples/helpers_query.clj) | Beginner | Stateless query API with blocking and streaming modes | +| [`reasoning_effort.clj`](./examples/reasoning_effort.clj) | Beginner | Control reasoning effort level | | [`tool_integration.clj`](./examples/tool_integration.clj) | Intermediate | Custom tools that the LLM can invoke | -| [`multi_agent.clj`](./examples/multi_agent.clj) | Advanced | Multi-agent orchestration with core.async | | [`config_skill_output.clj`](./examples/config_skill_output.clj) | Intermediate | Config dir, skills, and large output settings | -| [`permission_bash.clj`](./examples/permission_bash.clj) | Intermediate | Permission handling with bash | +| [`permission_bash.clj`](./examples/permission_bash.clj) | Intermediate | Permission handling with bash tool | +| [`session_events.clj`](./examples/session_events.clj) | Intermediate | Monitor session state events and their flow | +| [`session_resume.clj`](./examples/session_resume.clj) | Intermediate | Save and resume sessions by ID | +| [`file_attachments.clj`](./examples/file_attachments.clj) | Intermediate | Send file attachments for analysis | +| [`infinite_sessions.clj`](./examples/infinite_sessions.clj) | Intermediate | Infinite sessions with context compaction | +| [`lifecycle_hooks.clj`](./examples/lifecycle_hooks.clj) | Intermediate | Lifecycle hooks for tool use, prompts, errors | +| [`user_input.clj`](./examples/user_input.clj) | Intermediate | Handle ask_user requests from the agent | +| [`metadata_api.clj`](./examples/metadata_api.clj) | Intermediate | List sessions, tools, and quota | +| [`multi_agent.clj`](./examples/multi_agent.clj) | Advanced | Multi-agent orchestration with core.async | +| [`ask_user_failure.clj`](./examples/ask_user_failure.clj) | Advanced | User cancellation (Esc) with event tracing | +| [`mcp_local_server.clj`](./examples/mcp_local_server.clj) | Advanced | Model Context Protocol server integration | +| [`byok_provider.clj`](./examples/byok_provider.clj) | Advanced | Bring Your Own Key provider configuration | Run examples: ```bash clojure -A:examples -M -m basic-chat +clojure -A:examples -M -m helpers-query clojure -A:examples -M -m tool-integration +clojure -A:examples -M -m session-events clojure -A:examples -M -m multi-agent -clojure -A:examples -M -m config-skill-output -clojure -A:examples -M -m permission-bash +clojure -A:examples -M -m byok-provider ``` See [`examples/README.md`](./examples/README.md) for detailed walkthroughs and explanations. diff --git a/doc/getting-started.md b/doc/getting-started.md index b3fe9c4..302d90a 100644 --- a/doc/getting-started.md +++ b/doc/getting-started.md @@ -291,6 +291,7 @@ Pass `:client-name` to identify your application in API requests (included in th | **Session** | A conversation with context, model, and tools | | **Tools** | Functions that Copilot can call in your code | | **Events** | Streaming updates via core.async channels | +| **On-Event** | Optional callback receiving all session events, registered before RPC | | **Helpers** | High-level stateless API with automatic lifecycle management | ### Comparison with JavaScript SDK diff --git a/doc/index.md b/doc/index.md index 7b17b5e..0723426 100644 --- a/doc/index.md +++ b/doc/index.md @@ -5,7 +5,7 @@ Clojure SDK for programmatic control of the GitHub Copilot CLI via JSON-RPC. ## Getting Started - [Getting Started](getting-started.md) — Step-by-step tutorial building a weather assistant -- [Examples](../examples/README.md) — 11 working examples with walkthroughs +- [Examples](../examples/README.md) — 17 working examples with walkthroughs ## Guides diff --git a/doc/reference/API.md b/doc/reference/API.md index 033f30b..246382a 100644 --- a/doc/reference/API.md +++ b/doc/reference/API.md @@ -86,6 +86,15 @@ or want to stop reading early without leaking session resources. Explicitly shutdown the shared client. Safe to call multiple times. +### `client-info` + +```clojure +(h/client-info) +;; => {:client-opts {:log-level :info, ...} :connected? true} +``` + +Get information about the current shared client state. Returns `nil` if no shared client exists, otherwise a map with `:client-opts` and `:connected?` keys. + --- ## CopilotClient @@ -243,6 +252,7 @@ Create a client and session together, ensuring both are cleaned up on exit. | `:on-user-input-request` | fn | Handler for `ask_user` requests (see below) | | `: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. | #### `resume-session` @@ -304,6 +314,26 @@ Same config options as `resume-session`. Safe for use inside `go` blocks. On RPC )) ``` +#### `join-session` + +```clojure +(copilot/join-session config) +``` + +Join the current foreground session from an extension running as a child process of the Copilot CLI. Reads the `SESSION_ID` environment variable, creates a child-process client, and resumes the session with `:disable-resume?` defaulting to `true`. + +Returns a map with `:client` and `:session` keys. The caller is responsible for stopping the client when done. + +Throws if `SESSION_ID` is not set in the environment. + +```clojure +(let [{:keys [client session]} (copilot/join-session + {:on-permission-request copilot/approve-all + :tools [my-tool]})] + ;; use session... + (copilot/stop! client)) +``` + #### `ping` ```clojure @@ -884,6 +914,15 @@ copilot/interaction-events ;; :copilot/external_tool.requested} ``` +### `evt` — Event Keyword Helper + +```clojure +(copilot/evt :session.info) ;; => :copilot/session.info +(copilot/evt :assistant.message) ;; => :copilot/assistant.message +``` + +Convert an unqualified event keyword to a namespace-qualified `:copilot/` keyword. Throws `IllegalArgumentException` if the keyword is not a valid event type. + ### Event Reference | Event Type | Description | @@ -934,6 +973,7 @@ copilot/interaction-events | `:copilot/hook.start` | Hook invocation started | | `:copilot/hook.end` | Hook invocation finished | | `:copilot/system.message` | System message emitted | +| `:copilot/system.notification` | System notification with structured `:kind` discriminator (e.g. `agent_completed`, `shell_completed`, `shell_detached_completed`) | | `:copilot/permission.requested` | Permission request initiated | | `:copilot/permission.completed` | Permission request resolved | | `:copilot/user_input.requested` | User input requested from agent | diff --git a/examples/README.md b/examples/README.md index 465ff62..186de8e 100644 --- a/examples/README.md +++ b/examples/README.md @@ -60,6 +60,9 @@ clojure -A:examples -X session-events/run clojure -A:examples -X user-input/run clojure -A:examples -X user-input/run-simple +# Ask user cancellation (simulates Esc) +clojure -A:examples -X ask-user-failure/run + # BYOK provider (requires API key, see example docs) OPENAI_API_KEY=sk-... clojure -A:examples -X byok-provider/run clojure -A:examples -X byok-provider/run :provider-name '"ollama"' @@ -756,6 +759,59 @@ clojure -A:examples -X reasoning-effort/run :effort '"high"' --- +## Example 17: Ask User Failure (`ask_user_failure.clj`) + +**Difficulty:** Intermediate +**Concepts:** User input cancellation, ask_user tool, error handling, event tracing + +Demonstrates what happens when a user cancels an `ask_user` request (simulating pressing Esc). This is a 1:1 port of the upstream `basic-example.ts`. + +### What It Demonstrates + +- Handling user cancellation by throwing from `:on-user-input-request` +- Event tracing: subscribing to all events via `tap` on the session events mult +- Graceful degradation when the user skips a question +- Full event stream logging for debugging + +### Usage + +```bash +clojure -A:examples -X ask-user-failure/run +``` + +### Code Walkthrough + +```clojure +(require '[clojure.core.async :refer [chan tap go-loop config + (not (contains? config :disable-resume?)) + (assoc :disable-resume? true))] + (try + (let [sess (resume-session c session-id merged-config)] + {:client c :session sess}) + (catch Throwable t + (try (stop! c) (catch Throwable _)) + (throw t)))))) (defn list-sessions "List all available sessions. diff --git a/src/github/copilot_sdk/instrument.clj b/src/github/copilot_sdk/instrument.clj index 03b31fa..fc0b84a 100644 --- a/src/github/copilot_sdk/instrument.clj +++ b/src/github/copilot_sdk/instrument.clj @@ -68,6 +68,10 @@ :config ::specs/resume-session-config) :ret ::specs/events-ch) +(s/fdef github.copilot-sdk.client/join-session + :args (s/cat :config ::specs/resume-session-config) + :ret (s/keys :req-un [::specs/client ::specs/session])) + (s/fdef github.copilot-sdk.client/list-sessions :args (s/cat :client ::specs/client :filter (s/? (s/nilable ::specs/session-list-filter))) @@ -273,6 +277,7 @@ github.copilot-sdk.client/CopilotSession session-id workspace-path client))) + (->CopilotSession session-id client))) + +(defn set-workspace-path! + "Update the workspace path in session state. Called after RPC response." + [client session-id workspace-path] + (when workspace-path + (swap! (:state client) assoc-in [:sessions session-id :workspace-path] workspace-path))) + +(defn remove-session! + "Remove a session from client state. Called on RPC failure during pre-registration." + [client session-id] + (when-let [{:keys [event-chan]} (get-in @(:state client) [:session-io session-id])] + (close! event-chan)) + (swap! (:state client) (fn [s] + (-> s + (update :sessions dissoc session-id) + (update :session-io dissoc session-id))))) (defn dispatch-event! "Dispatch an event to all subscribers via the mult. Called by client notification router. @@ -711,7 +744,8 @@ (defn workspace-path "Get the session workspace path when provided by the CLI." [session] - (:workspace-path session)) + (let [{:keys [session-id client]} session] + (:workspace-path (session-state client session-id)))) (defn get-current-model "Get the current model for this session. diff --git a/src/github/copilot_sdk/specs.clj b/src/github/copilot_sdk/specs.clj index edf81dc..67006f8 100644 --- a/src/github/copilot_sdk/specs.clj +++ b/src/github/copilot_sdk/specs.clj @@ -205,6 +205,9 @@ ;; Disable resume flag (s/def ::disable-resume? boolean?) +;; Event handler (1-arity fn receiving event map) +(s/def ::on-event fn?) + (s/def ::client-name ::non-blank-string) (def session-config-keys @@ -214,7 +217,7 @@ :custom-agents :config-dir :skill-directories :disabled-skills :large-output :infinite-sessions :reasoning-effort :on-user-input-request :hooks - :working-directory :agent}) + :working-directory :agent :on-event}) (s/def ::session-config (closed-keys @@ -225,7 +228,7 @@ ::custom-agents ::config-dir ::skill-directories ::disabled-skills ::large-output ::infinite-sessions ::reasoning-effort ::on-user-input-request ::hooks - ::working-directory ::agent]) + ::working-directory ::agent ::on-event]) session-config-keys)) (def ^:private resume-session-config-keys @@ -233,7 +236,7 @@ :provider :streaming? :on-permission-request :mcp-servers :custom-agents :config-dir :skill-directories :disabled-skills :infinite-sessions :reasoning-effort - :on-user-input-request :hooks :working-directory :disable-resume? :agent}) + :on-user-input-request :hooks :working-directory :disable-resume? :agent :on-event}) (s/def ::resume-session-config (closed-keys @@ -242,7 +245,8 @@ ::provider ::streaming? ::mcp-servers ::custom-agents ::config-dir ::skill-directories ::disabled-skills ::infinite-sessions ::reasoning-effort - ::on-user-input-request ::hooks ::working-directory ::disable-resume? ::agent]) + ::on-user-input-request ::hooks ::working-directory ::disable-resume? ::agent + ::on-event]) resume-session-config-keys)) ;; ----------------------------------------------------------------------------- @@ -401,6 +405,7 @@ :copilot/skill.invoked :copilot/hook.start :copilot/hook.end :copilot/system.message + :copilot/system.notification ;; Interaction broadcast events (permission, user input, elicitation, tool flows) :copilot/permission.requested :copilot/permission.completed :copilot/user_input.requested :copilot/user_input.completed diff --git a/test/github/copilot_sdk/integration_test.clj b/test/github/copilot_sdk/integration_test.clj index b427239..b917a68 100644 --- a/test/github/copilot_sdk/integration_test.clj +++ b/test/github/copilot_sdk/integration_test.clj @@ -215,7 +215,24 @@ :model "gpt-5.2"})] (is (some? session)) (is (string? (sdk/session-id session))) - (is (clojure.string/starts-with? (sdk/session-id session) "session-"))))) + ;; Session ID is now generated client-side as a UUID + (is (re-matches #"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}" + (sdk/session-id session))))) + (testing "Create session with custom session-id" + (let [custom-id "my-custom-session-id" + session (sdk/create-session *test-client* + {:on-permission-request sdk/approve-all + :session-id custom-id})] + (is (= custom-id (sdk/session-id session))))) + (testing "Create session with :on-event captures session.start" + (let [events (atom []) + session (sdk/create-session *test-client* + {:on-permission-request sdk/approve-all + :on-event (fn [evt] (swap! events conj evt))})] + ;; Give the on-event handler time to receive the session.start event + (Thread/sleep 200) + (is (some #(= :copilot/session.start (:type %)) @events) + "on-event handler should receive session.start event")))) (deftest test-list-sessions (testing "List sessions includes created sessions" From b9735576952ce2aaadfca77fdda7723d6b5cf74b Mon Sep 17 00:00:00 2001 From: Karl Krukow Date: Thu, 12 Mar 2026 12:29:28 +0100 Subject: [PATCH 2/3] Address Copilot code review feedback - Document sliding-buffer drop behavior in on-event handler comment - Improve on-event error logging: include throwable, session-id, event-type - Replace Thread/sleep in on-event test with promise + deref timeout - Clarify ::on-event spec comment re: fn? consistency with other handlers Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/github/copilot_sdk/session.clj | 6 +++++- src/github/copilot_sdk/specs.clj | 3 ++- test/github/copilot_sdk/integration_test.clj | 13 ++++++++----- 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/src/github/copilot_sdk/session.clj b/src/github/copilot_sdk/session.clj index 1d5759b..da0c1c7 100644 --- a/src/github/copilot_sdk/session.clj +++ b/src/github/copilot_sdk/session.clj @@ -73,6 +73,8 @@ ;; If an on-event handler is provided, tap and forward events to it. ;; Uses async/thread to avoid blocking core.async dispatch threads, ;; since user handlers may perform blocking I/O. + ;; The handler channel uses a sliding buffer — if the handler cannot keep up + ;; with the event rate, oldest unprocessed events are silently dropped. (when on-event (let [handler-ch (chan (async/sliding-buffer 1024))] (tap event-mult handler-ch) @@ -83,7 +85,9 @@ (try (on-event event) (catch Throwable t - (log/warn "on-event handler threw: " (ex-message t)))) + (log/warn t "on-event handler threw" + {:session-id session-id + :event-type (:type event)}))) (recur)) ;; Channel closed — session torn down nil))))) diff --git a/src/github/copilot_sdk/specs.clj b/src/github/copilot_sdk/specs.clj index 67006f8..20711a7 100644 --- a/src/github/copilot_sdk/specs.clj +++ b/src/github/copilot_sdk/specs.clj @@ -205,7 +205,8 @@ ;; Disable resume flag (s/def ::disable-resume? boolean?) -;; Event handler (1-arity fn receiving event map) +;; Event handler — 1-arity fn receiving event map. Uses fn? for consistency +;; with ::on-permission-request and ::on-user-input-request specs. (s/def ::on-event fn?) (s/def ::client-name ::non-blank-string) diff --git a/test/github/copilot_sdk/integration_test.clj b/test/github/copilot_sdk/integration_test.clj index b917a68..b6c0f54 100644 --- a/test/github/copilot_sdk/integration_test.clj +++ b/test/github/copilot_sdk/integration_test.clj @@ -226,13 +226,16 @@ (is (= custom-id (sdk/session-id session))))) (testing "Create session with :on-event captures session.start" (let [events (atom []) + got-start (promise) session (sdk/create-session *test-client* {:on-permission-request sdk/approve-all - :on-event (fn [evt] (swap! events conj evt))})] - ;; Give the on-event handler time to receive the session.start event - (Thread/sleep 200) - (is (some #(= :copilot/session.start (:type %)) @events) - "on-event handler should receive session.start event")))) + :on-event (fn [evt] + (swap! events conj evt) + (when (= :copilot/session.start (:type evt)) + (deliver got-start true)))})] + (is (deref got-start 2000 false) + "on-event handler should receive session.start event within timeout") + (is (some #(= :copilot/session.start (:type %)) @events))))) (deftest test-list-sessions (testing "List sessions includes created sessions" From 9063a97d197d69f1f5391ed35e37f428e278925a Mon Sep 17 00:00:00 2001 From: Karl Krukow Date: Thu, 12 Mar 2026 12:35:37 +0100 Subject: [PATCH 3/3] Update create-session docstring to reflect sliding-buffer drop semantics Address Copilot review: docstring now mentions best-effort delivery and that events may be dropped if handler cannot keep up. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/github/copilot_sdk/session.clj | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/github/copilot_sdk/session.clj b/src/github/copilot_sdk/session.clj index da0c1c7..e1fa518 100644 --- a/src/github/copilot_sdk/session.clj +++ b/src/github/copilot_sdk/session.clj @@ -47,7 +47,9 @@ (defn create-session "Create a new session. Internal use - called by client. Initializes session state in client's atom and returns a CopilotSession handle. - If :on-event is provided, taps a subscriber that invokes the handler for each event." + 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]}] (log/debug "Creating session: " session-id) (let [event-chan (chan (async/sliding-buffer 4096))