Skip to content
Closed
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 @@
4e1499dd23709022c720eaaa5457d00bf0cb3977
723560972ecce16566739cdaf10e00c11b9a15f0
12 changes: 11 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## [Unreleased]

> **Upstream sync:** [`github/copilot-sdk@dcd86c1`](https://github.com/github/copilot-sdk/commit/dcd86c189501ce1b46b787ca60d90f3f315f3079)
> **Upstream sync:** [`github/copilot-sdk@7235609`](https://github.com/github/copilot-sdk/commit/723560972ecce16566739cdaf10e00c11b9a15f0)

### Added

- **Protocol version 3**: SDK now communicates using protocol v3 and accepts servers running v2 or v3 (upstream: [`11dde6e`](https://github.com/github/copilot-sdk/commit/11dde6e))
- `SessionConfig.setAgent(String)` / `ResumeSessionConfig.setAgent(String)` — pre-selects a custom agent when the session starts; must match the name of one of the `customAgents` entries (upstream: [`7766b1a`](https://github.com/github/copilot-sdk/commit/7766b1a))
- `CopilotClientOptions.setOnListModels(Supplier<CompletableFuture<List<ModelInfo>>>)` — custom handler for `listModels()` that bypasses the CLI server; useful in BYOK mode to expose models from a custom provider (upstream: [`e478657`](https://github.com/github/copilot-sdk/commit/e478657))
- `CopilotSession.log(String)` / `CopilotSession.log(String, SessionLogRequestLevel, Boolean)` — logs a message to the session timeline at a given severity level; supports ephemeral messages that are not persisted (upstream: [`11dde6e`](https://github.com/github/copilot-sdk/commit/11dde6e))
- `SessionLogRequestLevel` enum — `INFO`, `WARNING`, `ERROR` severity levels for `log()` (upstream: [`11dde6e`](https://github.com/github/copilot-sdk/commit/11dde6e))
- New session event types for protocol v3: `ExternalToolRequestedEvent` (`external_tool.requested`), `ExternalToolCompletedEvent` (`external_tool.completed`), `PermissionRequestedEvent` (`permission.requested`), `CommandQueuedEvent` (`command.queued`), `CommandCompletedEvent` (`command.completed`), `ExitPlanModeRequestedEvent` (`exit_plan_mode.requested`), `ExitPlanModeCompletedEvent` (`exit_plan_mode.completed`), `SystemNotificationEvent` (`system.notification`) (upstream: [`11dde6e`](https://github.com/github/copilot-sdk/commit/11dde6e))
- Protocol v3 broadcast event handling: SDK now automatically handles `external_tool.requested` and `permission.requested` broadcast events by invoking registered handlers and responding via `session.tools.handlePendingToolCall` / `session.permissions.handlePendingPermissionRequest` RPCs (upstream: [`396e8b3`](https://github.com/github/copilot-sdk/commit/396e8b3))

## [1.0.10] - 2026-03-03

Expand Down
39 changes: 32 additions & 7 deletions src/main/java/com/github/copilot/sdk/CopilotClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,11 @@ public final class CopilotClient implements AutoCloseable {

private static final Logger LOG = Logger.getLogger(CopilotClient.class.getName());

/**
* Minimum protocol version this SDK can communicate with.
*/
private static final int MIN_PROTOCOL_VERSION = 2;

/**
* Timeout, in seconds, used by {@link #close()} when waiting for graceful
* shutdown via {@link #stop()}.
Expand All @@ -81,6 +86,7 @@ public final class CopilotClient implements AutoCloseable {
private final Integer optionsPort;
private volatile List<ModelInfo> modelsCache;
private final Object modelsCacheLock = new Object();
private final java.util.function.Supplier<CompletableFuture<List<ModelInfo>>> onListModels;

/**
* Creates a new CopilotClient with default options.
Expand Down Expand Up @@ -129,6 +135,7 @@ public CopilotClient(CopilotClientOptions options) {
}

this.serverManager = new CliServerManager(this.options);
this.onListModels = this.options.getOnListModels();
}

/**
Expand Down Expand Up @@ -189,20 +196,21 @@ private CompletableFuture<Connection> startCore() {
}

private void verifyProtocolVersion(Connection connection) throws Exception {
int expectedVersion = SdkProtocolVersion.get();
int maxVersion = SdkProtocolVersion.get();
var params = new HashMap<String, Object>();
params.put("message", null);
PingResponse pingResponse = connection.rpc.invoke("ping", params, PingResponse.class).get(30, TimeUnit.SECONDS);

if (pingResponse.protocolVersion() == null) {
throw new RuntimeException("SDK protocol version mismatch: SDK expects version " + expectedVersion
+ ", but server does not report a protocol version. "
throw new RuntimeException("SDK protocol version mismatch: SDK supports versions " + MIN_PROTOCOL_VERSION
+ "-" + maxVersion + ", but server does not report a protocol version. "
+ "Please update your server to ensure compatibility.");
}

if (pingResponse.protocolVersion() != expectedVersion) {
throw new RuntimeException("SDK protocol version mismatch: SDK expects version " + expectedVersion
+ ", but server reports version " + pingResponse.protocolVersion() + ". "
int serverVersion = pingResponse.protocolVersion();
if (serverVersion < MIN_PROTOCOL_VERSION || serverVersion > maxVersion) {
throw new RuntimeException("SDK protocol version mismatch: SDK supports versions " + MIN_PROTOCOL_VERSION
+ "-" + maxVersion + ", but server reports version " + serverVersion + ". "
+ "Please update your SDK or server to ensure compatibility.");
}
}
Expand Down Expand Up @@ -434,17 +442,34 @@ public CompletableFuture<GetAuthStatusResponse> getAuthStatus() {
* <p>
* Results are cached after the first successful call to avoid rate limiting.
* The cache is cleared when the client disconnects.
* <p>
* If an {@code onListModels} handler was provided via
* {@link com.github.copilot.sdk.json.CopilotClientOptions#setOnListModels},
* that handler is called instead of querying the CLI server. This is useful in
* BYOK mode to return models from a custom provider without a running server.
*
* @return a future that resolves with a list of available models
* @see ModelInfo
*/
public CompletableFuture<List<ModelInfo>> listModels() {
// Check cache first
// Check cache first (works for both custom handler and server-backed paths)
List<ModelInfo> cached = modelsCache;
if (cached != null) {
return CompletableFuture.completedFuture(new ArrayList<>(cached));
}

if (onListModels != null) {
// Use custom handler — no server connection required
return onListModels.get().thenApply(models -> {
synchronized (modelsCacheLock) {
if (modelsCache == null) {
modelsCache = new ArrayList<>(models);
}
}
return new ArrayList<>(models);
});
}

return ensureConnected().thenCompose(connection -> {
// Double-check cache inside lock
synchronized (modelsCacheLock) {
Expand Down
168 changes: 168 additions & 0 deletions src/main/java/com/github/copilot/sdk/CopilotSession.java
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,14 @@
import com.github.copilot.sdk.json.PermissionInvocation;
import com.github.copilot.sdk.json.PermissionRequest;
import com.github.copilot.sdk.json.PermissionRequestResult;
import com.github.copilot.sdk.json.PermissionRequestResultKind;
import com.github.copilot.sdk.json.PostToolUseHookInput;
import com.github.copilot.sdk.json.PreToolUseHookInput;
import com.github.copilot.sdk.json.SendMessageRequest;
import com.github.copilot.sdk.json.SendMessageResponse;
import com.github.copilot.sdk.json.SessionEndHookInput;
import com.github.copilot.sdk.json.SessionHooks;
import com.github.copilot.sdk.json.SessionLogRequestLevel;
import com.github.copilot.sdk.json.SessionStartHookInput;
import com.github.copilot.sdk.json.ToolDefinition;
import com.github.copilot.sdk.json.UserInputHandler;
Expand Down Expand Up @@ -572,6 +574,118 @@ void dispatchEvent(AbstractSessionEvent event) {
}
}

/**
* Handles broadcast request events (protocol v3) by executing the appropriate
* handler and responding via RPC. This implements the protocol v3 broadcast
* model where tool calls and permission requests are broadcast as session
* events to all listening clients.
* <p>
* Fire-and-forget: called asynchronously so handler errors do not interrupt
* event dispatch to user listeners.
*/
private void handleBroadcastEvent(AbstractSessionEvent event) {
if (event instanceof ExternalToolRequestedEvent toolEvent) {
var data = toolEvent.getData();
if (data == null || data.getRequestId() == null || data.getToolName() == null) {
return;
}
ToolDefinition tool = getTool(data.getToolName());
if (tool == null) {
return; // This client doesn't handle this tool; another client will.
}
executeToolAndRespond(data.getRequestId(), data.getToolCallId(), data.getToolName(), data.getArguments(),
tool);
} else if (event instanceof PermissionRequestedEvent permEvent) {
var data = permEvent.getData();
if (data == null || data.requestId() == null || data.permissionRequest() == null) {
return;
}
PermissionHandler handler = permissionHandler.get();
if (handler == null) {
return; // This client doesn't handle permissions; another client will.
}
executePermissionAndRespond(data.requestId(), data.permissionRequest(), handler);
}
}

/**
* Executes a tool handler and sends the result back via the
* {@code session.tools.handlePendingToolCall} RPC.
*/
private void executeToolAndRespond(String requestId, String toolCallId, String toolName, JsonNode arguments,
ToolDefinition tool) {
var invocation = new com.github.copilot.sdk.json.ToolInvocation();
invocation.setSessionId(sessionId);
invocation.setToolCallId(toolCallId);
invocation.setToolName(toolName);
invocation.setArguments(arguments);

tool.handler().invoke(invocation).whenComplete((result, ex) -> {
try {
var params = new java.util.HashMap<String, Object>();
params.put("sessionId", sessionId);
params.put("requestId", requestId);
if (ex != null) {
params.put("error", ex.getMessage());
} else {
com.github.copilot.sdk.json.ToolResultObject toolResult;
if (result instanceof com.github.copilot.sdk.json.ToolResultObject tr) {
toolResult = tr;
} else {
toolResult = com.github.copilot.sdk.json.ToolResultObject
.success(result instanceof String s ? s : MAPPER.writeValueAsString(result));
}
params.put("result", toolResult);
}
rpc.invoke("session.tools.handlePendingToolCall", params, Void.class).whenComplete((v, rpcEx) -> {
if (rpcEx != null) {
LOG.log(Level.WARNING, "Failed to respond to tool call " + requestId, rpcEx);
}
});
} catch (Exception e) {
LOG.log(Level.WARNING, "Error responding to tool call " + requestId, e);
}
});
}

/**
* Executes a permission handler and sends the result back via the
* {@code session.permissions.handlePendingPermissionRequest} RPC.
*/
private void executePermissionAndRespond(String requestId, PermissionRequest permissionRequest,
PermissionHandler handler) {
var invocation = new PermissionInvocation();
invocation.setSessionId(sessionId);

CompletableFuture<PermissionRequestResult> resultFuture;
try {
resultFuture = handler.handle(permissionRequest, invocation);
} catch (Exception e) {
resultFuture = CompletableFuture.failedFuture(e);
}

resultFuture.whenComplete((result, ex) -> {
try {
PermissionRequestResult finalResult = (ex != null || result == null)
? new PermissionRequestResult()
.setKind(PermissionRequestResultKind.DENIED_COULD_NOT_REQUEST_FROM_USER)
: result;
var params = new java.util.HashMap<String, Object>();
params.put("sessionId", sessionId);
params.put("requestId", requestId);
params.put("result", finalResult);
rpc.invoke("session.permissions.handlePendingPermissionRequest", params, Void.class)
.whenComplete((v, rpcEx) -> {
if (rpcEx != null) {
LOG.log(Level.WARNING, "Failed to respond to permission request " + requestId, rpcEx);
}
});
} catch (Exception e) {
LOG.log(Level.WARNING, "Error responding to permission request " + requestId, e);
}
});
}

/**
* Registers custom tool handlers for this session.
* <p>
Expand Down Expand Up @@ -915,6 +1029,60 @@ public CompletableFuture<Void> compact() {
return rpc.invoke("session.compaction.compact", Map.of("sessionId", sessionId), 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.
*
* <pre>{@code
* session.log("Build completed successfully").get();
* session.log("Disk space low", SessionLogRequestLevel.WARNING, null).get();
* session.log("Temporary status", null, true).get();
* }</pre>
*
* @param message
* the message to log
* @param level
* the log severity level, or {@code null} for the default
* ({@link SessionLogRequestLevel#INFO})
* @param ephemeral
* when {@code true}, the message is not persisted to disk
* @return a future that completes when the log entry is acknowledged
* @throws IllegalStateException
* if this session has been terminated
* @since 1.0.14
*/
public CompletableFuture<Void> log(String message, SessionLogRequestLevel level, Boolean ephemeral) {
ensureNotTerminated();
var params = new java.util.HashMap<String, Object>();
params.put("sessionId", sessionId);
params.put("message", message);
if (level != null) {
params.put("level", level.name().toLowerCase());
}
if (ephemeral != null) {
params.put("ephemeral", ephemeral);
}
return rpc.invoke("session.log", params, Void.class);
}

/**
* Logs a message to the session timeline at the default ({@code info}) level.
*
* @param message
* the message to log
* @return a future that completes when the log entry is acknowledged
* @throws IllegalStateException
* if this session has been terminated
* @see #log(String, SessionLogRequestLevel, Boolean)
* @since 1.0.14
*/
public CompletableFuture<Void> log(String message) {
return log(message, null, null);
}

/**
* Verifies that this session has not yet been terminated.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
*/
public enum SdkProtocolVersion {

LATEST(2);
LATEST(3);

private int versionNumber;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ static CreateSessionRequest buildCreateRequest(SessionConfig config) {
request.setStreaming(config.isStreaming() ? true : null);
request.setMcpServers(config.getMcpServers());
request.setCustomAgents(config.getCustomAgents());
request.setAgent(config.getAgent());
request.setInfiniteSessions(config.getInfiniteSessions());
request.setSkillDirectories(config.getSkillDirectories());
request.setDisabledSkills(config.getDisabledSkills());
Expand Down Expand Up @@ -99,6 +100,7 @@ static ResumeSessionRequest buildResumeRequest(String sessionId, ResumeSessionCo
request.setStreaming(config.isStreaming() ? true : null);
request.setMcpServers(config.getMcpServers());
request.setCustomAgents(config.getCustomAgents());
request.setAgent(config.getAgent());
request.setSkillDirectories(config.getSkillDirectories());
request.setDisabledSkills(config.getDisabledSkills());
request.setInfiniteSessions(config.getInfiniteSessions());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,10 @@ public abstract sealed class AbstractSessionEvent permits
SkillInvokedEvent,
// Other events
SubagentStartedEvent, SubagentCompletedEvent, SubagentFailedEvent, SubagentSelectedEvent,
SubagentDeselectedEvent, HookStartEvent, HookEndEvent, SystemMessageEvent {
SubagentDeselectedEvent, HookStartEvent, HookEndEvent, SystemMessageEvent, SystemNotificationEvent,
// Protocol v3 broadcast events
ExternalToolRequestedEvent, ExternalToolCompletedEvent, PermissionRequestedEvent, CommandQueuedEvent,
CommandCompletedEvent, ExitPlanModeRequestedEvent, ExitPlanModeCompletedEvent {

@JsonProperty("id")
private UUID id;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/*---------------------------------------------------------------------------------------------
* 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: command.completed
* <p>
* Emitted when a queued command has been resolved. Clients should dismiss any
* pending UI for the associated request.
*
* @since 1.0.14
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public final class CommandCompletedEvent extends AbstractSessionEvent {

@JsonProperty("data")
private CommandCompletedData data;

@Override
public String getType() {
return "command.completed";
}

public CommandCompletedData getData() {
return data;
}

public void setData(CommandCompletedData data) {
this.data = data;
}

@JsonIgnoreProperties(ignoreUnknown = true)
public record CommandCompletedData(@JsonProperty("requestId") String requestId) {
}
}
Loading
Loading