diff --git a/.lastmerge b/.lastmerge index c5649a512..179fceade 100644 --- a/.lastmerge +++ b/.lastmerge @@ -1 +1 @@ -062b61c8aa63b9b5d45fa1d7b01723e6660ffa83 +485ea5ed1ce43125075bab2f3d2681f1816a4f9a diff --git a/src/main/java/com/github/copilot/sdk/CliServerManager.java b/src/main/java/com/github/copilot/sdk/CliServerManager.java index b2a798ada..8031d191f 100644 --- a/src/main/java/com/github/copilot/sdk/CliServerManager.java +++ b/src/main/java/com/github/copilot/sdk/CliServerManager.java @@ -110,6 +110,28 @@ ProcessInfo startCliServer() throws IOException, InterruptedException { pb.environment().put("COPILOT_SDK_AUTH_TOKEN", options.getGitHubToken()); } + // Set telemetry environment variables if configured + var telemetry = options.getTelemetry(); + if (telemetry != null) { + pb.environment().put("COPILOT_OTEL_ENABLED", "true"); + if (telemetry.getOtlpEndpoint() != null) { + pb.environment().put("OTEL_EXPORTER_OTLP_ENDPOINT", telemetry.getOtlpEndpoint()); + } + if (telemetry.getFilePath() != null) { + pb.environment().put("COPILOT_OTEL_FILE_EXPORTER_PATH", telemetry.getFilePath()); + } + if (telemetry.getExporterType() != null) { + pb.environment().put("COPILOT_OTEL_EXPORTER_TYPE", telemetry.getExporterType()); + } + if (telemetry.getSourceName() != null) { + pb.environment().put("COPILOT_OTEL_SOURCE_NAME", telemetry.getSourceName()); + } + if (telemetry.getCaptureContent() != null) { + pb.environment().put("OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT", + telemetry.getCaptureContent() ? "true" : "false"); + } + } + Process process = pb.start(); // Forward stderr to logger in background diff --git a/src/main/java/com/github/copilot/sdk/CopilotSession.java b/src/main/java/com/github/copilot/sdk/CopilotSession.java index 452e82671..9fcfae9a1 100644 --- a/src/main/java/com/github/copilot/sdk/CopilotSession.java +++ b/src/main/java/com/github/copilot/sdk/CopilotSession.java @@ -709,6 +709,11 @@ private void executePermissionAndRespondAsync(String requestId, PermissionReques invocation.setSessionId(sessionId); handler.handle(permissionRequest, invocation).thenAccept(result -> { try { + if (PermissionRequestResultKind.NO_RESULT + .equals(new PermissionRequestResultKind(result.getKind()))) { + // "no-result" means leave the request unanswered — no RPC call. + return; + } rpc.invoke("session.permissions.handlePendingPermissionRequest", Map.of("sessionId", sessionId, "requestId", requestId, "result", result), Object.class); } catch (Exception e) { @@ -801,7 +806,17 @@ CompletableFuture handlePermissionRequest(JsonNode perm PermissionRequest request = MAPPER.treeToValue(permissionRequestData, PermissionRequest.class); var invocation = new PermissionInvocation(); invocation.setSessionId(sessionId); - return handler.handle(request, invocation).exceptionally(ex -> { + return handler.handle(request, invocation).thenApply(result -> { + if (PermissionRequestResultKind.NO_RESULT.equals(new PermissionRequestResultKind(result.getKind()))) { + throw new IllegalStateException( + "Permission handlers cannot return 'no-result' when connected to a protocol v2 server."); + } + return result; + }).exceptionally(ex -> { + if (ex instanceof IllegalStateException ise && ise.getMessage() != null + && ise.getMessage().contains("no-result")) { + throw (IllegalStateException) ex; + } LOG.log(Level.SEVERE, "Permission handler threw an exception", ex); PermissionRequestResult result = new PermissionRequestResult(); result.setKind("denied-no-approval-rule-and-could-not-request-from-user"); @@ -1000,8 +1015,39 @@ public CompletableFuture abort() { * @since 1.0.11 */ public CompletableFuture setModel(String model) { + return setModel(model, null); + } + + /** + * Changes the model and reasoning effort for this session. + *

+ * The new model and reasoning effort take effect for the next message. + * Conversation history is preserved. + * + *

