Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
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
27 changes: 27 additions & 0 deletions src/main/java/com/github/copilot/sdk/CopilotClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -332,7 +332,22 @@ public CompletableFuture<CopilotSession> createSession(SessionConfig config) {
SessionRequestBuilder.configureSession(session, config);
sessions.put(sessionId, session);

// Extract transform callbacks from the system message config.
// Callbacks are registered with the session; a wire-safe copy of the
// system message (with transform sections replaced by action="transform")
// is used in the RPC request.
Object[] extracted = SessionRequestBuilder.extractTransformCallbacks(config.getSystemMessage());
var wireSystemMessage = (com.github.copilot.sdk.json.SystemMessageConfig) extracted[0];
@SuppressWarnings("unchecked")
var transformCallbacks = (java.util.Map<String, java.util.function.Function<String, java.util.concurrent.CompletableFuture<String>>>) extracted[1];
if (transformCallbacks != null) {
session.registerTransformCallbacks(transformCallbacks);
}

var request = SessionRequestBuilder.buildCreateRequest(config, sessionId);
if (wireSystemMessage != config.getSystemMessage()) {
request.setSystemMessage(wireSystemMessage);
}

return connection.rpc.invoke("session.create", request, CreateSessionResponse.class).thenApply(response -> {
session.setWorkspacePath(response.workspacePath());
Expand Down Expand Up @@ -390,7 +405,19 @@ public CompletableFuture<CopilotSession> resumeSession(String sessionId, ResumeS
SessionRequestBuilder.configureSession(session, config);
sessions.put(sessionId, session);

// Extract transform callbacks from the system message config.
Object[] extracted = SessionRequestBuilder.extractTransformCallbacks(config.getSystemMessage());
var wireSystemMessage = (com.github.copilot.sdk.json.SystemMessageConfig) extracted[0];
@SuppressWarnings("unchecked")
var transformCallbacks = (java.util.Map<String, java.util.function.Function<String, java.util.concurrent.CompletableFuture<String>>>) extracted[1];
if (transformCallbacks != null) {
session.registerTransformCallbacks(transformCallbacks);
}

var request = SessionRequestBuilder.buildResumeRequest(sessionId, config);
if (wireSystemMessage != config.getSystemMessage()) {
request.setSystemMessage(wireSystemMessage);
}

return connection.rpc.invoke("session.resume", request, ResumeSessionResponse.class).thenApply(response -> {
session.setWorkspacePath(response.workspacePath());
Expand Down
143 changes: 140 additions & 3 deletions src/main/java/com/github/copilot/sdk/CopilotSession.java
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ public final class CopilotSession implements AutoCloseable {
private final AtomicReference<SessionHooks> hooksHandler = new AtomicReference<>();
private volatile EventErrorHandler eventErrorHandler;
private volatile EventErrorPolicy eventErrorPolicy = EventErrorPolicy.PROPAGATE_AND_LOG_ERRORS;
private volatile Map<String, java.util.function.Function<String, CompletableFuture<String>>> transformCallbacks;

/** Tracks whether this session instance has been terminated via close(). */
private volatile boolean isTerminated = false;
Expand Down Expand Up @@ -709,6 +710,11 @@ private void executePermissionAndRespondAsync(String requestId, PermissionReques
invocation.setSessionId(sessionId);
handler.handle(permissionRequest, invocation).thenAccept(result -> {
try {
if (PermissionRequestResultKind.NO_RESULT.equals(result.getKind())) {
Comment thread
github-code-quality[bot] marked this conversation as resolved.
Fixed
// Handler explicitly abstains — leave the request unanswered
// so another client can handle it.
return;
}
rpc.invoke("session.permissions.handlePendingPermissionRequest",
Map.of("sessionId", sessionId, "requestId", requestId, "result", result), Object.class);
} catch (Exception e) {
Expand Down Expand Up @@ -867,6 +873,67 @@ void registerHooks(SessionHooks hooks) {
hooksHandler.set(hooks);
}

/**
* Registers transform callbacks for system message sections.
* <p>
* Called internally when creating or resuming a session with
* {@link com.github.copilot.sdk.SystemMessageMode#CUSTOMIZE} and transform
* callbacks.
*
* @param callbacks
* the transform callbacks keyed by section identifier; {@code null}
* clears any previously registered callbacks
*/
void registerTransformCallbacks(
Map<String, java.util.function.Function<String, CompletableFuture<String>>> callbacks) {
this.transformCallbacks = callbacks;
}

/**
* Handles a {@code systemMessage.transform} RPC call from the Copilot CLI.
* <p>
* The CLI sends section content; the SDK invokes the registered transform
* callbacks and returns the transformed sections.
*
* @param sections
* JSON node containing sections keyed by section identifier
* @return a future resolving with a map of transformed sections
*/
CompletableFuture<Map<String, Object>> handleSystemMessageTransform(JsonNode sections) {
var callbacks = this.transformCallbacks;
var result = new java.util.LinkedHashMap<String, Object>();
var futures = new ArrayList<CompletableFuture<Void>>();

if (sections != null && sections.isObject()) {
sections.fields().forEachRemaining(entry -> {
String sectionId = entry.getKey();
String content = entry.getValue().has("content") ? entry.getValue().get("content").asText("") : "";

java.util.function.Function<String, CompletableFuture<String>> cb = callbacks != null
? callbacks.get(sectionId)
: null;

if (cb != null) {
CompletableFuture<Void> f = cb.apply(content).exceptionally(ex -> content)
.thenAccept(transformed -> {
synchronized (result) {
result.put(sectionId, Map.of("content", transformed != null ? transformed : ""));
}
});
futures.add(f);
} else {
result.put(sectionId, Map.of("content", content));
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

handleSystemMessageTransform() writes to result from async completion handlers (synchronized) but also writes to result in the else branch without synchronization. If any transform callback completes on another thread while iteration is still running, this can cause concurrent modification of the non-thread-safe LinkedHashMap (and nondeterministic output). Consider synchronizing all writes to result (including the else branch) or using a concurrent map / collecting results after all futures complete.

Suggested change
result.put(sectionId, Map.of("content", content));
synchronized (result) {
result.put(sectionId, Map.of("content", content));
}

Copilot uses AI. Check for mistakes.
}
});
}

return CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).thenApply(v -> {
Map<String, Object> response = new java.util.LinkedHashMap<>();
response.put("sections", result);
return response;
});
}

/**
* Handles a hook invocation from the Copilot CLI.
* <p>
Expand Down Expand Up @@ -982,6 +1049,38 @@ public CompletableFuture<Void> abort() {
return rpc.invoke("session.abort", Map.of("sessionId", sessionId), Void.class);
}

/**
* Changes the model for this session with an optional reasoning effort level.
* <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
* reasoning effort level (e.g., {@code "low"}, {@code "medium"},
* {@code "high"}, {@code "xhigh"}); {@code null} to use default
* @return a future that completes when the model switch is acknowledged
* @throws IllegalStateException
* if this session has been terminated
* @since 1.2.0
*/
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 +1099,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 All @@ -1017,6 +1115,7 @@ public CompletableFuture<Void> setModel(String model) {
* session.log("Build completed successfully").get();
* session.log("Disk space low", "warning", null).get();
* session.log("Temporary status", null, true).get();
* session.log("Details at link", "info", null, "https://example.com").get();
* }</pre>
*
* @param message
Expand All @@ -1028,11 +1127,14 @@ public CompletableFuture<Void> setModel(String model) {
* @param ephemeral
* when {@code true}, the message is transient and not persisted to
* disk; {@code null} uses default behavior
* @param url
* optional URL to associate with the log entry; {@code null} to omit
* @return a future that completes when the message is logged
* @throws IllegalStateException
* if this session has been terminated
* @since 1.2.0
*/
public CompletableFuture<Void> log(String message, String level, Boolean ephemeral) {
public CompletableFuture<Void> log(String message, String level, Boolean ephemeral, String url) {
ensureNotTerminated();
var params = new java.util.HashMap<String, Object>();
params.put("sessionId", sessionId);
Expand All @@ -1043,9 +1145,44 @@ public CompletableFuture<Void> log(String message, String level, Boolean ephemer
if (ephemeral != null) {
params.put("ephemeral", ephemeral);
}
if (url != null) {
params.put("url", url);
}
return rpc.invoke("session.log", params, Void.class);
}

/**
* Logs a message to the session timeline.
* <p>
* The message appears in the session event stream and is visible to SDK
* consumers. Non-ephemeral messages are also persisted to the session event log
* on disk.
*
* <h2>Example Usage</h2>
*
* <pre>{@code
* session.log("Build completed successfully").get();
* session.log("Disk space low", "warning", null).get();
* session.log("Temporary status", null, true).get();
* }</pre>
*
* @param message
* the message to log
* @param level
* the log severity level ({@code "info"}, {@code "warning"},
* {@code "error"}), or {@code null} to use the default
* ({@code "info"})
* @param ephemeral
* when {@code true}, the message is transient and not persisted to
* disk; {@code null} uses default behavior
* @return a future that completes when the message is logged
* @throws IllegalStateException
* if this session has been terminated
*/
public CompletableFuture<Void> log(String message, String level, Boolean ephemeral) {
return log(message, level, ephemeral, null);
}

/**
* Logs an informational message to the session timeline.
*
Expand Down
39 changes: 39 additions & 0 deletions src/main/java/com/github/copilot/sdk/RpcHandlerDispatcher.java
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ void registerHandlers(JsonRpcClient rpc) {
rpc.registerMethodHandler("userInput.request",
(requestId, params) -> handleUserInputRequest(rpc, requestId, params));
rpc.registerMethodHandler("hooks.invoke", (requestId, params) -> handleHooksInvoke(rpc, requestId, params));
rpc.registerMethodHandler("systemMessage.transform",
(requestId, params) -> handleSystemMessageTransform(rpc, requestId, params));
}

private void handleSessionEvent(JsonNode params) {
Expand Down Expand Up @@ -191,6 +193,11 @@ private void handlePermissionRequest(JsonRpcClient rpc, String requestId, JsonNo

session.handlePermissionRequest(permissionRequest).thenAccept(result -> {
try {
if (PermissionRequestResultKind.NO_RESULT.equals(result.getKind())) {
Comment thread
github-code-quality[bot] marked this conversation as resolved.
Fixed
// Handler explicitly abstains — do not send a response,
// allowing another client to handle the request.
return;
}
rpc.sendResponse(Long.parseLong(requestId), Map.of("result", result));
} catch (IOException e) {
LOG.log(Level.SEVERE, "Error sending permission result", e);
Expand Down Expand Up @@ -310,4 +317,36 @@ interface LifecycleEventDispatcher {

void dispatch(SessionLifecycleEvent event);
}

private void handleSystemMessageTransform(JsonRpcClient rpc, String requestId, JsonNode params) {
CompletableFuture.runAsync(() -> {
try {
String sessionId = params.has("sessionId") ? params.get("sessionId").asText() : null;
JsonNode sections = params.get("sections");

CopilotSession session = sessionId != null ? sessions.get(sessionId) : null;
if (session == null) {
rpc.sendErrorResponse(Long.parseLong(requestId), -32602, "Unknown session " + sessionId);
return;
}

session.handleSystemMessageTransform(sections).thenAccept(result -> {
try {
rpc.sendResponse(Long.parseLong(requestId), result);
Comment thread
github-code-quality[bot] marked this conversation as resolved.
Fixed
} catch (IOException e) {
LOG.log(Level.SEVERE, "Error sending systemMessage.transform response", e);
}
}).exceptionally(ex -> {
try {
rpc.sendErrorResponse(Long.parseLong(requestId), -32603, "Transform error: " + ex.getMessage());
Comment thread
github-code-quality[bot] marked this conversation as resolved.
Fixed
} catch (IOException e) {
LOG.log(Level.SEVERE, "Error sending transform error response", e);
}
return null;
});
} catch (Exception e) {
LOG.log(Level.SEVERE, "Error handling systemMessage.transform", e);
}
});
}
}
Loading
Loading