Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .lastmerge
Original file line number Diff line number Diff line change
@@ -1 +1 @@
062b61c8aa63b9b5d45fa1d7b01723e6660ffa83
485ea5ed1ce43125075bab2f3d2681f1816a4f9a
22 changes: 22 additions & 0 deletions src/main/java/com/github/copilot/sdk/CliServerManager.java
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,28 @@ ProcessInfo startCliServer() throws IOException, InterruptedException {
pb.environment().put("COPILOT_SDK_AUTH_TOKEN", options.getGitHubToken());
}

// Set telemetry environment variables if configured
if (options.getTelemetry() != null) {
var telemetry = options.getTelemetry();
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
Expand Down
140 changes: 106 additions & 34 deletions src/main/java/com/github/copilot/sdk/CopilotSession.java
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
Expand Down Expand Up @@ -113,14 +114,21 @@ public final class CopilotSession implements AutoCloseable {
private volatile String sessionId;
private volatile String workspacePath;
private final JsonRpcClient rpc;
private final Set<Consumer<AbstractSessionEvent>> eventHandlers = ConcurrentHashMap.newKeySet();
private final CopyOnWriteArrayList<Consumer<AbstractSessionEvent>> eventHandlers = new CopyOnWriteArrayList<>();
private final Map<String, ToolDefinition> toolHandlers = new ConcurrentHashMap<>();
private final AtomicReference<PermissionHandler> permissionHandler = new AtomicReference<>();
private final AtomicReference<UserInputHandler> userInputHandler = new AtomicReference<>();
private final AtomicReference<SessionHooks> hooksHandler = new AtomicReference<>();
private volatile EventErrorHandler eventErrorHandler;
private volatile EventErrorPolicy eventErrorPolicy = EventErrorPolicy.PROPAGATE_AND_LOG_ERRORS;

/**
* Single-threaded executor that serializes event dispatch. Events are enqueued
* by {@link #dispatchEvent} and processed one at a time, preserving arrival
* order and ensuring handlers are never called concurrently.
*/
private final ExecutorService eventDispatcher;

/** Tracks whether this session instance has been terminated via close(). */
private volatile boolean isTerminated = false;

Expand Down Expand Up @@ -156,6 +164,11 @@ public final class CopilotSession implements AutoCloseable {
this.sessionId = sessionId;
this.rpc = rpc;
this.workspacePath = workspacePath;
this.eventDispatcher = Executors.newSingleThreadExecutor(r -> {
var t = new Thread(r, "copilot-session-events");
t.setDaemon(true);
return t;
});
}

/**
Expand Down Expand Up @@ -563,49 +576,52 @@ public <T extends AbstractSessionEvent> Closeable on(Class<T> eventType, Consume
/**
* Dispatches an event to all registered handlers.
* <p>
* This is called internally when events are received from the server. Each
* handler is invoked in its own try/catch block. Errors are always logged at
* {@link Level#WARNING}. Whether dispatch continues after a handler error
* depends on the configured {@link EventErrorPolicy}:
* <ul>
* <li>{@link EventErrorPolicy#PROPAGATE_AND_LOG_ERRORS} (default) — dispatch
* stops after the first error</li>
* <li>{@link EventErrorPolicy#SUPPRESS_AND_LOG_ERRORS} — remaining handlers
* still execute</li>
* </ul>
* This is called internally when events are received from the server. Broadcast
* request events (protocol v3) are handled concurrently and immediately.
* User-registered handlers are invoked serially on a single background thread,
* preserving event arrival order and ensuring handlers are never called
* concurrently with each other on the same session.
* <p>
* The configured {@link EventErrorHandler} is always invoked (if set),
* regardless of the policy. If the error handler itself throws, dispatch stops
* regardless of policy and the error is logged at {@link Level#SEVERE}.
* Handler exceptions are caught, logged, and do not halt delivery of subsequent
* events. The configured {@link EventErrorHandler} is invoked when set. Whether
* remaining handlers for the same event execute depends on the configured
* {@link EventErrorPolicy}.
*
* @param event
* the event to dispatch
* @see #setEventErrorHandler(EventErrorHandler)
* @see #setEventErrorPolicy(EventErrorPolicy)
*/
void dispatchEvent(AbstractSessionEvent event) {
// Handle broadcast request events (protocol v3) before dispatching to user
// handlers. These are fire-and-forget: the response is sent asynchronously.
// Handle broadcast request events (protocol v3) concurrently (fire-and-forget).
// Done outside the serial queue so a stalled broadcast handler doesn't block
// user event delivery.
handleBroadcastEventAsync(event);

for (Consumer<AbstractSessionEvent> handler : eventHandlers) {
try {
handler.accept(event);
} catch (Exception e) {
LOG.log(Level.WARNING, "Error in event handler", e);
EventErrorHandler errorHandler = this.eventErrorHandler;
if (errorHandler != null) {
// Enqueue for serial processing by user handlers on the background thread.
// If the executor has been shut down (session closed), silently drop.
if (!eventDispatcher.isShutdown()) {
eventDispatcher.execute(() -> {
for (Consumer<AbstractSessionEvent> handler : eventHandlers) {
try {
errorHandler.handleError(event, e);
} catch (Exception errorHandlerException) {
LOG.log(Level.SEVERE, "Error in event error handler", errorHandlerException);
break; // error handler itself failed — stop regardless of policy
handler.accept(event);
} catch (Exception e) {
LOG.log(Level.WARNING, "Error in event handler", e);
EventErrorHandler errorHandler = this.eventErrorHandler;
if (errorHandler != null) {
try {
errorHandler.handleError(event, e);
} catch (Exception errorHandlerException) {
LOG.log(Level.SEVERE, "Error in event error handler", errorHandlerException);
break; // error handler itself failed — stop regardless of policy
}
}
if (eventErrorPolicy == EventErrorPolicy.PROPAGATE_AND_LOG_ERRORS) {
break;
}
}
}
if (eventErrorPolicy == EventErrorPolicy.PROPAGATE_AND_LOG_ERRORS) {
break;
}
}
});
}
}

Expand Down Expand Up @@ -708,6 +724,12 @@ private void executePermissionAndRespondAsync(String requestId, PermissionReques
var invocation = new PermissionInvocation();
invocation.setSessionId(sessionId);
handler.handle(permissionRequest, invocation).thenAccept(result -> {
// If the handler returns no-result, leave the request unanswered
// (another client in a multi-client scenario may handle it).
if (PermissionRequestResultKind.NO_RESULT
.equals(new PermissionRequestResultKind(result.getKind()))) {
return;
}
try {
rpc.invoke("session.permissions.handlePendingPermissionRequest",
Map.of("sessionId", sessionId, "requestId", requestId, "result", result), Object.class);
Expand Down Expand Up @@ -982,6 +1004,39 @@ public CompletableFuture<Void> abort() {
return rpc.invoke("session.abort", Map.of("sessionId", sessionId), Void.class);
}

/**
* Changes the model for this session.
* <p>
* The new model takes effect for the next message. Conversation history is
* preserved.
*
* <pre>{@code
* session.setModel("gpt-4.1").get();
* session.setModel("claude-sonnet-4.6", "high").get();
* }</pre>
*
* @param model
* the model ID to switch to (e.g., {@code "gpt-4.1"})
* @param reasoningEffort
* the reasoning effort level (e.g., {@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.0.12
*/
public CompletableFuture<Void> setModel(String model, String reasoningEffort) {
ensureNotTerminated();
var params = new java.util.HashMap<String, Object>();
params.put("sessionId", sessionId);
params.put("modelId", model);
if (reasoningEffort != null) {
params.put("reasoningEffort", reasoningEffort);
}
return rpc.invoke("session.model.switchTo", params, Void.class);
}

/**
* Changes the model for this session.
* <p>
Expand All @@ -1000,8 +1055,7 @@ public CompletableFuture<Void> abort() {
* @since 1.0.11
*/
public CompletableFuture<Void> setModel(String model) {
ensureNotTerminated();
return rpc.invoke("session.model.switchTo", Map.of("sessionId", sessionId, "modelId", model), Void.class);
return setModel(model, null);
}

/**
Expand Down Expand Up @@ -1165,6 +1219,10 @@ public void close() {
isTerminated = true;
}

// Shut down the event dispatcher: no new events will be accepted, but
// already-queued events will still be delivered to handlers.
eventDispatcher.shutdown();

try {
rpc.invoke("session.destroy", Map.of("sessionId", sessionId), Void.class).get(5, TimeUnit.SECONDS);
} catch (Exception e) {
Expand Down Expand Up @@ -1192,4 +1250,18 @@ private record AgentGetCurrentResponse(@JsonProperty("agent") AgentInfo agent) {
private record AgentSelectResponse(@JsonProperty("agent") AgentInfo agent) {
}

/**
* Package-private test helper: submits a barrier task to the event dispatcher
* and blocks until all previously queued events have been processed.
* <p>
* This ensures that any events dispatched before this call have been fully
* delivered to all registered handlers.
*/
void awaitEventDispatch() throws Exception {
if (!eventDispatcher.isShutdown()) {
eventDispatcher.submit(() -> {
}).get(5, TimeUnit.SECONDS);
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, String> environment;
private String gitHubToken;
private Boolean useLoggedInUser;
private Supplier<CompletableFuture<List<ModelInfo>>> onListModels;
private TelemetryConfig telemetry;

/**
* Gets the path to the Copilot CLI executable.
Expand Down Expand Up @@ -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 false} always; this option has no effect
* @deprecated {@code autoRestart} has no effect and will be removed in a future
* release.
*/
@Deprecated
public boolean isAutoRestart() {
return autoRestart;
}
Expand All @@ -247,9 +252,12 @@ public boolean isAutoRestart() {
* crashes unexpectedly.
*
* @param autoRestart
* {@code true} to auto-restart, {@code false} otherwise
* ignored; this option has no effect
* @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;
Expand Down Expand Up @@ -378,6 +386,34 @@ public CopilotClientOptions setOnListModels(Supplier<CompletableFuture<List<Mode
return this;
}

/**
* Gets the OpenTelemetry configuration for the CLI server.
*
* @return the telemetry config, or {@code null} if telemetry is disabled
* @since 1.0.12
*/
public TelemetryConfig getTelemetry() {
return telemetry;
}

/**
* Sets the OpenTelemetry configuration for the CLI server.
* <p>
* When set to a non-{@code null} instance, the CLI server is started with
* OpenTelemetry instrumentation enabled. The individual properties of
* {@link TelemetryConfig} map to specific environment variables consumed by the
* CLI.
*
* @param telemetry
* the telemetry configuration, or {@code null} to disable telemetry
* @return this options instance for method chaining
* @since 1.0.12
*/
public CopilotClientOptions setTelemetry(TelemetryConfig telemetry) {
this.telemetry = telemetry;
return this;
}

/**
* Creates a shallow clone of this {@code CopilotClientOptions} instance.
* <p>
Expand All @@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@
* no handler and couldn't ask user</li>
* <li>{@link PermissionRequestResultKind#DENIED_INTERACTIVELY_BY_USER} — denied
* by the user interactively</li>
* <li>{@link PermissionRequestResultKind#NO_RESULT} — leave the request
* unanswered (another client may handle it)</li>
* </ul>
*
* @see PermissionHandler
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,18 @@ public final class PermissionRequestResultKind {
public static final PermissionRequestResultKind DENIED_INTERACTIVELY_BY_USER = new PermissionRequestResultKind(
"denied-interactively-by-user");

/**
* Indicates that the permission request should be left unanswered (no result).
* <p>
* When a handler returns this kind, the pending permission request is not
* responded to, allowing another client (in a multi-client scenario) to handle
* it.
* </p>
*
* @since 1.0.12
*/
public static final PermissionRequestResultKind NO_RESULT = new PermissionRequestResultKind("no-result");

private final String value;

/**
Expand Down
Loading
Loading