diff --git a/CHANGELOG.md b/CHANGELOG.md index 05a7a37..02fda63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,21 @@ All notable changes to this project will be documented in this file. This change ### Added - `:no-result` permission outcome — extensions can attach to sessions without actively answering permission requests by returning `{:kind :no-result}` from their `:on-permission-request` handler. On v3 protocol, the `handlePendingPermissionRequest` RPC is skipped; on v2, an error is propagated to the CLI (upstream PR #802). +### Added (v0.1.33 sync) +- `:skip-permission?` option on tool definitions — when `true`, the tool executes without triggering a permission prompt. Sent as `skipPermission: true` in the wire protocol (upstream PR #808). +- OpenTelemetry support: new `:telemetry` client option (map with `:otlp-endpoint`, `:file-path`, `:exporter-type`, `:source-name`, `:capture-content?`) configures OTel environment variables on the spawned CLI process. New `:on-get-trace-context` client option (0-arity fn returning `{:traceparent ... :tracestate ...}`) enables W3C Trace Context propagation into `session.create`, `session.resume`, and `session.send` RPCs (upstream PR #785). +- Tool invocations now receive `:traceparent` and `:tracestate` fields in the invocation context map when the CLI provides them (upstream PR #785). +- Optional `:reasoning-effort` parameter in `switch-model!` and `set-model!` — pass `{:reasoning-effort "high"}` as a third argument to set reasoning effort when switching models (upstream PR #712). +- New event data fields from upstream codegen update (upstream PR #796): + - `session.start` event: `:reasoning-effort`, `:already-in-use?`, `:host-type`, `:head-commit`, `:base-commit` optional fields + - `session.resume` event: new `::session.resume-data` spec with `:event-count`, `:selected-model`, `:reasoning-effort`, `:already-in-use?`, `:host-type`, `:head-commit`, `:base-commit` + - `session.model_change` event: new `::session.model_change-data` spec with `:new-model`, `:previous-model`, `:reasoning-effort`, `:previous-reasoning-effort` + - `user.message` event: new `:blob` attachment type with `:data` (base64), `:mime-type`, optional `:display-name` + +### Changed (v0.1.33 sync) +- `join-session` now makes `:on-permission-request` **optional**. When omitted, a default handler returns `{:kind :no-result}`, leaving any pending permission request unanswered. This matches the upstream `JoinSessionConfig` where `onPermissionRequest` is optional (upstream PR #802). +- `:auto-restart?` client option is **deprecated** and has no effect. The auto-restart/reconnect behavior has been removed across all official SDKs. The option is retained for backward compatibility but will be removed in a future release (upstream PR #803). + ## [0.1.32.0] - 2026-03-12 ### 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). diff --git a/src/github/copilot_sdk.clj b/src/github/copilot_sdk.clj index 4be1afb..1c67b80 100644 --- a/src/github/copilot_sdk.clj +++ b/src/github/copilot_sdk.clj @@ -170,8 +170,10 @@ - :use-stdio? - Use stdio transport (default: true) - :log-level - :none :error :warning :info :debug :all - :auto-start? - Auto-start on first use (default: true) - - :auto-restart? - Auto-restart on crash (default: true) + - :auto-restart? - **DEPRECATED**: This option has no effect and will be removed in a future release. - :env - Environment variables map + - :telemetry - OpenTelemetry config map with :otlp-endpoint, :file-path, :exporter-type, :source-name, :capture-content? + - :on-get-trace-context - Zero-arg fn returning {:traceparent ... :tracestate ...} Example: ```clojure @@ -810,14 +812,18 @@ (defn switch-model! "Switch the model for this session. The new model takes effect for the next message. Conversation history is preserved. + Optional opts map with `:reasoning-effort` (\"low\", \"medium\", \"high\", \"xhigh\"). Returns the new model ID string, or nil. Example: ```clojure (copilot/switch-model! session \"claude-sonnet-4.5\") + (copilot/switch-model! session \"claude-sonnet-4.5\" {:reasoning-effort \"high\"}) ```" - [session model-id] - (session/switch-model! session model-id)) + ([session model-id] + (session/switch-model! session model-id)) + ([session model-id opts] + (session/switch-model! session model-id opts))) (defn set-model! "Alias for switch-model!. Matches the upstream SDK's setModel() API. @@ -826,9 +832,12 @@ Example: ```clojure (copilot/set-model! session \"gpt-4.1\") + (copilot/set-model! session \"gpt-4.1\" {:reasoning-effort \"high\"}) ```" - [session model-id] - (session/set-model! session model-id)) + ([session model-id] + (session/set-model! session model-id)) + ([session model-id opts] + (session/set-model! session model-id opts))) (defn log! "Log a message to the session timeline. diff --git a/src/github/copilot_sdk/client.clj b/src/github/copilot_sdk/client.clj index 2bf0aec..d2351de 100644 --- a/src/github/copilot_sdk/client.clj +++ b/src/github/copilot_sdk/client.clj @@ -17,6 +17,21 @@ (def ^:private sdk-protocol-version-max 3) (def ^:private sdk-protocol-version-min 2) +(defn- get-trace-context + "Call the user-provided trace context provider. Returns {} when no provider + is configured or returns a non-map value. Only :traceparent and :tracestate + keys are retained to prevent accidental override of RPC params." + [provider] + (if-not provider + {} + (try + (let [ctx (provider)] + (if (map? ctx) + (select-keys ctx [:traceparent :tracestate]) + {})) + (catch Throwable _ + {})))) + (defn- parse-cli-url "Parse CLI URL into {:host :port}." [url] @@ -47,7 +62,7 @@ :use-stdio? true :log-level :info :auto-start? true - :auto-restart? true + :auto-restart? false :notification-queue-size 4096 :router-queue-size 4096 :tool-timeout-ms 120000 @@ -94,7 +109,6 @@ :router-thread nil :router-running? false :stopping? false - :restarting? false :force-stopping? false :models-cache nil ; nil, promise, or vector of models (cleared on stop) :lifecycle-handlers {} @@ -113,7 +127,7 @@ - :use-stdio? - Use stdio transport (default: true) - :log-level - :none :error :warning :info :debug :all - :auto-start? - Auto-start on first use (default: true) - - :auto-restart? - Auto-restart on crash (default: true) + - :auto-restart? - **DEPRECATED**: This option has no effect and will be removed in a future release. - :notification-queue-size - Max queued protocol notifications (default: 4096) - :router-queue-size - Max queued non-session notifications (default: 4096) - :tool-timeout-ms - Timeout for tool calls that return a channel (default: 120000) @@ -121,7 +135,9 @@ - :github-token - GitHub token for authentication (sets COPILOT_SDK_AUTH_TOKEN env var) - :use-logged-in-user? - Whether to use logged-in user auth (default: true, false when github-token provided) - :is-child-process? - When true, SDK is a child of an existing Copilot CLI process and uses stdio to communicate with it (no process spawning) - - :on-list-models - Zero-arg fn returning a seq of model info maps; bypasses the RPC call and does not require start!" + - :on-list-models - Zero-arg fn returning a seq of model info maps; bypasses the RPC call and does not require start! + - :telemetry - OpenTelemetry config map with optional keys :otlp-endpoint, :file-path, :exporter-type, :source-name, :capture-content? + - :on-get-trace-context - Zero-arg fn returning {:traceparent ... :tracestate ...} for distributed trace propagation" ([] (client {})) ([opts] @@ -184,7 +200,9 @@ :actual-host (or host "localhost") :state (atom (assoc (initial-state port) :options final-opts))} (:on-list-models opts) - (assoc :on-list-models (:on-list-models opts)))))) + (assoc :on-list-models (:on-list-models opts)) + (:on-get-trace-context opts) + (assoc :on-get-trace-context (:on-get-trace-context opts)))))) (defn state "Get the current connection state." @@ -216,12 +234,15 @@ request-id (:request-id data) tool-name (:tool-name data) tool-call-id (:tool-call-id data) - arguments (:arguments data)] + arguments (:arguments data) + traceparent (:traceparent data) + tracestate (:tracestate data)] (when (and request-id tool-name) (go (try (let [tool-response ( config + (not (contains? config :on-permission-request)) + (assoc :on-permission-request (constantly {:kind :no-result})) (not (contains? config :disable-resume?)) (assoc :disable-resume? true))] (try diff --git a/src/github/copilot_sdk/instrument.clj b/src/github/copilot_sdk/instrument.clj index fc0b84a..e61ca45 100644 --- a/src/github/copilot_sdk/instrument.clj +++ b/src/github/copilot_sdk/instrument.clj @@ -69,7 +69,7 @@ :ret ::specs/events-ch) (s/fdef github.copilot-sdk.client/join-session - :args (s/cat :config ::specs/resume-session-config) + :args (s/cat :config ::specs/join-session-config) :ret (s/keys :req-un [::specs/client ::specs/session])) (s/fdef github.copilot-sdk.client/list-sessions @@ -217,13 +217,15 @@ (s/fdef github.copilot-sdk.session/switch-model! :args (s/cat :session ::specs/session - :model-id string?) - :ret ::specs/model-id) + :model-id string? + :opts (s/? (s/nilable (s/keys :opt-un [::specs/reasoning-effort])))) + :ret (s/nilable ::specs/model-id)) (s/fdef github.copilot-sdk.session/set-model! :args (s/cat :session ::specs/session - :model-id string?) - :ret ::specs/model-id) + :model-id string? + :opts (s/? (s/nilable (s/keys :opt-un [::specs/reasoning-effort])))) + :ret (s/nilable ::specs/model-id)) (s/fdef github.copilot-sdk.session/log! :args (s/cat :session ::specs/session diff --git a/src/github/copilot_sdk/process.clj b/src/github/copilot_sdk/process.clj index 251d497..ec760bf 100644 --- a/src/github/copilot_sdk/process.clj +++ b/src/github/copilot_sdk/process.clj @@ -66,7 +66,22 @@ ;; Set github token in environment if provided (PR #237). ;; Explicit github-token should take precedence over env. (when github-token - (.put env-map "COPILOT_SDK_AUTH_TOKEN" github-token))) + (.put env-map "COPILOT_SDK_AUTH_TOKEN" github-token)) + + ;; Set OpenTelemetry environment variables if telemetry is configured (upstream PR #785) + (when-let [telemetry (:telemetry opts)] + (.put env-map "COPILOT_OTEL_ENABLED" "true") + (when-let [v (:otlp-endpoint telemetry)] + (.put env-map "OTEL_EXPORTER_OTLP_ENDPOINT" v)) + (when-let [v (:file-path telemetry)] + (.put env-map "COPILOT_OTEL_FILE_EXPORTER_PATH" v)) + (when-let [v (:exporter-type telemetry)] + (.put env-map "COPILOT_OTEL_EXPORTER_TYPE" v)) + (when-let [v (:source-name telemetry)] + (.put env-map "COPILOT_OTEL_SOURCE_NAME" v)) + (when (some? (:capture-content? telemetry)) + (.put env-map "OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT" + (str (:capture-content? telemetry)))))) ;; Configure stdio — use explicit PIPE redirects for all three streams. ;; On Windows, the JVM's ProcessImpl sets CREATE_NO_WINDOW when none of the diff --git a/src/github/copilot_sdk/session.clj b/src/github/copilot_sdk/session.clj index 92c1e62..8e75dea 100644 --- a/src/github/copilot_sdk/session.clj +++ b/src/github/copilot_sdk/session.clj @@ -162,7 +162,7 @@ (defn handle-tool-call! "Handle an incoming tool call request. Returns a channel with the result wrapper." - [client session-id tool-call-id tool-name arguments] + [client session-id tool-call-id tool-name arguments & {:keys [traceparent tracestate]}] (async/thread-call (fn [] (let [handler (get-in (session-state client session-id) [:tool-handlers tool-name]) @@ -173,10 +173,12 @@ :error (str "tool '" tool-name "' not supported") :tool-telemetry {}}} (try - (let [invocation {:session-id session-id - :tool-call-id tool-call-id - :tool-name tool-name - :arguments arguments} + (let [invocation (cond-> {:session-id session-id + :tool-call-id tool-call-id + :tool-name tool-name + :arguments arguments} + traceparent (assoc :traceparent traceparent) + tracestate (assoc :tracestate tracestate)) result (handler arguments invocation) result (if (channel? result) (let [timeout-ch (async/timeout timeout-ms) @@ -341,8 +343,14 @@ (let [conn (connection-io client) wire-attachments (when (:attachments opts) (util/attachments->wire (:attachments opts))) + trace-ctx (when-let [provider (:on-get-trace-context client)] + (try (let [ctx (provider)] + (when (map? ctx) + (select-keys ctx [:traceparent :tracestate]))) + (catch Throwable _ nil))) params (cond-> {:session-id session-id :prompt (:prompt opts)} + trace-ctx (merge trace-ctx) wire-attachments (assoc :attachments wire-attachments) (:mode opts) (assoc :mode (name (:mode opts)))) result (proto/send-request! conn "session.send" params) @@ -541,8 +549,14 @@ (let [conn (connection-io client) wire-attachments (when (:attachments opts) (util/attachments->wire (:attachments opts))) + trace-ctx (when-let [provider (:on-get-trace-context client)] + (try (let [ctx (provider)] + (when (map? ctx) + (select-keys ctx [:traceparent :tracestate]))) + (catch Throwable _ nil))) params (cond-> {:session-id session-id :prompt (:prompt opts)} + trace-ctx (merge trace-ctx) wire-attachments (assoc :attachments wire-attachments) (:mode opts) (assoc :mode (name (:mode opts)))) response-ch (proto/send-request conn "session.send" params) @@ -781,20 +795,26 @@ (defn switch-model! "Switch the model for this session. The new model takes effect for the next message. Conversation history is preserved. + + Optional opts map: + - :reasoning-effort - Reasoning effort level for the new model (\"low\", \"medium\", \"high\", \"xhigh\") + Returns the new model ID string, or nil." - [session model-id] - (let [{:keys [session-id client]} session - conn (connection-io client) - result (proto/send-request! conn "session.model.switchTo" - {:sessionId session-id - :modelId model-id})] - (:model-id result))) + ([session model-id] (switch-model! session model-id nil)) + ([session model-id opts] + (let [{:keys [session-id client]} session + conn (connection-io client) + params (cond-> {:sessionId session-id + :modelId model-id} + (:reasoning-effort opts) (assoc :reasoningEffort (:reasoning-effort opts))) + result (proto/send-request! conn "session.model.switchTo" params)] + (:model-id result)))) (defn set-model! "Alias for switch-model!. Matches the upstream SDK's setModel() API. See switch-model! for details." - [session model-id] - (switch-model! session model-id)) + ([session model-id] (switch-model! session model-id nil)) + ([session model-id opts] (switch-model! session model-id opts))) (defn log! "Log a message to the session timeline. diff --git a/src/github/copilot_sdk/specs.clj b/src/github/copilot_sdk/specs.clj index c4fb320..5ca29b4 100644 --- a/src/github/copilot_sdk/specs.clj +++ b/src/github/copilot_sdk/specs.clj @@ -52,12 +52,24 @@ ;; Custom model listing handler (upstream PR #730) (s/def ::on-list-models fn?) +;; OpenTelemetry configuration (upstream PR #785) +(s/def ::otlp-endpoint string?) +(s/def ::exporter-type string?) +(s/def ::source-name string?) +(s/def ::capture-content? boolean?) + +(s/def ::telemetry + (s/keys :opt-un [::otlp-endpoint ::file-path ::exporter-type ::source-name ::capture-content?])) + +;; Trace context provider: 0-arity fn returning {:traceparent ... :tracestate ...} +(s/def ::on-get-trace-context fn?) + (def client-options-keys #{:cli-path :cli-args :cli-url :cwd :port :use-stdio? :log-level :auto-start? :auto-restart? :notification-queue-size :router-queue-size :tool-timeout-ms :env :github-token :use-logged-in-user? - :is-child-process? :on-list-models}) + :is-child-process? :on-list-models :telemetry :on-get-trace-context}) (s/def ::client-options (closed-keys @@ -65,7 +77,7 @@ ::use-stdio? ::log-level ::auto-start? ::auto-restart? ::notification-queue-size ::router-queue-size ::tool-timeout-ms ::env ::github-token ::use-logged-in-user? - ::is-child-process? ::on-list-models]) + ::is-child-process? ::on-list-models ::telemetry ::on-get-trace-context]) client-options-keys)) ;; ----------------------------------------------------------------------------- @@ -78,10 +90,11 @@ (s/def ::tool-parameters (s/nilable ::json-schema)) (s/def ::tool-handler fn?) (s/def ::overrides-built-in-tool boolean?) +(s/def ::skip-permission? boolean?) (s/def ::tool (s/keys :req-un [::tool-name ::tool-handler] - :opt-un [::tool-description ::tool-parameters ::overrides-built-in-tool])) + :opt-un [::tool-description ::tool-parameters ::overrides-built-in-tool ::skip-permission?])) (s/def ::tools (s/coll-of ::tool)) @@ -250,6 +263,19 @@ ::on-event]) resume-session-config-keys)) +;; join-session config: same as resume-session-config but :on-permission-request is optional. +;; When omitted, join-session defaults to a handler that returns {:kind :no-result}. +(s/def ::join-session-config + (closed-keys + (s/keys :opt-un [::on-permission-request + ::client-name ::model ::tools ::system-message ::available-tools ::excluded-tools + ::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-event]) + resume-session-config-keys)) + ;; ----------------------------------------------------------------------------- ;; Message options ;; ----------------------------------------------------------------------------- @@ -305,12 +331,31 @@ #(string? (:state %)) #(string? (:url %)))) +;; Blob attachment (base64-encoded inline data, received in user.message events) +;; Note: blob uses manual predicates for :data/:mime-type to avoid conflicting with +;; ::data used as event data (map?) in ::session-event. +(s/def ::mime-type string?) +(s/def ::blob-attachment + (s/and map? + #(= :blob (:type %)) + #(string? (:data %)) + #(string? (:mime-type %)) + #(or (nil? (:display-name %)) (string? (:display-name %))))) + (s/def ::attachment (s/or :file-or-directory ::file-or-directory-attachment :selection ::selection-attachment :github-reference ::github-reference-attachment)) +;; Inbound attachment (includes blob, used in event data) +(s/def ::inbound-attachment + (s/or :file-or-directory ::file-or-directory-attachment + :selection ::selection-attachment + :github-reference ::github-reference-attachment + :blob ::blob-attachment)) + (s/def ::attachments (s/coll-of ::attachment)) +(s/def ::inbound-attachments (s/coll-of ::inbound-attachment)) (s/def ::mode #{:enqueue :immediate}) (s/def ::send-options @@ -414,9 +459,21 @@ :copilot/external_tool.requested}) ;; Session events +(s/def ::already-in-use? boolean?) +(s/def ::host-type string?) +(s/def ::head-commit string?) +(s/def ::base-commit string?) + (s/def ::session.start-data (s/keys :req-un [::session-id] - :opt-un [::version ::producer ::copilot-version ::start-time ::selected-model])) + :opt-un [::version ::producer ::copilot-version ::start-time ::selected-model + ::reasoning-effort ::already-in-use? ::host-type ::head-commit ::base-commit])) + +(s/def ::event-count nat-int?) +(s/def ::session.resume-data + (s/keys :req-un [::event-count] + :opt-un [::selected-model ::reasoning-effort ::already-in-use? + ::host-type ::head-commit ::base-commit])) (s/def ::session.error-data (s/keys :req-un [::error-type ::message] @@ -427,9 +484,12 @@ (s/def ::agent-mode #{:interactive :plan :autopilot :shell}) (s/def ::interaction-id string?) +;; user.message event data — attachments can include blobs (inbound-only types) (s/def ::user.message-data - (s/keys :req-un [::content] - :opt-un [::transformed-content ::attachments ::source ::agent-mode ::interaction-id])) + (s/and (s/keys :req-un [::content] + :opt-un [::transformed-content ::source ::agent-mode ::interaction-id]) + #(or (not (contains? % :attachments)) + (s/valid? ::inbound-attachments (:attachments %))))) (s/def ::assistant.message-data (s/keys :req-un [::message-id ::content] @@ -493,6 +553,14 @@ (s/keys :req-un [::cwd] :opt-un [::git-root ::repository ::branch])) +;; Session model change event (upstream PR #796) +(s/def ::previous-model (s/nilable string?)) +(s/def ::new-model string?) +(s/def ::previous-reasoning-effort string?) +(s/def ::session.model_change-data + (s/keys :req-un [::new-model] + :opt-un [::previous-model ::previous-reasoning-effort ::reasoning-effort])) + ;; Session mode changed event (s/def ::previous-mode string?) (s/def ::new-mode string?) diff --git a/test/github/copilot_sdk/integration_test.clj b/test/github/copilot_sdk/integration_test.clj index 7500b4a..5aa5f39 100644 --- a/test/github/copilot_sdk/integration_test.clj +++ b/test/github/copilot_sdk/integration_test.clj @@ -43,11 +43,11 @@ (is (= :connected (sdk/state *test-client*))) (is (some? (:connection @(:state *test-client*)))))) -(deftest test-auto-restart-on-connection-close - (testing "auto-restart triggers on connection close" +(deftest test-auto-restart-deprecated-connection-close + (testing "auto-restart no longer triggers on connection close (deprecated)" (let [starts (atom 0) stops (atom 0)] - (log/info "Warnings expected in this test: connection close triggers auto-restart.") + (log/info "Warnings expected in this test: connection close no longer triggers auto-restart.") (with-redefs [client/stop! (fn [c] (swap! stops inc) (swap! (:state c) assoc :status :disconnected) @@ -58,16 +58,16 @@ nil)] (mock/stop-mock-server! *mock-server*) (Thread/sleep 200) - (is (= 1 @stops)) - (is (= 1 @starts)))))) + (is (zero? @stops) "auto-restart is deprecated; stop! should not be called") + (is (zero? @starts) "auto-restart is deprecated; start! should not be called"))))) -(deftest test-auto-restart-on-process-exit - (testing "auto-restart triggers on process exit" +(deftest test-auto-restart-deprecated-process-exit + (testing "auto-restart no longer triggers on process exit (deprecated)" (let [starts (atom 0) stops (atom 0) exit-ch (chan 1) watch-exit (var client/watch-process-exit!)] - (log/info "Warnings expected in this test: simulated process exit triggers auto-restart.") + (log/info "Warnings expected in this test: simulated process exit no longer triggers auto-restart.") (with-redefs [client/stop! (fn [c] (swap! stops inc) (swap! (:state c) assoc :status :disconnected) @@ -80,8 +80,8 @@ (>!! exit-ch {:exit-code 123}) (close! exit-ch) (Thread/sleep 200) - (is (= 1 @stops)) - (is (= 1 @starts)))))) + (is (zero? @stops) "auto-restart is deprecated; stop! should not be called") + (is (zero? @starts) "auto-restart is deprecated; start! should not be called"))))) (deftest test-auto-restart-suppressed-when-stopping (testing "auto-restart is suppressed while stopping" diff --git a/test/github/copilot_sdk_test.clj b/test/github/copilot_sdk_test.clj index 9c9f07c..689c388 100644 --- a/test/github/copilot_sdk_test.clj +++ b/test/github/copilot_sdk_test.clj @@ -195,7 +195,10 @@ sentinel)] (let [result (copilot/set-model! :fake-session "gpt-4.1")] (is (some? @called-args) "switch-model! should have been called") - (is (= [:fake-session "gpt-4.1"] (vec @called-args))) + (is (= 3 (count @called-args)) "switch-model! receives 3 args (session, model-id, nil opts)") + (is (= :fake-session (first @called-args))) + (is (= "gpt-4.1" (second @called-args))) + (is (nil? (nth @called-args 2)) "opts should be nil when not provided") (is (= sentinel result))))))) ;; =============================================================================