{@code
+     * session.setModel("claude-sonnet-4.6", "high").get();
+     * }
+ * + * @param model + * the model ID to switch to (e.g., {@code "gpt-4.1"}) + * @param reasoningEffort + * the reasoning effort level ({@code "low"}, {@code "medium"}, + * {@code "high"}, {@code "xhigh"}), or {@code null} to use the + * model's default + * @return a future that completes when the model switch is acknowledged + * @throws IllegalStateException + * if this session has been terminated + * @since 1.1.0 + */ + public CompletableFuture setModel(String model, String reasoningEffort) { ensureNotTerminated(); - return rpc.invoke("session.model.switchTo", Map.of("sessionId", sessionId, "modelId", model), Void.class); + var params = new java.util.LinkedHashMap(); + params.put("sessionId", sessionId); + params.put("modelId", model); + if (reasoningEffort != null) { + params.put("reasoningEffort", reasoningEffort); + } + return rpc.invoke("session.model.switchTo", params, Void.class); } /** diff --git a/src/main/java/com/github/copilot/sdk/events/AbstractSessionEvent.java b/src/main/java/com/github/copilot/sdk/events/AbstractSessionEvent.java index 5127f6eee..fb2f578d8 100644 --- a/src/main/java/com/github/copilot/sdk/events/AbstractSessionEvent.java +++ b/src/main/java/com/github/copilot/sdk/events/AbstractSessionEvent.java @@ -55,7 +55,7 @@ public abstract sealed class AbstractSessionEvent permits SessionModelChangeEvent, SessionModeChangedEvent, SessionPlanChangedEvent, SessionWorkspaceFileChangedEvent, SessionHandoffEvent, SessionTruncationEvent, SessionSnapshotRewindEvent, SessionUsageInfoEvent, SessionCompactionStartEvent, SessionCompactionCompleteEvent, SessionShutdownEvent, SessionContextChangedEvent, - SessionTaskCompleteEvent, + SessionTaskCompleteEvent, SessionBackgroundTasksChangedEvent, SessionToolsUpdatedEvent, // Assistant events AssistantTurnStartEvent, AssistantIntentEvent, AssistantReasoningEvent, AssistantReasoningDeltaEvent, AssistantMessageEvent, AssistantMessageDeltaEvent, AssistantStreamingDeltaEvent, AssistantTurnEndEvent, diff --git a/src/main/java/com/github/copilot/sdk/events/AssistantUsageEvent.java b/src/main/java/com/github/copilot/sdk/events/AssistantUsageEvent.java index 9aa21925b..764c55b96 100644 --- a/src/main/java/com/github/copilot/sdk/events/AssistantUsageEvent.java +++ b/src/main/java/com/github/copilot/sdk/events/AssistantUsageEvent.java @@ -44,7 +44,8 @@ public record AssistantUsageData(@JsonProperty("model") String model, @JsonProperty("apiCallId") String apiCallId, @JsonProperty("providerCallId") String providerCallId, @JsonProperty("parentToolCallId") String parentToolCallId, @JsonProperty("quotaSnapshots") Map quotaSnapshots, - @JsonProperty("copilotUsage") CopilotUsage copilotUsage) { + @JsonProperty("copilotUsage") CopilotUsage copilotUsage, + @JsonProperty("reasoningEffort") String reasoningEffort) { /** Returns a defensive copy of the quota snapshots map. */ @Override diff --git a/src/main/java/com/github/copilot/sdk/events/ExternalToolRequestedEvent.java b/src/main/java/com/github/copilot/sdk/events/ExternalToolRequestedEvent.java index 8eb11f5b8..fdf8db197 100644 --- a/src/main/java/com/github/copilot/sdk/events/ExternalToolRequestedEvent.java +++ b/src/main/java/com/github/copilot/sdk/events/ExternalToolRequestedEvent.java @@ -38,6 +38,7 @@ public void setData(ExternalToolRequestedData data) { @JsonIgnoreProperties(ignoreUnknown = true) public record ExternalToolRequestedData(@JsonProperty("requestId") String requestId, @JsonProperty("sessionId") String sessionId, @JsonProperty("toolCallId") String toolCallId, - @JsonProperty("toolName") String toolName, @JsonProperty("arguments") Object arguments) { + @JsonProperty("toolName") String toolName, @JsonProperty("arguments") Object arguments, + @JsonProperty("traceparent") String traceparent, @JsonProperty("tracestate") String tracestate) { } } diff --git a/src/main/java/com/github/copilot/sdk/events/SessionBackgroundTasksChangedEvent.java b/src/main/java/com/github/copilot/sdk/events/SessionBackgroundTasksChangedEvent.java new file mode 100644 index 000000000..2f6619e24 --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/events/SessionBackgroundTasksChangedEvent.java @@ -0,0 +1,37 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk.events; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Event: session.background_tasks_changed + * + * @since 1.1.0 + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class SessionBackgroundTasksChangedEvent extends AbstractSessionEvent { + + @JsonProperty("data") + private SessionBackgroundTasksChangedData data; + + @Override + public String getType() { + return "session.background_tasks_changed"; + } + + public SessionBackgroundTasksChangedData getData() { + return data; + } + + public void setData(SessionBackgroundTasksChangedData data) { + this.data = data; + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record SessionBackgroundTasksChangedData() { + } +} diff --git a/src/main/java/com/github/copilot/sdk/events/SessionEventParser.java b/src/main/java/com/github/copilot/sdk/events/SessionEventParser.java index 75971b29e..5064fcf47 100644 --- a/src/main/java/com/github/copilot/sdk/events/SessionEventParser.java +++ b/src/main/java/com/github/copilot/sdk/events/SessionEventParser.java @@ -66,6 +66,8 @@ public class SessionEventParser { TYPE_MAP.put("session.compaction_complete", SessionCompactionCompleteEvent.class); TYPE_MAP.put("session.context_changed", SessionContextChangedEvent.class); TYPE_MAP.put("session.task_complete", SessionTaskCompleteEvent.class); + TYPE_MAP.put("session.background_tasks_changed", SessionBackgroundTasksChangedEvent.class); + TYPE_MAP.put("session.tools_updated", SessionToolsUpdatedEvent.class); TYPE_MAP.put("user.message", UserMessageEvent.class); TYPE_MAP.put("pending_messages.modified", PendingMessagesModifiedEvent.class); TYPE_MAP.put("assistant.turn_start", AssistantTurnStartEvent.class); diff --git a/src/main/java/com/github/copilot/sdk/events/SessionModelChangeEvent.java b/src/main/java/com/github/copilot/sdk/events/SessionModelChangeEvent.java index 57d0b5499..8b992114f 100644 --- a/src/main/java/com/github/copilot/sdk/events/SessionModelChangeEvent.java +++ b/src/main/java/com/github/copilot/sdk/events/SessionModelChangeEvent.java @@ -33,6 +33,8 @@ public void setData(SessionModelChangeData data) { @JsonIgnoreProperties(ignoreUnknown = true) public record SessionModelChangeData(@JsonProperty("previousModel") String previousModel, - @JsonProperty("newModel") String newModel) { + @JsonProperty("newModel") String newModel, + @JsonProperty("previousReasoningEffort") String previousReasoningEffort, + @JsonProperty("reasoningEffort") String reasoningEffort) { } } diff --git a/src/main/java/com/github/copilot/sdk/events/SessionResumeEvent.java b/src/main/java/com/github/copilot/sdk/events/SessionResumeEvent.java index bf305bc30..b8d60ba64 100644 --- a/src/main/java/com/github/copilot/sdk/events/SessionResumeEvent.java +++ b/src/main/java/com/github/copilot/sdk/events/SessionResumeEvent.java @@ -35,6 +35,7 @@ public void setData(SessionResumeData data) { @JsonIgnoreProperties(ignoreUnknown = true) public record SessionResumeData(@JsonProperty("resumeTime") OffsetDateTime resumeTime, - @JsonProperty("eventCount") double eventCount) { + @JsonProperty("eventCount") double eventCount, @JsonProperty("selectedModel") String selectedModel, + @JsonProperty("reasoningEffort") String reasoningEffort) { } } diff --git a/src/main/java/com/github/copilot/sdk/events/SessionStartEvent.java b/src/main/java/com/github/copilot/sdk/events/SessionStartEvent.java index 317b4a470..135b523eb 100644 --- a/src/main/java/com/github/copilot/sdk/events/SessionStartEvent.java +++ b/src/main/java/com/github/copilot/sdk/events/SessionStartEvent.java @@ -36,6 +36,7 @@ public void setData(SessionStartData data) { @JsonIgnoreProperties(ignoreUnknown = true) public record SessionStartData(@JsonProperty("sessionId") String sessionId, @JsonProperty("version") double version, @JsonProperty("producer") String producer, @JsonProperty("copilotVersion") String copilotVersion, - @JsonProperty("startTime") OffsetDateTime startTime, @JsonProperty("selectedModel") String selectedModel) { + @JsonProperty("startTime") OffsetDateTime startTime, @JsonProperty("selectedModel") String selectedModel, + @JsonProperty("reasoningEffort") String reasoningEffort) { } } diff --git a/src/main/java/com/github/copilot/sdk/events/SessionToolsUpdatedEvent.java b/src/main/java/com/github/copilot/sdk/events/SessionToolsUpdatedEvent.java new file mode 100644 index 000000000..295c42612 --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/events/SessionToolsUpdatedEvent.java @@ -0,0 +1,37 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk.events; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Event: session.tools_updated + * + * @since 1.1.0 + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class SessionToolsUpdatedEvent extends AbstractSessionEvent { + + @JsonProperty("data") + private SessionToolsUpdatedData data; + + @Override + public String getType() { + return "session.tools_updated"; + } + + public SessionToolsUpdatedData getData() { + return data; + } + + public void setData(SessionToolsUpdatedData data) { + this.data = data; + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record SessionToolsUpdatedData(@JsonProperty("model") String model) { + } +} diff --git a/src/main/java/com/github/copilot/sdk/json/CopilotClientOptions.java b/src/main/java/com/github/copilot/sdk/json/CopilotClientOptions.java index 4fd55d3ba..c6022db6e 100644 --- a/src/main/java/com/github/copilot/sdk/json/CopilotClientOptions.java +++ b/src/main/java/com/github/copilot/sdk/json/CopilotClientOptions.java @@ -42,11 +42,13 @@ public class CopilotClientOptions { private String cliUrl; private String logLevel = "info"; private boolean autoStart = true; - private boolean autoRestart = true; + @Deprecated + private boolean autoRestart; private Map environment; private String gitHubToken; private Boolean useLoggedInUser; private Supplier>> onListModels; + private TelemetryConfig telemetry; /** * Gets the path to the Copilot CLI executable. @@ -236,8 +238,11 @@ public CopilotClientOptions setAutoStart(boolean autoStart) { /** * Returns whether the client should automatically restart the server on crash. * - * @return {@code true} to auto-restart (default), {@code false} otherwise + * @return {@code true} to auto-restart, {@code false} otherwise + * @deprecated {@code autoRestart} has no effect and will be removed in a future + * release. */ + @Deprecated public boolean isAutoRestart() { return autoRestart; } @@ -249,7 +254,10 @@ public boolean isAutoRestart() { * @param autoRestart * {@code true} to auto-restart, {@code false} otherwise * @return this options instance for method chaining + * @deprecated {@code autoRestart} has no effect and will be removed in a future + * release. */ + @Deprecated public CopilotClientOptions setAutoRestart(boolean autoRestart) { this.autoRestart = autoRestart; return this; @@ -378,6 +386,34 @@ public CopilotClientOptions setOnListModels(Supplier + * When set to a non-{@code null} instance, the CLI server is started with + * OpenTelemetry instrumentation enabled. The configuration controls the + * exporter endpoint, file path, exporter type, source name, and content capture + * settings. + * + * @param telemetry + * the telemetry configuration, or {@code null} to disable + * OpenTelemetry instrumentation + * @return this options instance for method chaining + * @see TelemetryConfig + */ + public CopilotClientOptions setTelemetry(TelemetryConfig telemetry) { + this.telemetry = telemetry; + return this; + } + /** * Creates a shallow clone of this {@code CopilotClientOptions} instance. *

