diff --git a/.lastmerge b/.lastmerge index df5b85477..54a340ca3 100644 --- a/.lastmerge +++ b/.lastmerge @@ -1 +1 @@ -4e1499dd23709022c720eaaa5457d00bf0cb3977 +723560972ecce16566739cdaf10e00c11b9a15f0 diff --git a/CHANGELOG.md b/CHANGELOG.md index 4845a214e..179aca339 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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>>)` — 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 diff --git a/src/main/java/com/github/copilot/sdk/CopilotClient.java b/src/main/java/com/github/copilot/sdk/CopilotClient.java index 023051edd..0229e1979 100644 --- a/src/main/java/com/github/copilot/sdk/CopilotClient.java +++ b/src/main/java/com/github/copilot/sdk/CopilotClient.java @@ -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()}. @@ -81,6 +86,7 @@ public final class CopilotClient implements AutoCloseable { private final Integer optionsPort; private volatile List modelsCache; private final Object modelsCacheLock = new Object(); + private final java.util.function.Supplier>> onListModels; /** * Creates a new CopilotClient with default options. @@ -129,6 +135,7 @@ public CopilotClient(CopilotClientOptions options) { } this.serverManager = new CliServerManager(this.options); + this.onListModels = this.options.getOnListModels(); } /** @@ -189,20 +196,21 @@ private CompletableFuture startCore() { } private void verifyProtocolVersion(Connection connection) throws Exception { - int expectedVersion = SdkProtocolVersion.get(); + int maxVersion = SdkProtocolVersion.get(); var params = new HashMap(); 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."); } } @@ -434,17 +442,34 @@ public CompletableFuture getAuthStatus() { *

* Results are cached after the first successful call to avoid rate limiting. * The cache is cleared when the client disconnects. + *

+ * 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> listModels() { - // Check cache first + // Check cache first (works for both custom handler and server-backed paths) List 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) { diff --git a/src/main/java/com/github/copilot/sdk/CopilotSession.java b/src/main/java/com/github/copilot/sdk/CopilotSession.java index 80b2e85ea..2bf6603d0 100644 --- a/src/main/java/com/github/copilot/sdk/CopilotSession.java +++ b/src/main/java/com/github/copilot/sdk/CopilotSession.java @@ -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; @@ -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. + *

+ * 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(); + 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 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(); + 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. *

@@ -915,6 +1029,60 @@ public CompletableFuture compact() { return rpc.invoke("session.compaction.compact", Map.of("sessionId", sessionId), Void.class); } + /** + * Logs a message to the session timeline. + *

+ * 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. + * + *

{@code
+     * session.log("Build completed successfully").get();
+     * session.log("Disk space low", SessionLogRequestLevel.WARNING, null).get();
+     * session.log("Temporary status", null, true).get();
+     * }
+ * + * @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 log(String message, SessionLogRequestLevel level, Boolean ephemeral) { + ensureNotTerminated(); + var params = new java.util.HashMap(); + 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 log(String message) { + return log(message, null, null); + } + /** * Verifies that this session has not yet been terminated. * diff --git a/src/main/java/com/github/copilot/sdk/SdkProtocolVersion.java b/src/main/java/com/github/copilot/sdk/SdkProtocolVersion.java index 40a7f9d56..3b00a88ae 100644 --- a/src/main/java/com/github/copilot/sdk/SdkProtocolVersion.java +++ b/src/main/java/com/github/copilot/sdk/SdkProtocolVersion.java @@ -14,7 +14,7 @@ */ public enum SdkProtocolVersion { - LATEST(2); + LATEST(3); private int versionNumber; diff --git a/src/main/java/com/github/copilot/sdk/SessionRequestBuilder.java b/src/main/java/com/github/copilot/sdk/SessionRequestBuilder.java index 90f3c71d8..4861c3fcd 100644 --- a/src/main/java/com/github/copilot/sdk/SessionRequestBuilder.java +++ b/src/main/java/com/github/copilot/sdk/SessionRequestBuilder.java @@ -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()); @@ -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()); 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 6c9d76e99..882896ec2 100644 --- a/src/main/java/com/github/copilot/sdk/events/AbstractSessionEvent.java +++ b/src/main/java/com/github/copilot/sdk/events/AbstractSessionEvent.java @@ -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; diff --git a/src/main/java/com/github/copilot/sdk/events/CommandCompletedEvent.java b/src/main/java/com/github/copilot/sdk/events/CommandCompletedEvent.java new file mode 100644 index 000000000..8c82cefe6 --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/events/CommandCompletedEvent.java @@ -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 + *

+ * 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) { + } +} diff --git a/src/main/java/com/github/copilot/sdk/events/CommandQueuedEvent.java b/src/main/java/com/github/copilot/sdk/events/CommandQueuedEvent.java new file mode 100644 index 000000000..6dc062b32 --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/events/CommandQueuedEvent.java @@ -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.queued + *

+ * Emitted when a slash command has been queued for approval. + * + * @since 1.0.14 + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class CommandQueuedEvent extends AbstractSessionEvent { + + @JsonProperty("data") + private CommandQueuedData data; + + @Override + public String getType() { + return "command.queued"; + } + + public CommandQueuedData getData() { + return data; + } + + public void setData(CommandQueuedData data) { + this.data = data; + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record CommandQueuedData(@JsonProperty("requestId") String requestId, + @JsonProperty("command") String command) { + } +} diff --git a/src/main/java/com/github/copilot/sdk/events/ExitPlanModeCompletedEvent.java b/src/main/java/com/github/copilot/sdk/events/ExitPlanModeCompletedEvent.java new file mode 100644 index 000000000..b9df0b8e5 --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/events/ExitPlanModeCompletedEvent.java @@ -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: exit_plan_mode.completed + *

+ * Emitted when an exit-plan-mode request has been resolved. Clients should + * dismiss any pending UI for the associated request. + * + * @since 1.0.14 + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class ExitPlanModeCompletedEvent extends AbstractSessionEvent { + + @JsonProperty("data") + private ExitPlanModeCompletedData data; + + @Override + public String getType() { + return "exit_plan_mode.completed"; + } + + public ExitPlanModeCompletedData getData() { + return data; + } + + public void setData(ExitPlanModeCompletedData data) { + this.data = data; + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record ExitPlanModeCompletedData(@JsonProperty("requestId") String requestId) { + } +} diff --git a/src/main/java/com/github/copilot/sdk/events/ExitPlanModeRequestedEvent.java b/src/main/java/com/github/copilot/sdk/events/ExitPlanModeRequestedEvent.java new file mode 100644 index 000000000..44f629fbd --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/events/ExitPlanModeRequestedEvent.java @@ -0,0 +1,42 @@ +/*--------------------------------------------------------------------------------------------- + * 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: exit_plan_mode.requested + *

+ * Emitted when the agent has finished a planning phase and is requesting + * approval to exit plan mode and begin execution. + * + * @since 1.0.14 + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class ExitPlanModeRequestedEvent extends AbstractSessionEvent { + + @JsonProperty("data") + private ExitPlanModeRequestedData data; + + @Override + public String getType() { + return "exit_plan_mode.requested"; + } + + public ExitPlanModeRequestedData getData() { + return data; + } + + public void setData(ExitPlanModeRequestedData data) { + this.data = data; + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record ExitPlanModeRequestedData(@JsonProperty("requestId") String requestId, + @JsonProperty("summary") String summary, @JsonProperty("planContent") String planContent, + @JsonProperty("actions") String[] actions, @JsonProperty("recommendedAction") String recommendedAction) { + } +} diff --git a/src/main/java/com/github/copilot/sdk/events/ExternalToolCompletedEvent.java b/src/main/java/com/github/copilot/sdk/events/ExternalToolCompletedEvent.java new file mode 100644 index 000000000..35d587f7f --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/events/ExternalToolCompletedEvent.java @@ -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: external_tool.completed + *

+ * Emitted when an external tool request has been resolved. Clients should + * dismiss any pending UI for the associated request. + * + * @since 1.0.14 + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class ExternalToolCompletedEvent extends AbstractSessionEvent { + + @JsonProperty("data") + private ExternalToolCompletedData data; + + @Override + public String getType() { + return "external_tool.completed"; + } + + public ExternalToolCompletedData getData() { + return data; + } + + public void setData(ExternalToolCompletedData data) { + this.data = data; + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record ExternalToolCompletedData(@JsonProperty("requestId") String requestId) { + } +} diff --git a/src/main/java/com/github/copilot/sdk/events/ExternalToolRequestedEvent.java b/src/main/java/com/github/copilot/sdk/events/ExternalToolRequestedEvent.java new file mode 100644 index 000000000..6ff797055 --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/events/ExternalToolRequestedEvent.java @@ -0,0 +1,97 @@ +/*--------------------------------------------------------------------------------------------- + * 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; +import com.fasterxml.jackson.databind.JsonNode; + +/** + * Event: external_tool.requested + *

+ * Emitted by a protocol v3 server when a registered tool is invoked. The SDK + * handles this event automatically by executing the tool and responding via the + * {@code session.tools.handlePendingToolCall} RPC. + * + * @since 1.0.14 + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class ExternalToolRequestedEvent extends AbstractSessionEvent { + + @JsonProperty("data") + private ExternalToolRequestedData data; + + @Override + public String getType() { + return "external_tool.requested"; + } + + public ExternalToolRequestedData getData() { + return data; + } + + public void setData(ExternalToolRequestedData data) { + this.data = data; + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public static class ExternalToolRequestedData { + + @JsonProperty("requestId") + private String requestId; + + @JsonProperty("sessionId") + private String sessionId; + + @JsonProperty("toolCallId") + private String toolCallId; + + @JsonProperty("toolName") + private String toolName; + + @JsonProperty("arguments") + private JsonNode arguments; + + public String getRequestId() { + return requestId; + } + + public void setRequestId(String requestId) { + this.requestId = requestId; + } + + public String getSessionId() { + return sessionId; + } + + public void setSessionId(String sessionId) { + this.sessionId = sessionId; + } + + public String getToolCallId() { + return toolCallId; + } + + public void setToolCallId(String toolCallId) { + this.toolCallId = toolCallId; + } + + public String getToolName() { + return toolName; + } + + public void setToolName(String toolName) { + this.toolName = toolName; + } + + public JsonNode getArguments() { + return arguments; + } + + public void setArguments(JsonNode arguments) { + this.arguments = arguments; + } + } +} diff --git a/src/main/java/com/github/copilot/sdk/events/PermissionRequestedEvent.java b/src/main/java/com/github/copilot/sdk/events/PermissionRequestedEvent.java new file mode 100644 index 000000000..c2b4cdc8d --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/events/PermissionRequestedEvent.java @@ -0,0 +1,44 @@ +/*--------------------------------------------------------------------------------------------- + * 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; +import com.github.copilot.sdk.json.PermissionRequest; + +/** + * Event: permission.requested + *

+ * Emitted by a protocol v3 server when a permission is required for an + * operation. The SDK handles this event automatically by invoking the + * registered permission handler and responding via the + * {@code session.permissions.handlePendingPermissionRequest} RPC. + * + * @since 1.0.14 + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class PermissionRequestedEvent extends AbstractSessionEvent { + + @JsonProperty("data") + private PermissionRequestedData data; + + @Override + public String getType() { + return "permission.requested"; + } + + public PermissionRequestedData getData() { + return data; + } + + public void setData(PermissionRequestedData data) { + this.data = data; + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record PermissionRequestedData(@JsonProperty("requestId") String requestId, + @JsonProperty("permissionRequest") PermissionRequest permissionRequest) { + } +} 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 cfe8d5711..eb21f5718 100644 --- a/src/main/java/com/github/copilot/sdk/events/SessionEventParser.java +++ b/src/main/java/com/github/copilot/sdk/events/SessionEventParser.java @@ -91,8 +91,16 @@ public class SessionEventParser { TYPE_MAP.put("hook.start", HookStartEvent.class); TYPE_MAP.put("hook.end", HookEndEvent.class); TYPE_MAP.put("system.message", SystemMessageEvent.class); + TYPE_MAP.put("system.notification", SystemNotificationEvent.class); TYPE_MAP.put("session.shutdown", SessionShutdownEvent.class); TYPE_MAP.put("skill.invoked", SkillInvokedEvent.class); + TYPE_MAP.put("external_tool.requested", ExternalToolRequestedEvent.class); + TYPE_MAP.put("external_tool.completed", ExternalToolCompletedEvent.class); + TYPE_MAP.put("permission.requested", PermissionRequestedEvent.class); + TYPE_MAP.put("command.queued", CommandQueuedEvent.class); + TYPE_MAP.put("command.completed", CommandCompletedEvent.class); + TYPE_MAP.put("exit_plan_mode.requested", ExitPlanModeRequestedEvent.class); + TYPE_MAP.put("exit_plan_mode.completed", ExitPlanModeCompletedEvent.class); } /** diff --git a/src/main/java/com/github/copilot/sdk/events/SystemNotificationEvent.java b/src/main/java/com/github/copilot/sdk/events/SystemNotificationEvent.java new file mode 100644 index 000000000..516aee299 --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/events/SystemNotificationEvent.java @@ -0,0 +1,41 @@ +/*--------------------------------------------------------------------------------------------- + * 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: system.notification + *

+ * Emitted when a background agent or shell task completes, carrying structured + * metadata about the completion. + * + * @since 1.0.14 + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class SystemNotificationEvent extends AbstractSessionEvent { + + @JsonProperty("data") + private SystemNotificationData data; + + @Override + public String getType() { + return "system.notification"; + } + + public SystemNotificationData getData() { + return data; + } + + public void setData(SystemNotificationData data) { + this.data = data; + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record SystemNotificationData(@JsonProperty("content") String content, + @JsonProperty("kind") java.util.Map kind) { + } +} 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 70ce99850..3ab8ca284 100644 --- a/src/main/java/com/github/copilot/sdk/json/CopilotClientOptions.java +++ b/src/main/java/com/github/copilot/sdk/json/CopilotClientOptions.java @@ -4,7 +4,10 @@ package com.github.copilot.sdk.json; +import java.util.List; import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.function.Supplier; import com.fasterxml.jackson.annotation.JsonInclude; @@ -43,6 +46,7 @@ public class CopilotClientOptions { private Map environment; private String gitHubToken; private Boolean useLoggedInUser; + private Supplier>> onListModels; /** * Gets the path to the Copilot CLI executable. @@ -349,6 +353,34 @@ public CopilotClientOptions setUseLoggedInUser(Boolean useLoggedInUser) { return this; } + /** + * Gets the custom handler for listing available models. + * + * @return the custom model list handler, or {@code null} if not set + */ + public Supplier>> getOnListModels() { + return onListModels; + } + + /** + * Sets a custom handler for listing available models. + *

+ * When provided, {@link com.github.copilot.sdk.CopilotClient#listModels()} + * calls this handler instead of querying the CLI server. Useful in BYOK mode to + * return models available from your custom provider. + *

+ * The handler is called at most once per client lifetime; results are cached + * after the first call. + * + * @param onListModels + * the handler that returns a list of available models + * @return this options instance for method chaining + */ + public CopilotClientOptions setOnListModels(Supplier>> onListModels) { + this.onListModels = onListModels; + return this; + } + /** * Creates a shallow clone of this {@code CopilotClientOptions} instance. *

@@ -374,6 +406,7 @@ public CopilotClientOptions clone() { copy.environment = this.environment != null ? new java.util.HashMap<>(this.environment) : null; copy.gitHubToken = this.gitHubToken; copy.useLoggedInUser = this.useLoggedInUser; + copy.onListModels = this.onListModels; return copy; } } diff --git a/src/main/java/com/github/copilot/sdk/json/CreateSessionRequest.java b/src/main/java/com/github/copilot/sdk/json/CreateSessionRequest.java index d73d82e6a..2968fbbdc 100644 --- a/src/main/java/com/github/copilot/sdk/json/CreateSessionRequest.java +++ b/src/main/java/com/github/copilot/sdk/json/CreateSessionRequest.java @@ -76,6 +76,9 @@ public final class CreateSessionRequest { @JsonProperty("customAgents") private List customAgents; + @JsonProperty("agent") + private String agent; + @JsonProperty("infiniteSessions") private InfiniteSessionConfig infiniteSessions; @@ -260,6 +263,16 @@ public void setCustomAgents(List customAgents) { this.customAgents = customAgents; } + /** Gets the agent name to pre-select. @return the agent name */ + public String getAgent() { + return agent; + } + + /** Sets the agent name to pre-select. @param agent the agent name */ + public void setAgent(String agent) { + this.agent = agent; + } + /** Gets infinite sessions config. @return the config */ public InfiniteSessionConfig getInfiniteSessions() { return infiniteSessions; diff --git a/src/main/java/com/github/copilot/sdk/json/ResumeSessionConfig.java b/src/main/java/com/github/copilot/sdk/json/ResumeSessionConfig.java index 0682699bc..f2be82657 100644 --- a/src/main/java/com/github/copilot/sdk/json/ResumeSessionConfig.java +++ b/src/main/java/com/github/copilot/sdk/json/ResumeSessionConfig.java @@ -50,6 +50,7 @@ public class ResumeSessionConfig { private boolean streaming; private Map mcpServers; private List customAgents; + private String agent; private List skillDirectories; private List disabledSkills; private InfiniteSessionConfig infiniteSessions; @@ -436,6 +437,30 @@ public ResumeSessionConfig setCustomAgents(List customAgents) return this; } + /** + * Gets the name of the custom agent to activate when the session starts. + * + * @return the agent name, or {@code null} if not set + */ + public String getAgent() { + return agent; + } + + /** + * Sets the name of the custom agent to activate when the session starts. + *

+ * Must match the name of one of the agents in the {@code customAgents} list. + * + * @param agent + * the agent name to pre-select + * @return this config for method chaining + * @see #setCustomAgents(List) + */ + public ResumeSessionConfig setAgent(String agent) { + this.agent = agent; + return this; + } + /** * Gets the skill directories. * @@ -532,6 +557,7 @@ public ResumeSessionConfig clone() { copy.streaming = this.streaming; copy.mcpServers = this.mcpServers != null ? new java.util.HashMap<>(this.mcpServers) : null; copy.customAgents = this.customAgents != null ? new ArrayList<>(this.customAgents) : null; + copy.agent = this.agent; copy.skillDirectories = this.skillDirectories != null ? new ArrayList<>(this.skillDirectories) : null; copy.disabledSkills = this.disabledSkills != null ? new ArrayList<>(this.disabledSkills) : null; copy.infiniteSessions = this.infiniteSessions; diff --git a/src/main/java/com/github/copilot/sdk/json/ResumeSessionRequest.java b/src/main/java/com/github/copilot/sdk/json/ResumeSessionRequest.java index 4216e5eef..9bc9c633c 100644 --- a/src/main/java/com/github/copilot/sdk/json/ResumeSessionRequest.java +++ b/src/main/java/com/github/copilot/sdk/json/ResumeSessionRequest.java @@ -83,6 +83,9 @@ public final class ResumeSessionRequest { @JsonProperty("customAgents") private List customAgents; + @JsonProperty("agent") + private String agent; + @JsonProperty("skillDirectories") private List skillDirectories; @@ -287,6 +290,16 @@ public void setCustomAgents(List customAgents) { this.customAgents = customAgents; } + /** Gets the agent name to pre-select. @return the agent name */ + public String getAgent() { + return agent; + } + + /** Sets the agent name to pre-select. @param agent the agent name */ + public void setAgent(String agent) { + this.agent = agent; + } + /** Gets skill directories. @return the directories */ public List getSkillDirectories() { return skillDirectories == null ? null : Collections.unmodifiableList(skillDirectories); diff --git a/src/main/java/com/github/copilot/sdk/json/SessionConfig.java b/src/main/java/com/github/copilot/sdk/json/SessionConfig.java index bfed0608e..1cd85865f 100644 --- a/src/main/java/com/github/copilot/sdk/json/SessionConfig.java +++ b/src/main/java/com/github/copilot/sdk/json/SessionConfig.java @@ -49,6 +49,7 @@ public class SessionConfig { private boolean streaming; private Map mcpServers; private List customAgents; + private String agent; private InfiniteSessionConfig infiniteSessions; private List skillDirectories; private List disabledSkills; @@ -438,6 +439,30 @@ public SessionConfig setCustomAgents(List customAgents) { return this; } + /** + * Gets the name of the custom agent to activate when the session starts. + * + * @return the agent name, or {@code null} if not set + */ + public String getAgent() { + return agent; + } + + /** + * Sets the name of the custom agent to activate when the session starts. + *

+ * Must match the name of one of the agents in the {@code customAgents} list. + * + * @param agent + * the agent name to pre-select + * @return this config instance for method chaining + * @see #setCustomAgents(List) + */ + public SessionConfig setAgent(String agent) { + this.agent = agent; + return this; + } + /** * Gets the infinite sessions configuration. * @@ -568,6 +593,7 @@ public SessionConfig clone() { copy.streaming = this.streaming; copy.mcpServers = this.mcpServers != null ? new java.util.HashMap<>(this.mcpServers) : null; copy.customAgents = this.customAgents != null ? new ArrayList<>(this.customAgents) : null; + copy.agent = this.agent; copy.infiniteSessions = this.infiniteSessions; copy.skillDirectories = this.skillDirectories != null ? new ArrayList<>(this.skillDirectories) : null; copy.disabledSkills = this.disabledSkills != null ? new ArrayList<>(this.disabledSkills) : null; diff --git a/src/main/java/com/github/copilot/sdk/json/SessionLogRequestLevel.java b/src/main/java/com/github/copilot/sdk/json/SessionLogRequestLevel.java new file mode 100644 index 000000000..9d1f81ecc --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/json/SessionLogRequestLevel.java @@ -0,0 +1,26 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk.json; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Log severity level for session log messages. + * + * @see com.github.copilot.sdk.CopilotSession#log(String, + * SessionLogRequestLevel, Boolean) + * @since 1.0.0 + */ +public enum SessionLogRequestLevel { + + @JsonProperty("info") + INFO, + + @JsonProperty("warning") + WARNING, + + @JsonProperty("error") + ERROR +} diff --git a/src/site/markdown/advanced.md b/src/site/markdown/advanced.md index dc57f6258..93f8ade6f 100644 --- a/src/site/markdown/advanced.md +++ b/src/site/markdown/advanced.md @@ -17,9 +17,11 @@ This guide covers advanced scenarios for extending and customizing your Copilot - [Infinite Sessions](#Infinite_Sessions) - [Manual Compaction](#Manual_Compaction) - [Compaction Events](#Compaction_Events) +- [Session Logging](#Session_Logging) - [MCP Servers](#MCP_Servers) - [Custom Agents](#Custom_Agents) - [Programmatic Agent Selection](#Programmatic_Agent_Selection) + - [Pre-selecting an Agent at Session Start](#Pre-selecting_an_Agent_at_Session_Start) - [Skills Configuration](#Skills_Configuration) - [Loading Skills](#Loading_Skills) - [Disabling Skills](#Disabling_Skills) @@ -298,6 +300,24 @@ You must use an API key or static bearer token that you manage yourself. **Why not Entra ID?** While Entra ID does issue bearer tokens, these tokens are short-lived (typically 1 hour) and require automatic refresh via the Azure Identity SDK. The `bearerToken` option only accepts a static string—there is no callback mechanism for the SDK to request fresh tokens. For long-running workloads requiring Entra authentication, you would need to implement your own token refresh logic and create new sessions with updated tokens. +#### Custom Model List + +By default, `listModels()` queries the CLI server. When using a custom provider, the CLI may not know which models are available. Use `setOnListModels()` to provide your own model list without requiring the CLI server to be started: + +```java +var options = new CopilotClientOptions() + .setOnListModels(() -> CompletableFuture.completedFuture(List.of( + new ModelInfo().setId("my-model").setName("My Custom Model") + ))); + +try (var client = new CopilotClient(options)) { + // No client.start() needed — the custom handler is called directly + var models = client.listModels().get(); +} +``` + +Results are cached after the first call. + --- ## Infinite Sessions @@ -350,6 +370,26 @@ new InfiniteSessionConfig().setEnabled(false) --- +## Session Logging + +Log messages to the session timeline to make your application's activity visible alongside the conversation. Log entries appear in the session event stream and are persisted to disk by default. + +```java +// Log at the default info level +session.log("Build started").get(); + +// Log at a specific severity level +session.log("Disk space low", SessionLogRequestLevel.WARNING, null).get(); +session.log("Connection failed", SessionLogRequestLevel.ERROR, null).get(); + +// Log an ephemeral message (visible in the stream, but not persisted to disk) +session.log("Processing step 3/10...", null, true).get(); +``` + +Log entries are reflected back as session events (`session.info`, `session.warning`, or `session.error` with `infoType`/`warningType`/`errorType` equal to `"notification"`). + +--- + ## MCP Servers Extend the AI with external tools via the Model Context Protocol. @@ -447,6 +487,20 @@ System.out.println("Selected: " + selected.name()); session.deselectAgent().get(); // Return to the default agent ``` +### Pre-selecting an Agent at Session Start + +Use `setAgent()` to activate a custom agent immediately when the session starts, without requiring a follow-up `selectAgent()` call: + +```java +var session = client.createSession( + new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL) + .setCustomAgents(List.of(reviewer)) + .setAgent("reviewer") // Activate this agent immediately +).get(); +``` + +The `agent` value must match the `name` of one of the agents in `customAgents`. + --- ## Skills Configuration diff --git a/src/test/java/com/github/copilot/sdk/ConfigCloneTest.java b/src/test/java/com/github/copilot/sdk/ConfigCloneTest.java index e1269a669..d04c50f22 100644 --- a/src/test/java/com/github/copilot/sdk/ConfigCloneTest.java +++ b/src/test/java/com/github/copilot/sdk/ConfigCloneTest.java @@ -140,4 +140,36 @@ void clonePreservesNullFields() { MessageOptions msgClone = msg.clone(); assertNull(msgClone.getMode()); } + + @Test + void sessionConfigAgentCopiedByClone() { + SessionConfig original = new SessionConfig(); + original.setAgent("custom-agent"); + + SessionConfig cloned = original.clone(); + + assertEquals("custom-agent", cloned.getAgent()); + } + + @Test + void resumeSessionConfigAgentCopiedByClone() { + ResumeSessionConfig original = new ResumeSessionConfig(); + original.setAgent("resume-agent"); + + ResumeSessionConfig cloned = original.clone(); + + assertEquals("resume-agent", cloned.getAgent()); + } + + @Test + void copilotClientOptionsOnListModelsCopiedByClone() { + java.util.function.Supplier>> handler = () -> java.util.concurrent.CompletableFuture + .completedFuture(java.util.List.of()); + CopilotClientOptions original = new CopilotClientOptions(); + original.setOnListModels(handler); + + CopilotClientOptions cloned = original.clone(); + + assertSame(handler, cloned.getOnListModels()); + } } diff --git a/src/test/java/com/github/copilot/sdk/MetadataApiTest.java b/src/test/java/com/github/copilot/sdk/MetadataApiTest.java index b3eb8fcb7..d7496eff3 100644 --- a/src/test/java/com/github/copilot/sdk/MetadataApiTest.java +++ b/src/test/java/com/github/copilot/sdk/MetadataApiTest.java @@ -324,10 +324,57 @@ void testListModels() throws Exception { } } + // ===== onListModels Tests ===== + + @Test + void testListModelsWithCustomHandler() throws Exception { + var customModels = List.of(new ModelInfo()); + var callCount = new int[]{0}; + + var options = new CopilotClientOptions().setOnListModels(() -> { + callCount[0]++; + return java.util.concurrent.CompletableFuture.completedFuture(customModels); + }); + + try (var client = new CopilotClient(options)) { + var models = client.listModels().get(); + assertEquals(1, callCount[0]); + assertEquals(1, models.size()); + } + } + + @Test + void testListModelsWithCustomHandlerCachesResults() throws Exception { + var callCount = new int[]{0}; + var options = new CopilotClientOptions().setOnListModels(() -> { + callCount[0]++; + return java.util.concurrent.CompletableFuture.completedFuture(List.of(new ModelInfo())); + }); + + try (var client = new CopilotClient(options)) { + client.listModels().get(); + client.listModels().get(); + assertEquals(1, callCount[0], "Handler should only be called once due to caching"); + } + } + + @Test + void testListModelsWithCustomHandlerDoesNotRequireStart() throws Exception { + var options = new CopilotClientOptions() + .setOnListModels(() -> java.util.concurrent.CompletableFuture.completedFuture(List.of())); + + // Intentionally no client.start() — onListModels should work without a server + try (var client = new CopilotClient(options)) { + var models = client.listModels().get(); + assertNotNull(models); + assertEquals(0, models.size()); + } + } + // ===== Protocol Version Test ===== @Test - void testProtocolVersionIsTwo() { - assertEquals(2, SdkProtocolVersion.get()); + void testProtocolVersionIsThree() { + assertEquals(3, SdkProtocolVersion.get()); } } diff --git a/src/test/java/com/github/copilot/sdk/SessionRequestBuilderTest.java b/src/test/java/com/github/copilot/sdk/SessionRequestBuilderTest.java index 7e9d5ee69..298bdaa46 100644 --- a/src/test/java/com/github/copilot/sdk/SessionRequestBuilderTest.java +++ b/src/test/java/com/github/copilot/sdk/SessionRequestBuilderTest.java @@ -237,4 +237,44 @@ private CopilotSession createTestSession() throws Exception { constructor.setAccessible(true); return constructor.newInstance("builder-test-session", null, null); } + + // ========================================================================= + // agent field tests + // ========================================================================= + + @Test + void testBuildCreateRequestWithAgent() { + var config = new SessionConfig().setAgent("my-agent"); + + CreateSessionRequest request = SessionRequestBuilder.buildCreateRequest(config); + + assertEquals("my-agent", request.getAgent()); + } + + @Test + void testBuildCreateRequestAgentNullByDefault() { + var config = new SessionConfig(); + + CreateSessionRequest request = SessionRequestBuilder.buildCreateRequest(config); + + assertNull(request.getAgent()); + } + + @Test + void testBuildResumeRequestWithAgent() { + var config = new ResumeSessionConfig().setAgent("resume-agent"); + + ResumeSessionRequest request = SessionRequestBuilder.buildResumeRequest("sess-123", config); + + assertEquals("resume-agent", request.getAgent()); + } + + @Test + void testBuildResumeRequestAgentNullByDefault() { + var config = new ResumeSessionConfig(); + + ResumeSessionRequest request = SessionRequestBuilder.buildResumeRequest("sess-456", config); + + assertNull(request.getAgent()); + } }