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 +750,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..20711a7 100644 --- a/src/github/copilot_sdk/specs.clj +++ b/src/github/copilot_sdk/specs.clj @@ -205,6 +205,10 @@ ;; Disable resume flag (s/def ::disable-resume? boolean?) +;; 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) (def session-config-keys @@ -214,7 +218,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 +229,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 +237,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 +246,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 +406,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..b6c0f54 100644 --- a/test/github/copilot_sdk/integration_test.clj +++ b/test/github/copilot_sdk/integration_test.clj @@ -215,7 +215,27 @@ :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 []) + got-start (promise) + session (sdk/create-session *test-client* + {:on-permission-request sdk/approve-all + :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"