Skip to content
Merged
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: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,5 @@ blog-copilotsdk/
.claude/worktrees
smoke-test
*job-logs.txt
temporary-prompts/
changebundle.txt*
2 changes: 1 addition & 1 deletion .lastmerge
Original file line number Diff line number Diff line change
@@ -1 +1 @@
062b61c8aa63b9b5d45fa1d7b01723e6660ffa83
40887393a9e687dacc141a645799441b0313ff15
31 changes: 30 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,36 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## [Unreleased]

> **Upstream sync:** [`github/copilot-sdk@062b61c`](https://github.com/github/copilot-sdk/commit/062b61c8aa63b9b5d45fa1d7b01723e6660ffa83)
> **Upstream sync:** [`github/copilot-sdk@4088739`](https://github.com/github/copilot-sdk/commit/40887393a9e687dacc141a645799441b0313ff15)

### Added

- `UnknownSessionEvent` — forward-compatible placeholder for event types not yet known to the SDK; unknown events are now dispatched to handlers instead of being silently dropped (upstream: [`d82fd62`](https://github.com/github/copilot-sdk/commit/d82fd62))
- `PermissionRequestResultKind.NO_RESULT` — new constant that signals the handler intentionally abstains from answering a permission request, leaving it unanswered for another client (upstream: [`df59a0e`](https://github.com/github/copilot-sdk/commit/df59a0e))
- `ToolDefinition.skipPermission` field and `ToolDefinition.createSkipPermission()` factory — marks a tool to skip the permission prompt (upstream: [`10c4d02`](https://github.com/github/copilot-sdk/commit/10c4d02))
- `SystemMessageMode.CUSTOMIZE` — new enum value for fine-grained system prompt customization (upstream: [`005b780`](https://github.com/github/copilot-sdk/commit/005b780))
- `SectionOverrideAction` enum — specifies the operation on a system prompt section (replace, remove, append, prepend, transform) (upstream: [`005b780`](https://github.com/github/copilot-sdk/commit/005b780))
- `SectionOverride` class — describes how one section of the system prompt should be modified, with optional transform callback (upstream: [`005b780`](https://github.com/github/copilot-sdk/commit/005b780))
- `SystemPromptSections` constants — well-known section identifier strings for use with CUSTOMIZE mode (upstream: [`005b780`](https://github.com/github/copilot-sdk/commit/005b780))
- `SystemMessageConfig.setSections(Map<String,SectionOverride>)` — section-level overrides for CUSTOMIZE mode (upstream: [`005b780`](https://github.com/github/copilot-sdk/commit/005b780))
- `systemMessage.transform` RPC handler — the SDK now registers a handler that invokes transform callbacks registered in the session config (upstream: [`005b780`](https://github.com/github/copilot-sdk/commit/005b780))
- `CopilotSession.setModel(String, String)` — new overload that accepts an optional reasoning effort level (upstream: [`ea90f07`](https://github.com/github/copilot-sdk/commit/ea90f07))
- `CopilotSession.log(String, String, Boolean, String)` — new overload with an optional `url` parameter (minor addition)
- `BlobAttachment` class — inline base64-encoded binary attachment for messages (e.g., images) (upstream: [`698b259`](https://github.com/github/copilot-sdk/commit/698b259))
- `MessageAttachment` sealed interface — type-safe base for all attachment types (`Attachment`, `BlobAttachment`), with Jackson polymorphic serialization support
- `TelemetryConfig` class — OpenTelemetry configuration for the CLI server; set on `CopilotClientOptions.setTelemetry()` (upstream: [`f2d21a0`](https://github.com/github/copilot-sdk/commit/f2d21a0))
- `CopilotClientOptions.setTelemetry(TelemetryConfig)` — enables OpenTelemetry instrumentation in the CLI server (upstream: [`f2d21a0`](https://github.com/github/copilot-sdk/commit/f2d21a0))

### Changed

- `Attachment` record now implements `MessageAttachment` sealed interface
- `BlobAttachment` class now implements `MessageAttachment` sealed interface and is `final`
- `MessageOptions.setAttachments(List<? extends MessageAttachment>)` — parameter type changed from `List<Attachment>` to `List<? extends MessageAttachment>` to support both `Attachment` and `BlobAttachment` in the same list with full compile-time safety
- `SendMessageRequest.setAttachments(List<MessageAttachment>)` — matching change for the internal request type

### Deprecated

- `CopilotClientOptions.setAutoRestart(boolean)` — this option has no effect and will be removed in a future release

## [0.1.32-java.0] - 2026-03-17

Expand Down
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
21 changes: 21 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,19 @@ 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.
var extracted = SessionRequestBuilder.extractTransformCallbacks(config.getSystemMessage());
if (extracted.transformCallbacks() != null) {
session.registerTransformCallbacks(extracted.transformCallbacks());
}

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

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

// Extract transform callbacks from the system message config.
var extracted = SessionRequestBuilder.extractTransformCallbacks(config.getSystemMessage());
if (extracted.transformCallbacks() != null) {
session.registerTransformCallbacks(extracted.transformCallbacks());
}

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

return connection.rpc.invoke("session.resume", request, ResumeSessionResponse.class).thenApply(response -> {
session.setWorkspacePath(response.workspacePath());
Expand Down
144 changes: 141 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,12 @@ private void executePermissionAndRespondAsync(String requestId, PermissionReques
invocation.setSessionId(sessionId);
handler.handle(permissionRequest, invocation).thenAccept(result -> {
try {
PermissionRequestResultKind kind = new PermissionRequestResultKind(result.getKind());
if (PermissionRequestResultKind.NO_RESULT.equals(kind)) {
// 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 +874,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 +1050,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 +1100,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 +1116,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 +1128,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 +1146,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
30 changes: 30 additions & 0 deletions src/main/java/com/github/copilot/sdk/ExtractedTransforms.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
*--------------------------------------------------------------------------------------------*/

package com.github.copilot.sdk;

import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.function.Function;

import com.github.copilot.sdk.json.SystemMessageConfig;

/**
* Result of extracting transform callbacks from a {@link SystemMessageConfig}.
* <p>
* Holds a wire-safe copy of the system message config (with transform callbacks
* replaced by {@code action="transform"}) alongside the extracted callbacks
* that must be registered with the session.
*
* @param wireSystemMessage
* the system message config safe for JSON serialization; may be
* {@code null} when the input config was {@code null}
* @param transformCallbacks
* transform callbacks keyed by section identifier; {@code null} when
* no transforms were present
* @see SessionRequestBuilder#extractTransformCallbacks(SystemMessageConfig)
*/
record ExtractedTransforms(SystemMessageConfig wireSystemMessage,
Map<String, Function<String, CompletableFuture<String>>> transformCallbacks) {
}
Loading
Loading