@@ -404,6 +440,7 @@ public CopilotClientOptions clone() { copy.gitHubToken = this.gitHubToken; copy.useLoggedInUser = this.useLoggedInUser; copy.onListModels = this.onListModels; + copy.telemetry = this.telemetry; return copy; } } diff --git a/src/main/java/com/github/copilot/sdk/json/PermissionRequestResultKind.java b/src/main/java/com/github/copilot/sdk/json/PermissionRequestResultKind.java index f85a0df1c..db8a1e0e3 100644 --- a/src/main/java/com/github/copilot/sdk/json/PermissionRequestResultKind.java +++ b/src/main/java/com/github/copilot/sdk/json/PermissionRequestResultKind.java @@ -26,6 +26,8 @@ * because no approval rule was found and the user could not be prompted. *

  • {@link #DENIED_INTERACTIVELY_BY_USER} — the permission was denied * interactively by the user.
  • + *
  • {@link #NO_RESULT} — leave the pending permission request unanswered (v3 + * protocol only).
  • * * * @see PermissionRequestResult @@ -51,6 +53,20 @@ public final class PermissionRequestResultKind { public static final PermissionRequestResultKind DENIED_INTERACTIVELY_BY_USER = new PermissionRequestResultKind( "denied-interactively-by-user"); + /** + * Leave the pending permission request unanswered (no-op). + *

    + * When a permission handler returns this kind, the SDK does not send any + * response to the server — the permission request stays pending. This is useful + * in multi-client scenarios where a secondary client wants to signal that it + * does not own the decision. + *

    + * Note: Returning this kind is only valid when the session is + * connected to a protocol v3 server (broadcast model). Returning it against a + * v2 server will result in an error. + */ + public static final PermissionRequestResultKind NO_RESULT = new PermissionRequestResultKind("no-result"); + private final String value; /** diff --git a/src/main/java/com/github/copilot/sdk/json/TelemetryConfig.java b/src/main/java/com/github/copilot/sdk/json/TelemetryConfig.java new file mode 100644 index 000000000..6ce918ea0 --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/json/TelemetryConfig.java @@ -0,0 +1,163 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk.json; + +/** + * OpenTelemetry configuration for the Copilot CLI server. + *

    + * When a non-{@code null} instance is set on + * {@link CopilotClientOptions#setTelemetry(TelemetryConfig)}, the CLI server is + * started with OpenTelemetry instrumentation enabled. + * + *

    Example Usage

    + * + *
    {@code
    + * var telemetry = new TelemetryConfig().setOtlpEndpoint("http://localhost:4318").setExporterType("otlp-http")
    + * 		.setSourceName("my-app");
    + *
    + * var options = new CopilotClientOptions().setTelemetry(telemetry);
    + * }
    + * + * @see CopilotClientOptions#setTelemetry(TelemetryConfig) + * @since 1.1.0 + */ +public class TelemetryConfig { + + private String otlpEndpoint; + private String filePath; + private String exporterType; + private String sourceName; + private Boolean captureContent; + + /** + * Gets the OTLP exporter endpoint URL. + *

    + * Maps to the {@code OTEL_EXPORTER_OTLP_ENDPOINT} environment variable. + * + * @return the endpoint URL, or {@code null} if not set + */ + public String getOtlpEndpoint() { + return otlpEndpoint; + } + + /** + * Sets the OTLP exporter endpoint URL. + *

    + * Maps to the {@code OTEL_EXPORTER_OTLP_ENDPOINT} environment variable. + * + * @param otlpEndpoint + * the endpoint URL (e.g., {@code "http://localhost:4318"}) + * @return this instance for method chaining + */ + public TelemetryConfig setOtlpEndpoint(String otlpEndpoint) { + this.otlpEndpoint = otlpEndpoint; + return this; + } + + /** + * Gets the file path for the file exporter. + *

    + * Maps to the {@code COPILOT_OTEL_FILE_EXPORTER_PATH} environment variable. + * + * @return the file path, or {@code null} if not set + */ + public String getFilePath() { + return filePath; + } + + /** + * Sets the file path for the file exporter. + *

    + * Maps to the {@code COPILOT_OTEL_FILE_EXPORTER_PATH} environment variable. + * + * @param filePath + * the output file path + * @return this instance for method chaining + */ + public TelemetryConfig setFilePath(String filePath) { + this.filePath = filePath; + return this; + } + + /** + * Gets the exporter type. + *

    + * Maps to the {@code COPILOT_OTEL_EXPORTER_TYPE} environment variable. + * + * @return the exporter type (e.g., {@code "otlp-http"} or {@code "file"}), or + * {@code null} if not set + */ + public String getExporterType() { + return exporterType; + } + + /** + * Sets the exporter type. + *

    + * Maps to the {@code COPILOT_OTEL_EXPORTER_TYPE} environment variable. + * + * @param exporterType + * the exporter type (e.g., {@code "otlp-http"} or {@code "file"}) + * @return this instance for method chaining + */ + public TelemetryConfig setExporterType(String exporterType) { + this.exporterType = exporterType; + return this; + } + + /** + * Gets the source name for telemetry spans. + *

    + * Maps to the {@code COPILOT_OTEL_SOURCE_NAME} environment variable. + * + * @return the source name, or {@code null} if not set + */ + public String getSourceName() { + return sourceName; + } + + /** + * Sets the source name for telemetry spans. + *

    + * Maps to the {@code COPILOT_OTEL_SOURCE_NAME} environment variable. + * + * @param sourceName + * the source name + * @return this instance for method chaining + */ + public TelemetryConfig setSourceName(String sourceName) { + this.sourceName = sourceName; + return this; + } + + /** + * Gets whether to capture message content as part of telemetry. + *

    + * Maps to the {@code OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT} + * environment variable. + * + * @return {@code true} to capture content, {@code false} to suppress it, or + * {@code null} to use the server default + */ + public Boolean getCaptureContent() { + return captureContent; + } + + /** + * Sets whether to capture message content as part of telemetry. + *

    + * Maps to the {@code OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT} + * environment variable. + * + * @param captureContent + * {@code true} to capture content, {@code false} to suppress it, + * {@code null} to use the server default + * @return this instance for method chaining + */ + public TelemetryConfig setCaptureContent(Boolean captureContent) { + this.captureContent = captureContent; + return this; + } +} diff --git a/src/main/java/com/github/copilot/sdk/json/ToolDefinition.java b/src/main/java/com/github/copilot/sdk/json/ToolDefinition.java index 9b3087a42..516ecddfb 100644 --- a/src/main/java/com/github/copilot/sdk/json/ToolDefinition.java +++ b/src/main/java/com/github/copilot/sdk/json/ToolDefinition.java @@ -52,6 +52,10 @@ * when {@code true}, indicates that this tool intentionally * overrides a built-in CLI tool with the same name; {@code null} or * {@code false} means the tool is purely custom + * @param skipPermission + * when {@code true}, instructs the CLI to skip the permission check + * for this tool's calls; {@code null} or {@code false} uses the + * default permission flow * @see SessionConfig#setTools(java.util.List) * @see ToolHandler * @since 1.0.0 @@ -59,7 +63,8 @@ @JsonInclude(JsonInclude.Include.NON_NULL) public record ToolDefinition(@JsonProperty("name") String name, @JsonProperty("description") String description, @JsonProperty("parameters") Object parameters, @JsonIgnore ToolHandler handler, - @JsonProperty("overridesBuiltInTool") Boolean overridesBuiltInTool) { + @JsonProperty("overridesBuiltInTool") Boolean overridesBuiltInTool, + @JsonProperty("skipPermission") Boolean skipPermission) { /** * Creates a tool definition with a JSON schema for parameters. @@ -79,7 +84,7 @@ public record ToolDefinition(@JsonProperty("name") String name, @JsonProperty("d */ public static ToolDefinition create(String name, String description, Map schema, ToolHandler handler) { - return new ToolDefinition(name, description, schema, handler, null); + return new ToolDefinition(name, description, schema, handler, null, null); } /** @@ -103,6 +108,30 @@ public static ToolDefinition create(String name, String description, Map schema, ToolHandler handler) { - return new ToolDefinition(name, description, schema, handler, true); + return new ToolDefinition(name, description, schema, handler, true, null); + } + + /** + * Creates a tool definition that skips the default permission check. + *

    + * Use this factory method when your tool is safe to invoke without user + * approval (e.g., read-only or idempotent operations). Setting + * {@code skipPermission} to {@code true} instructs the CLI to bypass the + * permission flow for this tool's calls. + * + * @param name + * the unique name of the tool + * @param description + * a description of what the tool does + * @param schema + * the JSON Schema as a {@code Map} + * @param handler + * the handler function to execute when invoked + * @return a new tool definition with the skip-permission flag set + * @since 1.1.0 + */ + public static ToolDefinition createSkipPermission(String name, String description, Map schema, + ToolHandler handler) { + return new ToolDefinition(name, description, schema, handler, null, true); } } diff --git a/src/site/markdown/advanced.md b/src/site/markdown/advanced.md index f9ab53f55..3b45b37b8 100644 --- a/src/site/markdown/advanced.md +++ b/src/site/markdown/advanced.md @@ -108,6 +108,31 @@ var session = client.createSession( ).get(); ``` +### Skipping Permission for Safe Tools + +If your tool performs only safe, read-only operations that don't require user approval, use +`ToolDefinition.createSkipPermission()` to bypass the permission flow entirely: + +```java +var safeLookup = ToolDefinition.createSkipPermission( + "safe_lookup", + "Read-only data lookup that requires no approval", + Map.of( + "type", "object", + "properties", Map.of( + "id", Map.of("type", "string", "description", "Record identifier") + ), + "required", List.of("id") + ), + invocation -> { + String id = (String) invocation.getArguments().get("id"); + return CompletableFuture.completedFuture(lookupRecord(id)); + } +); +``` + +When `skipPermission` is set, the CLI will not call the permission handler for this tool's invocations. + --- ## Switching Models Mid-Session @@ -124,10 +149,15 @@ var session = client.createSession( // Switch to a different model mid-conversation session.setModel("gpt-4.1").get(); +// Switch to a model with a specific reasoning effort level +session.setModel("claude-sonnet-4.5", "high").get(); + // Next message will use the new model session.sendAndWait(new MessageOptions().setPrompt("Continue with the new model")).get(); ``` +The optional `reasoningEffort` parameter accepts `"low"`, `"medium"`, `"high"`, or `"xhigh"`. + The session emits a [`SessionModelChangeEvent`](apidocs/com/github/copilot/sdk/events/SessionModelChangeEvent.html) when the switch completes, which you can observe with `session.on(SessionModelChangeEvent.class, event -> ...)`. @@ -622,6 +652,10 @@ The `PermissionRequestResultKind` class provides well-known constants for common | `PermissionRequestResultKind.DENIED_BY_RULES` | `"denied-by-rules"` | Denied by policy rules | | `PermissionRequestResultKind.DENIED_COULD_NOT_REQUEST_FROM_USER` | `"denied-no-approval-rule-and-could-not-request-from-user"` | No rule and user could not be prompted | | `PermissionRequestResultKind.DENIED_INTERACTIVELY_BY_USER` | `"denied-interactively-by-user"` | User denied interactively | +| `PermissionRequestResultKind.NO_RESULT` | `"no-result"` | Leave the request unanswered (v3 protocol only) | + +The `NO_RESULT` kind is useful in multi-client scenarios (protocol v3) where a secondary client +wants to signal that it does not own the permission decision and should let another client respond. You can also pass a raw string to `setKind(String)` for custom or extension values. Use [`PermissionHandler.APPROVE_ALL`](apidocs/com/github/copilot/sdk/json/PermissionHandler.html) to approve all diff --git a/src/site/markdown/setup.md b/src/site/markdown/setup.md index 10c1cb3d8..de6f923bf 100644 --- a/src/site/markdown/setup.md +++ b/src/site/markdown/setup.md @@ -345,11 +345,12 @@ Complete list of `CopilotClientOptions` settings: | `useStdio` | boolean | Use stdio transport | `true` | | `port` | int | TCP port for CLI | `0` (random) | | `autoStart` | boolean | Auto-start server | `true` | -| `autoRestart` | boolean | Auto-restart on crash | `true` | +| `autoRestart` | boolean | ~~Auto-restart on crash~~ (deprecated, no effect) | `false` | | `logLevel` | String | CLI log level | `"info"` | | `environment` | Map | Environment variables | inherited | | `cwd` | String | Working directory | current dir | | `onListModels` | Supplier | Custom model listing implementation | `null` (use CLI) | +| `telemetry` | TelemetryConfig | OpenTelemetry configuration | `null` (disabled) | ### Extra CLI Arguments @@ -384,6 +385,53 @@ try (var client = new CopilotClient(options)) { This is useful for configuring proxy servers, custom CA certificates, or any environment-specific settings the CLI needs. +## OpenTelemetry Configuration + +Enable OpenTelemetry tracing for the CLI server by setting a [`TelemetryConfig`](apidocs/com/github/copilot/sdk/json/TelemetryConfig.html) on the client options. +When set, the CLI server is started with OpenTelemetry instrumentation enabled. + +### OTLP Exporter (HTTP) + +```java +var options = new CopilotClientOptions() + .setTelemetry(new TelemetryConfig() + .setOtlpEndpoint("http://localhost:4318") + .setExporterType("otlp-http") + .setSourceName("my-app")); + +try (var client = new CopilotClient(options)) { + client.start().get(); + // CLI server exports OpenTelemetry traces to the OTLP endpoint +} +``` + +### File Exporter + +```java +var options = new CopilotClientOptions() + .setTelemetry(new TelemetryConfig() + .setFilePath("/tmp/traces.json") + .setExporterType("file")); +``` + +### Capturing Message Content + +To include message content in telemetry spans (disabled by default for privacy): + +```java +var telemetry = new TelemetryConfig() + .setOtlpEndpoint("http://localhost:4318") + .setCaptureContent(true); +``` + +| Property | Environment Variable | Description | +|---|---|---| +| `otlpEndpoint` | `OTEL_EXPORTER_OTLP_ENDPOINT` | OTLP exporter endpoint URL | +| `filePath` | `COPILOT_OTEL_FILE_EXPORTER_PATH` | Output file path for file exporter | +| `exporterType` | `COPILOT_OTEL_EXPORTER_TYPE` | Exporter type: `"otlp-http"` or `"file"` | +| `sourceName` | `COPILOT_OTEL_SOURCE_NAME` | Source name for telemetry spans | +| `captureContent` | `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` | Whether to capture message content | + ## Best Practices ### Development diff --git a/src/test/java/com/github/copilot/sdk/PermissionRequestResultKindTest.java b/src/test/java/com/github/copilot/sdk/PermissionRequestResultKindTest.java index b21f96e83..5d68a560e 100644 --- a/src/test/java/com/github/copilot/sdk/PermissionRequestResultKindTest.java +++ b/src/test/java/com/github/copilot/sdk/PermissionRequestResultKindTest.java @@ -28,6 +28,7 @@ void wellKnownKinds_haveExpectedValues() { PermissionRequestResultKind.DENIED_COULD_NOT_REQUEST_FROM_USER.getValue()); assertEquals("denied-interactively-by-user", PermissionRequestResultKind.DENIED_INTERACTIVELY_BY_USER.getValue()); + assertEquals("no-result", PermissionRequestResultKind.NO_RESULT.getValue()); } @Test @@ -113,7 +114,7 @@ void jsonRoundTrip_allWellKnownKinds() throws Exception { PermissionRequestResultKind[] kinds = {PermissionRequestResultKind.APPROVED, PermissionRequestResultKind.DENIED_BY_RULES, PermissionRequestResultKind.DENIED_COULD_NOT_REQUEST_FROM_USER, - PermissionRequestResultKind.DENIED_INTERACTIVELY_BY_USER,}; + PermissionRequestResultKind.DENIED_INTERACTIVELY_BY_USER, PermissionRequestResultKind.NO_RESULT,}; for (PermissionRequestResultKind kind : kinds) { var result = new PermissionRequestResult().setKind(kind); String json = mapper.writeValueAsString(result); diff --git a/src/test/java/com/github/copilot/sdk/SessionEventHandlingTest.java b/src/test/java/com/github/copilot/sdk/SessionEventHandlingTest.java index 2e9da4fd2..07fd235d5 100644 --- a/src/test/java/com/github/copilot/sdk/SessionEventHandlingTest.java +++ b/src/test/java/com/github/copilot/sdk/SessionEventHandlingTest.java @@ -179,7 +179,7 @@ void testHandlerReceivesCorrectEventData() { }); SessionStartEvent startEvent = createSessionStartEvent(); - startEvent.setData(new SessionStartEvent.SessionStartData("my-session-123", 0, null, null, null, null)); + startEvent.setData(new SessionStartEvent.SessionStartData("my-session-123", 0, null, null, null, null, null)); dispatchEvent(startEvent); AssistantMessageEvent msgEvent = createAssistantMessageEvent("Test content"); @@ -855,7 +855,7 @@ private SessionStartEvent createSessionStartEvent() { private SessionStartEvent createSessionStartEvent(String sessionId) { var event = new SessionStartEvent(); - var data = new SessionStartEvent.SessionStartData(sessionId, 0, null, null, null, null); + var data = new SessionStartEvent.SessionStartData(sessionId, 0, null, null, null, null, null); event.setData(data); return event; } diff --git a/src/test/java/com/github/copilot/sdk/SessionEventParserTest.java b/src/test/java/com/github/copilot/sdk/SessionEventParserTest.java index d4770a721..5f4fe0aaa 100644 --- a/src/test/java/com/github/copilot/sdk/SessionEventParserTest.java +++ b/src/test/java/com/github/copilot/sdk/SessionEventParserTest.java @@ -2380,4 +2380,82 @@ void testParseSystemNotificationEvent() throws Exception { assertNotNull(event.getData()); assertTrue(event.getData().content().contains("Agent completed")); } + + @Test + void testParseSessionBackgroundTasksChangedEvent() throws Exception { + String json = """ + { + "type": "session.background_tasks_changed", + "data": {} + } + """; + + var event = (SessionBackgroundTasksChangedEvent) parseJson(json); + assertNotNull(event); + assertEquals("session.background_tasks_changed", event.getType()); + assertNotNull(event.getData()); + } + + @Test + void testParseSessionToolsUpdatedEvent() throws Exception { + String json = """ + { + "type": "session.tools_updated", + "data": { + "model": "gpt-4.1" + } + } + """; + + var event = (SessionToolsUpdatedEvent) parseJson(json); + assertNotNull(event); + assertEquals("session.tools_updated", event.getType()); + assertNotNull(event.getData()); + assertEquals("gpt-4.1", event.getData().model()); + } + + @Test + void testParseExternalToolRequestedEvent_withTraceparent() throws Exception { + String json = """ + { + "type": "external_tool.requested", + "data": { + "requestId": "req-123", + "sessionId": "sess-456", + "toolCallId": "call-789", + "toolName": "grep", + "arguments": {}, + "traceparent": "00-abc123-def456-01", + "tracestate": "vendor=value" + } + } + """; + + var event = (ExternalToolRequestedEvent) parseJson(json); + assertNotNull(event); + assertEquals("00-abc123-def456-01", event.getData().traceparent()); + assertEquals("vendor=value", event.getData().tracestate()); + } + + @Test + void testParseSessionModelChangeEvent_withReasoningEffort() throws Exception { + String json = """ + { + "type": "session.model_change", + "data": { + "previousModel": "gpt-4.1", + "newModel": "claude-sonnet-4.5", + "previousReasoningEffort": "low", + "reasoningEffort": "high" + } + } + """; + + var event = (SessionModelChangeEvent) parseJson(json); + assertNotNull(event); + assertEquals("gpt-4.1", event.getData().previousModel()); + assertEquals("claude-sonnet-4.5", event.getData().newModel()); + assertEquals("low", event.getData().previousReasoningEffort()); + assertEquals("high", event.getData().reasoningEffort()); + } } diff --git a/src/test/java/com/github/copilot/sdk/SessionEventsE2ETest.java b/src/test/java/com/github/copilot/sdk/SessionEventsE2ETest.java index dad3f5907..f37655634 100644 --- a/src/test/java/com/github/copilot/sdk/SessionEventsE2ETest.java +++ b/src/test/java/com/github/copilot/sdk/SessionEventsE2ETest.java @@ -277,4 +277,82 @@ void testInvokesBuiltInTools_eventOrderDuringToolExecution() throws Exception { assertTrue(toolCompleteIdx < turnEndIdx, "tool.execution_complete should be before turn_end"); } } + + /** + * Verifies that an exception in an event handler does not halt event delivery. + * + * @see Snapshot: session/handler_exception_does_not_halt_event_delivery + */ + @Test + void testHandlerExceptionDoesNotHaltEventDelivery() throws Exception { + ctx.configureForTest("session", "handler_exception_does_not_halt_event_delivery"); + + var eventCount = new java.util.concurrent.atomic.AtomicInteger(0); + var gotIdle = new java.util.concurrent.CompletableFuture(); + + try (CopilotClient client = ctx.createClient()) { + CopilotSession session = client + .createSession(new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL)).get(); + + // Use SUPPRESS_AND_LOG_ERRORS so handler exceptions don't halt delivery + session.setEventErrorPolicy(com.github.copilot.sdk.EventErrorPolicy.SUPPRESS_AND_LOG_ERRORS); + + session.on(evt -> { + int count = eventCount.incrementAndGet(); + // Throw on the first event to verify delivery continues + if (count == 1) { + throw new RuntimeException("boom"); + } + if (evt instanceof SessionIdleEvent) { + gotIdle.complete(null); + } + }); + + session.send(new MessageOptions().setPrompt("What is 1+1?")).get(60, TimeUnit.SECONDS); + + gotIdle.get(30, TimeUnit.SECONDS); + + // Handler saw more than just the first (throwing) event + assertTrue(eventCount.get() > 1, + "Event delivery should continue after a handler exception, got " + eventCount.get() + " events"); + + session.close(); + } + } + + /** + * Verifies that calling close() from within a handler does not deadlock. + * + * @see Snapshot: session/disposeasync_from_handler_does_not_deadlock + */ + @Test + void testDisposeAsyncFromHandlerDoesNotDeadlock() throws Exception { + ctx.configureForTest("session", "disposeasync_from_handler_does_not_deadlock"); + + var disposed = new java.util.concurrent.CompletableFuture(); + + try (CopilotClient client = ctx.createClient()) { + CopilotSession session = client + .createSession(new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL)).get(); + + session.on(evt -> { + if (evt instanceof UserMessageEvent) { + // Call close() from within a handler — must not deadlock + new Thread(() -> { + try { + session.close(); + disposed.complete(null); + } catch (Exception e) { + disposed.completeExceptionally(e); + } + }).start(); + } + }); + + session.send(new MessageOptions().setPrompt("What is 1+1?")).get(60, TimeUnit.SECONDS); + + // If this times out, we likely deadlocked + disposed.get(10, TimeUnit.SECONDS); + } + } } diff --git a/src/test/java/com/github/copilot/sdk/TelemetryConfigTest.java b/src/test/java/com/github/copilot/sdk/TelemetryConfigTest.java new file mode 100644 index 000000000..c418d0f50 --- /dev/null +++ b/src/test/java/com/github/copilot/sdk/TelemetryConfigTest.java @@ -0,0 +1,79 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Test; + +import com.github.copilot.sdk.json.CopilotClientOptions; +import com.github.copilot.sdk.json.TelemetryConfig; + +/** + * Unit tests for {@link TelemetryConfig} and its integration with + * {@link CopilotClientOptions}. + */ +public class TelemetryConfigTest { + + @Test + void telemetryConfig_defaultValues_areNull() { + var config = new TelemetryConfig(); + + assertNull(config.getOtlpEndpoint()); + assertNull(config.getFilePath()); + assertNull(config.getExporterType()); + assertNull(config.getSourceName()); + assertNull(config.getCaptureContent()); + } + + @Test + void telemetryConfig_canSetAllProperties() { + var config = new TelemetryConfig().setOtlpEndpoint("http://localhost:4318").setFilePath("/tmp/traces.json") + .setExporterType("otlp-http").setSourceName("my-app").setCaptureContent(true); + + assertEquals("http://localhost:4318", config.getOtlpEndpoint()); + assertEquals("/tmp/traces.json", config.getFilePath()); + assertEquals("otlp-http", config.getExporterType()); + assertEquals("my-app", config.getSourceName()); + assertTrue(config.getCaptureContent()); + } + + @Test + void copilotClientOptions_telemetry_defaultsToNull() { + var options = new CopilotClientOptions(); + assertNull(options.getTelemetry()); + } + + @Test + void copilotClientOptions_setTelemetry_roundTrips() { + var telemetry = new TelemetryConfig().setOtlpEndpoint("http://localhost:4318"); + var options = new CopilotClientOptions().setTelemetry(telemetry); + + assertSame(telemetry, options.getTelemetry()); + assertEquals("http://localhost:4318", options.getTelemetry().getOtlpEndpoint()); + } + + @Test + void copilotClientOptions_clone_copiesTelemetry() { + var telemetry = new TelemetryConfig().setOtlpEndpoint("http://localhost:4318").setSourceName("my-app"); + var original = new CopilotClientOptions().setTelemetry(telemetry); + + var clone = original.clone(); + + assertNotNull(clone.getTelemetry()); + assertSame(telemetry, clone.getTelemetry(), "Clone should share the same TelemetryConfig reference"); + assertEquals("http://localhost:4318", clone.getTelemetry().getOtlpEndpoint()); + assertEquals("my-app", clone.getTelemetry().getSourceName()); + } + + @Test + void copilotClientOptions_cloneWithNullTelemetry_isNull() { + var original = new CopilotClientOptions(); + + var clone = original.clone(); + + assertNull(clone.getTelemetry()); + } +} diff --git a/src/test/java/com/github/copilot/sdk/ToolsTest.java b/src/test/java/com/github/copilot/sdk/ToolsTest.java index 538da74d2..36c4796b6 100644 --- a/src/test/java/com/github/copilot/sdk/ToolsTest.java +++ b/src/test/java/com/github/copilot/sdk/ToolsTest.java @@ -322,40 +322,46 @@ void testDeniesCustomToolWhenPermissionDenied(TestInfo testInfo) throws Exceptio } /** - * Verifies that a custom tool can override a built-in CLI tool with the same - * name when {@code overridesBuiltInTool} is set to {@code true}. + * Verifies that a tool with {@code skipPermission=true} is invoked without + * triggering the permission handler. * - * @see Snapshot: tools/overrides_built_in_tool_with_custom_tool + * @see Snapshot: tools/skippermission_sent_in_tool_definition */ @Test - void testOverridesBuiltInToolWithCustomTool() throws Exception { - ctx.configureForTest("tools", "overrides_built_in_tool_with_custom_tool"); + void testSkipPermissionSentInToolDefinition() throws Exception { + ctx.configureForTest("tools", "skippermission_sent_in_tool_definition"); var parameters = new HashMap(); var properties = new HashMap(); - properties.put("query", Map.of("type", "string", "description", "Search query")); + properties.put("id", Map.of("type", "string", "description", "Lookup ID")); parameters.put("type", "object"); parameters.put("properties", properties); - parameters.put("required", List.of("query")); + parameters.put("required", List.of("id")); - ToolDefinition customGrep = ToolDefinition.createOverride("grep", "A custom grep implementation", parameters, - (invocation) -> { + boolean[] didRunPermissionRequest = {false}; + + ToolDefinition safeLookup = ToolDefinition.createSkipPermission("safe_lookup", "A tool that skips permission", + parameters, (invocation) -> { Map args = invocation.getArguments(); - String query = (String) args.get("query"); - return CompletableFuture.completedFuture("CUSTOM_GREP_RESULT: " + query); + String id = (String) args.get("id"); + return CompletableFuture.completedFuture("RESULT: " + id); }); try (CopilotClient client = ctx.createClient()) { - CopilotSession session = client.createSession(new SessionConfig().setTools(List.of(customGrep)) - .setOnPermissionRequest(PermissionHandler.APPROVE_ALL)).get(); + CopilotSession session = client.createSession( + new SessionConfig().setTools(List.of(safeLookup)).setOnPermissionRequest((request, invocation) -> { + didRunPermissionRequest[0] = true; + return CompletableFuture.completedFuture(new PermissionRequestResult().setKind("approved")); + })).get(); AssistantMessageEvent response = session - .sendAndWait(new MessageOptions().setPrompt("Use grep to search for the word 'hello'")) + .sendAndWait(new MessageOptions().setPrompt("Use safe_lookup to look up 'test123'")) .get(60, TimeUnit.SECONDS); assertNotNull(response); - assertTrue(response.getData().content().contains("CUSTOM_GREP_RESULT"), - "Response should contain CUSTOM_GREP_RESULT: " + response.getData().content()); + assertTrue(response.getData().content().contains("RESULT"), + "Response should contain RESULT: " + response.getData().content()); + assertFalse(didRunPermissionRequest[0], "Permission handler should not be called for skipPermission tool"); session.close(); }