diff --git a/.lastmerge b/.lastmerge index df5b85477..fd2e8b676 100644 --- a/.lastmerge +++ b/.lastmerge @@ -1 +1 @@ -4e1499dd23709022c720eaaa5457d00bf0cb3977 +9a0a1a5f21111f4ad02b5ce911750ecc75e054c3 diff --git a/CHANGELOG.md b/CHANGELOG.md index 4845a214e..b95104175 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,22 @@ 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@9a0a1a5`](https://github.com/github/copilot-sdk/commit/9a0a1a5f21111f4ad02b5ce911750ecc75e054c3) + +### Added + +- `CopilotClientOptions.setOnListModels(Supplier>>)` — custom handler for `listModels()` used in BYOK mode to return models from a custom provider instead of querying the CLI (upstream: [`e478657`](https://github.com/github/copilot-sdk/commit/e478657)) +- `SessionConfig.setAgent(String)` — pre-selects a custom agent by name when creating a session (upstream: [`7766b1a`](https://github.com/github/copilot-sdk/commit/7766b1a)) +- `ResumeSessionConfig.setAgent(String)` — pre-selects a custom agent by name when resuming a session (upstream: [`7766b1a`](https://github.com/github/copilot-sdk/commit/7766b1a)) +- `SessionConfig.setOnEvent(Consumer)` — registers an event handler before the `session.create` RPC is issued, ensuring no early events are missed (upstream: [`4125fe7`](https://github.com/github/copilot-sdk/commit/4125fe7)) +- `ResumeSessionConfig.setOnEvent(Consumer)` — registers an event handler before the `session.resume` RPC is issued (upstream: [`4125fe7`](https://github.com/github/copilot-sdk/commit/4125fe7)) +- New broadcast session event types (protocol v3): `ExternalToolRequestedEvent` (`external_tool.requested`), `ExternalToolCompletedEvent` (`external_tool.completed`), `PermissionRequestedEvent` (`permission.requested`), `PermissionCompletedEvent` (`permission.completed`), `CommandQueuedEvent` (`command.queued`), `CommandCompletedEvent` (`command.completed`), `ExitPlanModeRequestedEvent` (`exit_plan_mode.requested`), `ExitPlanModeCompletedEvent` (`exit_plan_mode.completed`), `SystemNotificationEvent` (`system.notification`) (upstream: [`1653812`](https://github.com/github/copilot-sdk/commit/1653812), [`396e8b3`](https://github.com/github/copilot-sdk/commit/396e8b3)) +- `CopilotSession.log(String)` and `CopilotSession.log(String, String, Boolean)` — log a message to the session timeline (upstream: [`4125fe7`](https://github.com/github/copilot-sdk/commit/4125fe7)) + +### Changed + +- **Protocol version bumped to v3.** The SDK now supports CLI servers running v2 or v3 (backward-compatible range). Sessions are now registered in the client's session map *before* the `session.create`/`session.resume` RPC is issued, ensuring broadcast events emitted immediately on session start are never dropped (upstream: [`4125fe7`](https://github.com/github/copilot-sdk/commit/4125fe7), [`1653812`](https://github.com/github/copilot-sdk/commit/1653812)) +- In protocol v3, tool calls and permission requests that have a registered handler are now handled automatically via `ExternalToolRequestedEvent` and `PermissionRequestedEvent` broadcast events; results are sent back via `session.tools.handlePendingToolCall` and `session.permissions.handlePendingPermissionRequest` RPC calls (upstream: [`1653812`](https://github.com/github/copilot-sdk/commit/1653812)) ## [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..39034c910 100644 --- a/src/main/java/com/github/copilot/sdk/CopilotClient.java +++ b/src/main/java/com/github/copilot/sdk/CopilotClient.java @@ -188,6 +188,8 @@ private CompletableFuture startCore() { }); } + private static final int MIN_PROTOCOL_VERSION = 2; + private void verifyProtocolVersion(Connection connection) throws Exception { int expectedVersion = SdkProtocolVersion.get(); var params = new HashMap(); @@ -200,9 +202,10 @@ private void verifyProtocolVersion(Connection connection) throws Exception { + "Please update your server to ensure compatibility."); } - if (pingResponse.protocolVersion() != expectedVersion) { + int serverVersion = pingResponse.protocolVersion(); + if (serverVersion < MIN_PROTOCOL_VERSION || serverVersion > expectedVersion) { throw new RuntimeException("SDK protocol version mismatch: SDK expects version " + expectedVersion - + ", but server reports version " + pingResponse.protocolVersion() + ". " + + " (minimum " + MIN_PROTOCOL_VERSION + "), but server reports version " + serverVersion + ". " + "Please update your SDK or server to ensure compatibility."); } } @@ -319,13 +322,32 @@ public CompletableFuture createSession(SessionConfig config) { + "new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL)")); } return ensureConnected().thenCompose(connection -> { - var request = SessionRequestBuilder.buildCreateRequest(config); + // Pre-generate session ID so the session can be registered before the RPC call, + // ensuring no events emitted by the CLI during creation are lost. + String sessionId = config.getSessionId() != null + ? config.getSessionId() + : java.util.UUID.randomUUID().toString(); + + var session = new CopilotSession(sessionId, connection.rpc); + SessionRequestBuilder.configureSession(session, config); + sessions.put(sessionId, session); + + var request = SessionRequestBuilder.buildCreateRequest(config, sessionId); return connection.rpc.invoke("session.create", request, CreateSessionResponse.class).thenApply(response -> { - var session = new CopilotSession(response.sessionId(), connection.rpc, response.workspacePath()); - SessionRequestBuilder.configureSession(session, config); - sessions.put(response.sessionId(), session); + session.setWorkspacePath(response.workspacePath()); + // If the server returned a different sessionId (e.g. a v2 CLI that ignores + // the client-supplied ID), re-key the sessions map. + String returnedId = response.sessionId(); + if (returnedId != null && !returnedId.equals(sessionId)) { + sessions.remove(sessionId); + session.setActiveSessionId(returnedId); + sessions.put(returnedId, session); + } return session; + }).exceptionally(ex -> { + sessions.remove(sessionId); + throw ex instanceof RuntimeException re ? re : new RuntimeException(ex); }); }); } @@ -363,13 +385,26 @@ public CompletableFuture resumeSession(String sessionId, ResumeS + "new ResumeSessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL)")); } return ensureConnected().thenCompose(connection -> { + // Register the session before the RPC call to avoid missing early events. + var session = new CopilotSession(sessionId, connection.rpc); + SessionRequestBuilder.configureSession(session, config); + sessions.put(sessionId, session); + var request = SessionRequestBuilder.buildResumeRequest(sessionId, config); return connection.rpc.invoke("session.resume", request, ResumeSessionResponse.class).thenApply(response -> { - var session = new CopilotSession(response.sessionId(), connection.rpc, response.workspacePath()); - SessionRequestBuilder.configureSession(session, config); - sessions.put(response.sessionId(), session); + session.setWorkspacePath(response.workspacePath()); + // If the server returned a different sessionId than what was requested, re-key. + String returnedId = response.sessionId(); + if (returnedId != null && !returnedId.equals(sessionId)) { + sessions.remove(sessionId); + session.setActiveSessionId(returnedId); + sessions.put(returnedId, session); + } return session; + }).exceptionally(ex -> { + sessions.remove(sessionId); + throw ex instanceof RuntimeException re ? re : new RuntimeException(ex); }); }); } @@ -434,6 +469,10 @@ 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 in + * {@link com.github.copilot.sdk.json.CopilotClientOptions}, it is called + * instead of querying the CLI server. This is useful in BYOK mode. * * @return a future that resolves with a list of available models * @see ModelInfo @@ -445,6 +484,22 @@ public CompletableFuture> listModels() { return CompletableFuture.completedFuture(new ArrayList<>(cached)); } + // If a custom handler is configured, use it instead of querying the CLI server + var onListModels = options.getOnListModels(); + if (onListModels != null) { + synchronized (modelsCacheLock) { + if (modelsCache != null) { + return CompletableFuture.completedFuture(new ArrayList<>(modelsCache)); + } + } + return onListModels.get().thenApply(models -> { + synchronized (modelsCacheLock) { + modelsCache = 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..452e82671 100644 --- a/src/main/java/com/github/copilot/sdk/CopilotSession.java +++ b/src/main/java/com/github/copilot/sdk/CopilotSession.java @@ -27,6 +27,8 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.github.copilot.sdk.events.AbstractSessionEvent; import com.github.copilot.sdk.events.AssistantMessageEvent; +import com.github.copilot.sdk.events.ExternalToolRequestedEvent; +import com.github.copilot.sdk.events.PermissionRequestedEvent; import com.github.copilot.sdk.events.SessionErrorEvent; import com.github.copilot.sdk.events.SessionEventParser; import com.github.copilot.sdk.events.SessionIdleEvent; @@ -38,6 +40,7 @@ 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; @@ -46,6 +49,7 @@ import com.github.copilot.sdk.json.SessionHooks; import com.github.copilot.sdk.json.SessionStartHookInput; import com.github.copilot.sdk.json.ToolDefinition; +import com.github.copilot.sdk.json.ToolResultObject; import com.github.copilot.sdk.json.UserInputHandler; import com.github.copilot.sdk.json.UserInputInvocation; import com.github.copilot.sdk.json.UserInputRequest; @@ -100,8 +104,14 @@ public final class CopilotSession implements AutoCloseable { private static final Logger LOG = Logger.getLogger(CopilotSession.class.getName()); private static final ObjectMapper MAPPER = JsonRpcClient.getObjectMapper(); - private final String sessionId; - private final String workspacePath; + /** + * The current active session ID. Initialized to the pre-generated value and may + * be updated after session.create / session.resume if the server returns a + * different ID (e.g. when working against a v2 CLI that ignores the + * client-supplied sessionId). + */ + private volatile String sessionId; + private volatile String workspacePath; private final JsonRpcClient rpc; private final Set> eventHandlers = ConcurrentHashMap.newKeySet(); private final Map toolHandlers = new ConcurrentHashMap<>(); @@ -157,6 +167,18 @@ public String getSessionId() { return sessionId; } + /** + * Updates the active session ID. Package-private; called by CopilotClient if + * the server returns a different session ID than the pre-generated one (e.g. + * when a v2 CLI ignores the client-supplied sessionId). + * + * @param sessionId + * the server-confirmed session ID + */ + void setActiveSessionId(String sessionId) { + this.sessionId = sessionId; + } + /** * Gets the path to the session workspace directory when infinite sessions are * enabled. @@ -170,6 +192,17 @@ public String getWorkspacePath() { return workspacePath; } + /** + * Sets the workspace path. Package-private; called by CopilotClient after + * session.create or session.resume RPC response. + * + * @param workspacePath + * the workspace path + */ + void setWorkspacePath(String workspacePath) { + this.workspacePath = workspacePath; + } + /** * Sets a custom error handler for exceptions thrown by event handlers. *

@@ -551,6 +584,10 @@ public Closeable on(Class eventType, Consume * @see #setEventErrorPolicy(EventErrorPolicy) */ void dispatchEvent(AbstractSessionEvent event) { + // Handle broadcast request events (protocol v3) before dispatching to user + // handlers. These are fire-and-forget: the response is sent asynchronously. + handleBroadcastEventAsync(event); + for (Consumer handler : eventHandlers) { try { handler.accept(event); @@ -572,6 +609,136 @@ void dispatchEvent(AbstractSessionEvent event) { } } + /** + * Handles broadcast request events by executing local handlers and responding + * via RPC (protocol v3). + *

+ * Fire-and-forget: the response is sent asynchronously. + * + * @param event + * the event to handle + */ + private void handleBroadcastEventAsync(AbstractSessionEvent event) { + if (event instanceof ExternalToolRequestedEvent toolEvent) { + var data = toolEvent.getData(); + if (data == null || data.requestId() == null || data.toolName() == null) { + return; + } + ToolDefinition tool = getTool(data.toolName()); + if (tool == null) { + return; // This client doesn't handle this tool; another client will + } + executeToolAndRespondAsync(data.requestId(), data.toolName(), data.toolCallId(), data.arguments(), 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 + } + executePermissionAndRespondAsync(data.requestId(), data.permissionRequest(), handler); + } + } + + /** + * Executes a tool handler and sends the result back via + * {@code session.tools.handlePendingToolCall}. + */ + private void executeToolAndRespondAsync(String requestId, String toolName, String toolCallId, Object arguments, + ToolDefinition tool) { + CompletableFuture.runAsync(() -> { + try { + JsonNode argumentsNode = arguments instanceof JsonNode jn + ? jn + : (arguments != null ? MAPPER.valueToTree(arguments) : null); + var invocation = new com.github.copilot.sdk.json.ToolInvocation().setSessionId(sessionId) + .setToolCallId(toolCallId).setToolName(toolName).setArguments(argumentsNode); + + tool.handler().invoke(invocation).thenAccept(result -> { + try { + ToolResultObject toolResult; + if (result instanceof ToolResultObject tr) { + toolResult = tr; + } else { + toolResult = ToolResultObject + .success(result instanceof String s ? s : MAPPER.writeValueAsString(result)); + } + rpc.invoke("session.tools.handlePendingToolCall", + Map.of("sessionId", sessionId, "requestId", requestId, "result", toolResult), + Object.class); + } catch (Exception e) { + LOG.log(Level.WARNING, "Error sending tool result for requestId=" + requestId, e); + } + }).exceptionally(ex -> { + try { + rpc.invoke( + "session.tools.handlePendingToolCall", Map.of("sessionId", sessionId, "requestId", + requestId, "error", ex.getMessage() != null ? ex.getMessage() : ex.toString()), + Object.class); + } catch (Exception e) { + LOG.log(Level.WARNING, "Error sending tool error for requestId=" + requestId, e); + } + return null; + }); + } catch (Exception e) { + LOG.log(Level.WARNING, "Error executing tool for requestId=" + requestId, e); + try { + rpc.invoke( + "session.tools.handlePendingToolCall", Map.of("sessionId", sessionId, "requestId", + requestId, "error", e.getMessage() != null ? e.getMessage() : e.toString()), + Object.class); + } catch (Exception sendEx) { + LOG.log(Level.WARNING, "Error sending tool error for requestId=" + requestId, sendEx); + } + } + }); + } + + /** + * Executes a permission handler and sends the result back via + * {@code session.permissions.handlePendingPermissionRequest}. + */ + private void executePermissionAndRespondAsync(String requestId, PermissionRequest permissionRequest, + PermissionHandler handler) { + CompletableFuture.runAsync(() -> { + try { + var invocation = new PermissionInvocation(); + invocation.setSessionId(sessionId); + handler.handle(permissionRequest, invocation).thenAccept(result -> { + try { + rpc.invoke("session.permissions.handlePendingPermissionRequest", + Map.of("sessionId", sessionId, "requestId", requestId, "result", result), Object.class); + } catch (Exception e) { + LOG.log(Level.WARNING, "Error sending permission result for requestId=" + requestId, e); + } + }).exceptionally(ex -> { + try { + PermissionRequestResult denied = new PermissionRequestResult(); + denied.setKind(PermissionRequestResultKind.DENIED_COULD_NOT_REQUEST_FROM_USER); + rpc.invoke("session.permissions.handlePendingPermissionRequest", + Map.of("sessionId", sessionId, "requestId", requestId, "result", denied), Object.class); + } catch (Exception e) { + LOG.log(Level.WARNING, "Error sending permission denied for requestId=" + requestId, e); + } + return null; + }); + } catch (Exception e) { + LOG.log(Level.WARNING, "Error executing permission handler for requestId=" + requestId, e); + try { + PermissionRequestResult denied = new PermissionRequestResult(); + denied.setKind(PermissionRequestResultKind.DENIED_COULD_NOT_REQUEST_FROM_USER); + rpc.invoke("session.permissions.handlePendingPermissionRequest", + Map.of("sessionId", sessionId, "requestId", requestId, "result", denied), Object.class); + } catch (Exception sendEx) { + LOG.log(Level.WARNING, "Error sending permission denied for requestId=" + requestId, sendEx); + } + } + }); + } + /** * Registers custom tool handlers for this session. *

@@ -837,6 +1004,61 @@ public CompletableFuture setModel(String model) { return rpc.invoke("session.model.switchTo", Map.of("sessionId", sessionId, "modelId", model), 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. + * + *

Example Usage

+ * + *
{@code
+     * session.log("Build completed successfully").get();
+     * session.log("Disk space low", "warning", null).get();
+     * session.log("Temporary status", null, true).get();
+     * }
+ * + * @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 log(String message, String 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); + } + if (ephemeral != null) { + params.put("ephemeral", ephemeral); + } + return rpc.invoke("session.log", params, Void.class); + } + + /** + * Logs an informational message to the session timeline. + * + * @param message + * the message to log + * @return a future that completes when the message is logged + * @throws IllegalStateException + * if this session has been terminated + */ + public CompletableFuture log(String message) { + return log(message, null, null); + } + /** * Lists the custom agents available for selection in this session. * 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..7ea0be880 100644 --- a/src/main/java/com/github/copilot/sdk/SessionRequestBuilder.java +++ b/src/main/java/com/github/copilot/sdk/SessionRequestBuilder.java @@ -27,20 +27,22 @@ private SessionRequestBuilder() { * * @param config * the session configuration (may be null) + * @param sessionId + * the pre-generated session ID to use * @return the built request object */ - static CreateSessionRequest buildCreateRequest(SessionConfig config) { + static CreateSessionRequest buildCreateRequest(SessionConfig config, String sessionId) { var request = new CreateSessionRequest(); // Always request permission callbacks to enable deny-by-default behavior request.setRequestPermission(true); // Always send envValueMode=direct for MCP servers request.setEnvValueMode("direct"); + request.setSessionId(sessionId); if (config == null) { return request; } request.setModel(config.getModel()); - request.setSessionId(config.getSessionId()); request.setClientName(config.getClientName()); request.setReasoningEffort(config.getReasoningEffort()); request.setTools(config.getTools()); @@ -54,6 +56,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()); @@ -62,6 +65,22 @@ static CreateSessionRequest buildCreateRequest(SessionConfig config) { return request; } + /** + * Builds a CreateSessionRequest from the given configuration. + * + * @param config + * the session configuration (may be null) + * @return the built request object + * @deprecated Use {@link #buildCreateRequest(SessionConfig, String)} instead. + */ + @Deprecated + static CreateSessionRequest buildCreateRequest(SessionConfig config) { + String sessionId = (config != null && config.getSessionId() != null) + ? config.getSessionId() + : java.util.UUID.randomUUID().toString(); + return buildCreateRequest(config, sessionId); + } + /** * Builds a ResumeSessionRequest from the given session ID and configuration. * @@ -99,6 +118,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()); @@ -131,6 +151,9 @@ static void configureSession(CopilotSession session, SessionConfig config) { if (config.getHooks() != null) { session.registerHooks(config.getHooks()); } + if (config.getOnEvent() != null) { + session.on(config.getOnEvent()); + } } /** @@ -158,5 +181,8 @@ static void configureSession(CopilotSession session, ResumeSessionConfig config) if (config.getHooks() != null) { session.registerHooks(config.getHooks()); } + if (config.getOnEvent() != null) { + session.on(config.getOnEvent()); + } } } 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..5127f6eee 100644 --- a/src/main/java/com/github/copilot/sdk/events/AbstractSessionEvent.java +++ b/src/main/java/com/github/copilot/sdk/events/AbstractSessionEvent.java @@ -63,6 +63,10 @@ public abstract sealed class AbstractSessionEvent permits // Tool events ToolUserRequestedEvent, ToolExecutionStartEvent, ToolExecutionPartialResultEvent, ToolExecutionProgressEvent, ToolExecutionCompleteEvent, + // Broadcast request/completion events (protocol v3) + ExternalToolRequestedEvent, ExternalToolCompletedEvent, PermissionRequestedEvent, PermissionCompletedEvent, + CommandQueuedEvent, CommandCompletedEvent, ExitPlanModeRequestedEvent, ExitPlanModeCompletedEvent, + SystemNotificationEvent, // User events UserMessageEvent, PendingMessagesModifiedEvent, // Skill events 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..f9aeb0f3f --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/events/CommandCompletedEvent.java @@ -0,0 +1,37 @@ +/*--------------------------------------------------------------------------------------------- + * 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 + * + * @since 1.0.0 + */ +@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..acd35a89c --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/events/CommandQueuedEvent.java @@ -0,0 +1,38 @@ +/*--------------------------------------------------------------------------------------------- + * 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 + * + * @since 1.0.0 + */ +@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..217859e43 --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/events/ExitPlanModeCompletedEvent.java @@ -0,0 +1,37 @@ +/*--------------------------------------------------------------------------------------------- + * 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 + * + * @since 1.0.0 + */ +@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..b0019c3ce --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/events/ExitPlanModeRequestedEvent.java @@ -0,0 +1,39 @@ +/*--------------------------------------------------------------------------------------------- + * 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 + * + * @since 1.0.0 + */ +@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..83a582720 --- /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 + *

+ * Broadcast when a pending tool call has been resolved by a client (protocol + * v3). + * + * @since 1.0.0 + */ +@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..8eb11f5b8 --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/events/ExternalToolRequestedEvent.java @@ -0,0 +1,43 @@ +/*--------------------------------------------------------------------------------------------- + * 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.requested + *

+ * Broadcast when the CLI needs a client to handle a tool call (protocol v3). + * Clients that own the named tool should respond via + * {@code session.tools.handlePendingToolCall}. + * + * @since 1.0.0 + */ +@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 record ExternalToolRequestedData(@JsonProperty("requestId") String requestId, + @JsonProperty("sessionId") String sessionId, @JsonProperty("toolCallId") String toolCallId, + @JsonProperty("toolName") String toolName, @JsonProperty("arguments") Object arguments) { + } +} diff --git a/src/main/java/com/github/copilot/sdk/events/PermissionCompletedEvent.java b/src/main/java/com/github/copilot/sdk/events/PermissionCompletedEvent.java new file mode 100644 index 000000000..90daf3b49 --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/events/PermissionCompletedEvent.java @@ -0,0 +1,45 @@ +/*--------------------------------------------------------------------------------------------- + * 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: permission.completed + *

+ * Broadcast when a pending permission request has been resolved by a client + * (protocol v3). + * + * @since 1.0.0 + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class PermissionCompletedEvent extends AbstractSessionEvent { + + @JsonProperty("data") + private PermissionCompletedData data; + + @Override + public String getType() { + return "permission.completed"; + } + + public PermissionCompletedData getData() { + return data; + } + + public void setData(PermissionCompletedData data) { + this.data = data; + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record PermissionCompletedData(@JsonProperty("requestId") String requestId, + @JsonProperty("result") PermissionCompletedResult result) { + + @JsonIgnoreProperties(ignoreUnknown = true) + public record PermissionCompletedResult(@JsonProperty("kind") String kind) { + } + } +} 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..d8f9ec147 --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/events/PermissionRequestedEvent.java @@ -0,0 +1,43 @@ +/*--------------------------------------------------------------------------------------------- + * 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 + *

+ * Broadcast when the CLI needs a client to handle a permission request + * (protocol v3). Clients that have a permission handler should respond via + * {@code session.permissions.handlePendingPermissionRequest}. + * + * @since 1.0.0 + */ +@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..75971b29e 100644 --- a/src/main/java/com/github/copilot/sdk/events/SessionEventParser.java +++ b/src/main/java/com/github/copilot/sdk/events/SessionEventParser.java @@ -93,6 +93,15 @@ public class SessionEventParser { TYPE_MAP.put("system.message", SystemMessageEvent.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("permission.completed", PermissionCompletedEvent.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); + TYPE_MAP.put("system.notification", SystemNotificationEvent.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..38711f276 --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/events/SystemNotificationEvent.java @@ -0,0 +1,37 @@ +/*--------------------------------------------------------------------------------------------- + * 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 + * + * @since 1.0.0 + */ +@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") Object 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..4fd55d3ba 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,31 @@ public CopilotClientOptions setUseLoggedInUser(Boolean useLoggedInUser) { return this; } + /** + * Gets the custom handler for listing available models. + * + * @return the handler, or {@code null} if not set + */ + public Supplier>> getOnListModels() { + return onListModels; + } + + /** + * Sets a custom handler for listing available models. + *

+ * When provided, {@code listModels()} calls this handler instead of querying + * the CLI server. Useful in BYOK (Bring Your Own Key) mode to return models + * available from your custom provider. + * + * @param onListModels + * the handler that returns the 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 +403,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..c0243f14b 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 pre-selected agent name. @return the agent name */ + public String getAgent() { + return agent; + } + + /** Sets the pre-selected agent name. @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..eab3c789c 100644 --- a/src/main/java/com/github/copilot/sdk/json/ResumeSessionConfig.java +++ b/src/main/java/com/github/copilot/sdk/json/ResumeSessionConfig.java @@ -8,9 +8,12 @@ import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.function.Consumer; import com.fasterxml.jackson.annotation.JsonInclude; +import com.github.copilot.sdk.events.AbstractSessionEvent; + /** * Configuration for resuming an existing Copilot session. *

@@ -50,9 +53,11 @@ public class ResumeSessionConfig { private boolean streaming; private Map mcpServers; private List customAgents; + private String agent; private List skillDirectories; private List disabledSkills; private InfiniteSessionConfig infiniteSessions; + private Consumer onEvent; /** * Gets the AI model to use. @@ -436,6 +441,29 @@ public ResumeSessionConfig setCustomAgents(List customAgents) return this; } + /** + * Gets the name of the custom agent to activate at session start. + * + * @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 {@link #setCustomAgents(List)}. + * + * @param agent + * the agent name to pre-select + * @return this config for method chaining + */ + public ResumeSessionConfig setAgent(String agent) { + this.agent = agent; + return this; + } + /** * Gets the skill directories. * @@ -501,6 +529,32 @@ public ResumeSessionConfig setInfiniteSessions(InfiniteSessionConfig infiniteSes return this; } + /** + * Gets the event handler registered before the session.resume RPC is issued. + * + * @return the event handler, or {@code null} if not set + */ + public Consumer getOnEvent() { + return onEvent; + } + + /** + * Sets an event handler that is registered on the session before the + * {@code session.resume} RPC is issued. + *

+ * Equivalent to calling + * {@link com.github.copilot.sdk.CopilotSession#on(Consumer)} immediately after + * resumption, but executes earlier in the lifecycle so no events are missed. + * + * @param onEvent + * the event handler to register before session resumption + * @return this config for method chaining + */ + public ResumeSessionConfig setOnEvent(Consumer onEvent) { + this.onEvent = onEvent; + return this; + } + /** * Creates a shallow clone of this {@code ResumeSessionConfig} instance. *

@@ -532,9 +586,11 @@ 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; + copy.onEvent = this.onEvent; return copy; } } 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..31d88399a 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 pre-selected agent name. @return the agent name */ + public String getAgent() { + return agent; + } + + /** Sets the pre-selected agent name. @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..76c15660d 100644 --- a/src/main/java/com/github/copilot/sdk/json/SessionConfig.java +++ b/src/main/java/com/github/copilot/sdk/json/SessionConfig.java @@ -8,9 +8,12 @@ import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.function.Consumer; import com.fasterxml.jackson.annotation.JsonInclude; +import com.github.copilot.sdk.events.AbstractSessionEvent; + /** * Configuration for creating a new Copilot session. *

@@ -49,10 +52,12 @@ public class SessionConfig { private boolean streaming; private Map mcpServers; private List customAgents; + private String agent; private InfiniteSessionConfig infiniteSessions; private List skillDirectories; private List disabledSkills; private String configDir; + private Consumer onEvent; /** * Gets the custom session ID. @@ -438,6 +443,29 @@ public SessionConfig setCustomAgents(List customAgents) { return this; } + /** + * Gets the name of the custom agent to activate at session start. + * + * @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 {@link #setCustomAgents(List)}. + * + * @param agent + * the agent name to pre-select + * @return this config instance for method chaining + */ + public SessionConfig setAgent(String agent) { + this.agent = agent; + return this; + } + /** * Gets the infinite sessions configuration. * @@ -538,6 +566,35 @@ public SessionConfig setConfigDir(String configDir) { return this; } + /** + * Gets the event handler registered before the session.create RPC is issued. + * + * @return the event handler, or {@code null} if not set + */ + public Consumer getOnEvent() { + return onEvent; + } + + /** + * Sets an event handler that is registered on the session before the + * {@code session.create} RPC is issued. + *

+ * Equivalent to calling + * {@link com.github.copilot.sdk.CopilotSession#on(Consumer)} immediately after + * creation, but executes earlier in the lifecycle so no events are missed. + * Using this property rather than {@code CopilotSession.on()} guarantees that + * early events emitted by the CLI during session creation (e.g. + * {@code session.start}) are delivered to the handler. + * + * @param onEvent + * the event handler to register before session creation + * @return this config instance for method chaining + */ + public SessionConfig setOnEvent(Consumer onEvent) { + this.onEvent = onEvent; + return this; + } + /** * Creates a shallow clone of this {@code SessionConfig} instance. *

@@ -568,10 +625,12 @@ 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; copy.configDir = this.configDir; + copy.onEvent = this.onEvent; return copy; } } diff --git a/src/test/java/com/github/copilot/sdk/ConfigCloneTest.java b/src/test/java/com/github/copilot/sdk/ConfigCloneTest.java index e1269a669..f3eceb4c2 100644 --- a/src/test/java/com/github/copilot/sdk/ConfigCloneTest.java +++ b/src/test/java/com/github/copilot/sdk/ConfigCloneTest.java @@ -10,13 +10,17 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.function.Consumer; import org.junit.jupiter.api.Test; +import com.github.copilot.sdk.events.AbstractSessionEvent; import com.github.copilot.sdk.json.CopilotClientOptions; -import com.github.copilot.sdk.json.SessionConfig; -import com.github.copilot.sdk.json.ResumeSessionConfig; import com.github.copilot.sdk.json.MessageOptions; +import com.github.copilot.sdk.json.ModelInfo; +import com.github.copilot.sdk.json.ResumeSessionConfig; +import com.github.copilot.sdk.json.SessionConfig; class ConfigCloneTest { @@ -68,6 +72,18 @@ void copilotClientOptionsEnvironmentIndependence() { assertEquals(2, original.getEnvironment().size()); } + @Test + void copilotClientOptionsOnListModelsCloned() { + CopilotClientOptions original = new CopilotClientOptions(); + List models = List.of(new ModelInfo()); + original.setOnListModels(() -> CompletableFuture.completedFuture(models)); + + CopilotClientOptions cloned = original.clone(); + + assertNotNull(cloned.getOnListModels()); + assertSame(original.getOnListModels(), cloned.getOnListModels()); + } + @Test void sessionConfigCloneBasic() { SessionConfig original = new SessionConfig(); @@ -102,6 +118,20 @@ void sessionConfigListIndependence() { assertEquals(3, original.getAvailableTools().size()); } + @Test + void sessionConfigAgentAndOnEventCloned() { + Consumer handler = event -> { + }; + SessionConfig original = new SessionConfig(); + original.setAgent("my-agent"); + original.setOnEvent(handler); + + SessionConfig cloned = original.clone(); + + assertEquals("my-agent", cloned.getAgent()); + assertSame(handler, cloned.getOnEvent()); + } + @Test void resumeSessionConfigCloneBasic() { ResumeSessionConfig original = new ResumeSessionConfig(); @@ -114,6 +144,20 @@ void resumeSessionConfigCloneBasic() { assertEquals(original.isStreaming(), cloned.isStreaming()); } + @Test + void resumeSessionConfigAgentAndOnEventCloned() { + Consumer handler = event -> { + }; + ResumeSessionConfig original = new ResumeSessionConfig(); + original.setAgent("my-agent"); + original.setOnEvent(handler); + + ResumeSessionConfig cloned = original.clone(); + + assertEquals("my-agent", cloned.getAgent()); + assertSame(handler, cloned.getOnEvent()); + } + @Test void messageOptionsCloneBasic() { MessageOptions original = new MessageOptions(); diff --git a/src/test/java/com/github/copilot/sdk/CopilotClientTest.java b/src/test/java/com/github/copilot/sdk/CopilotClientTest.java index 1f4f5193b..bc5869fda 100644 --- a/src/test/java/com/github/copilot/sdk/CopilotClientTest.java +++ b/src/test/java/com/github/copilot/sdk/CopilotClientTest.java @@ -494,4 +494,69 @@ void testNullOptionsDefaultsToEmpty() { assertEquals(ConnectionState.DISCONNECTED, client.getState()); } } + + // ===== OnListModels ===== + + @Test + void testListModels_WithCustomHandler_CallsHandler() throws Exception { + var customModels = new ArrayList(); + var model = new com.github.copilot.sdk.json.ModelInfo(); + model.setId("my-custom-model"); + customModels.add(model); + + var callCount = new int[]{0}; + var options = new CopilotClientOptions().setOnListModels(() -> { + callCount[0]++; + return CompletableFuture.completedFuture(new ArrayList<>(customModels)); + }); + + try (var client = new CopilotClient(options)) { + var models = client.listModels().get(); + assertEquals(1, callCount[0]); + assertEquals(1, models.size()); + assertEquals("my-custom-model", models.get(0).getId()); + } + } + + @Test + void testListModels_WithCustomHandler_CachesResults() throws Exception { + var customModels = new ArrayList(); + var model = new com.github.copilot.sdk.json.ModelInfo(); + model.setId("cached-model"); + customModels.add(model); + + var callCount = new int[]{0}; + var options = new CopilotClientOptions().setOnListModels(() -> { + callCount[0]++; + return CompletableFuture.completedFuture(new ArrayList<>(customModels)); + }); + + try (var client = new CopilotClient(options)) { + client.listModels().get(); + client.listModels().get(); + assertEquals(1, callCount[0], "Handler should be called only once due to caching"); + } + } + + @Test + void testListModels_WithCustomHandler_WorksWithoutStart() throws Exception { + var customModels = new ArrayList(); + var model = new com.github.copilot.sdk.json.ModelInfo(); + model.setId("no-start-model"); + customModels.add(model); + + var callCount = new int[]{0}; + var options = new CopilotClientOptions().setOnListModels(() -> { + callCount[0]++; + return CompletableFuture.completedFuture(new ArrayList<>(customModels)); + }); + + // No start() needed when onListModels is provided + try (var client = new CopilotClient(options)) { + var models = client.listModels().get(); + assertEquals(1, callCount[0]); + assertEquals(1, models.size()); + assertEquals("no-start-model", models.get(0).getId()); + } + } } diff --git a/src/test/java/com/github/copilot/sdk/MetadataApiTest.java b/src/test/java/com/github/copilot/sdk/MetadataApiTest.java index b3eb8fcb7..580c58153 100644 --- a/src/test/java/com/github/copilot/sdk/MetadataApiTest.java +++ b/src/test/java/com/github/copilot/sdk/MetadataApiTest.java @@ -327,7 +327,7 @@ void testListModels() throws Exception { // ===== 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/SessionEventParserTest.java b/src/test/java/com/github/copilot/sdk/SessionEventParserTest.java index 65129ce7e..d4770a721 100644 --- a/src/test/java/com/github/copilot/sdk/SessionEventParserTest.java +++ b/src/test/java/com/github/copilot/sdk/SessionEventParserTest.java @@ -2198,4 +2198,186 @@ void testParseJsonNodeUserMessageWithAttachment() throws Exception { assertEquals(0, att.selection().start().line()); assertEquals(14, att.selection().end().character()); } + + @Test + void testParseExternalToolRequestedEvent() throws Exception { + String json = """ + { + "type": "external_tool.requested", + "data": { + "requestId": "req-123", + "sessionId": "sess-456", + "toolCallId": "call-789", + "toolName": "get_weather", + "arguments": {"location": "Seattle"} + } + } + """; + + var event = (ExternalToolRequestedEvent) parseJson(json); + assertNotNull(event); + assertEquals("external_tool.requested", event.getType()); + assertNotNull(event.getData()); + assertEquals("req-123", event.getData().requestId()); + assertEquals("sess-456", event.getData().sessionId()); + assertEquals("call-789", event.getData().toolCallId()); + assertEquals("get_weather", event.getData().toolName()); + } + + @Test + void testParseExternalToolCompletedEvent() throws Exception { + String json = """ + { + "type": "external_tool.completed", + "data": { + "requestId": "req-123" + } + } + """; + + var event = (ExternalToolCompletedEvent) parseJson(json); + assertNotNull(event); + assertEquals("external_tool.completed", event.getType()); + assertEquals("req-123", event.getData().requestId()); + } + + @Test + void testParsePermissionRequestedEvent() throws Exception { + String json = """ + { + "type": "permission.requested", + "data": { + "requestId": "perm-req-456", + "permissionRequest": { + "kind": "shell", + "toolCallId": "call-001" + } + } + } + """; + + var event = (PermissionRequestedEvent) parseJson(json); + assertNotNull(event); + assertEquals("permission.requested", event.getType()); + assertEquals("perm-req-456", event.getData().requestId()); + assertNotNull(event.getData().permissionRequest()); + assertEquals("shell", event.getData().permissionRequest().getKind()); + } + + @Test + void testParsePermissionCompletedEvent() throws Exception { + String json = """ + { + "type": "permission.completed", + "data": { + "requestId": "perm-req-456", + "result": { + "kind": "approved" + } + } + } + """; + + var event = (PermissionCompletedEvent) parseJson(json); + assertNotNull(event); + assertEquals("permission.completed", event.getType()); + assertEquals("perm-req-456", event.getData().requestId()); + assertEquals("approved", event.getData().result().kind()); + } + + @Test + void testParseCommandQueuedEvent() throws Exception { + String json = """ + { + "type": "command.queued", + "data": { + "requestId": "cmd-req-789", + "command": "/help" + } + } + """; + + var event = (CommandQueuedEvent) parseJson(json); + assertNotNull(event); + assertEquals("command.queued", event.getType()); + assertEquals("cmd-req-789", event.getData().requestId()); + assertEquals("/help", event.getData().command()); + } + + @Test + void testParseCommandCompletedEvent() throws Exception { + String json = """ + { + "type": "command.completed", + "data": { + "requestId": "cmd-req-789" + } + } + """; + + var event = (CommandCompletedEvent) parseJson(json); + assertNotNull(event); + assertEquals("command.completed", event.getType()); + assertEquals("cmd-req-789", event.getData().requestId()); + } + + @Test + void testParseExitPlanModeRequestedEvent() throws Exception { + String json = """ + { + "type": "exit_plan_mode.requested", + "data": { + "requestId": "plan-req-001", + "summary": "Plan is ready", + "planContent": "## Plan\\n1. Do thing", + "actions": ["approve", "edit", "reject"], + "recommendedAction": "approve" + } + } + """; + + var event = (ExitPlanModeRequestedEvent) parseJson(json); + assertNotNull(event); + assertEquals("exit_plan_mode.requested", event.getType()); + assertEquals("plan-req-001", event.getData().requestId()); + assertEquals("Plan is ready", event.getData().summary()); + assertEquals(3, event.getData().actions().length); + assertEquals("approve", event.getData().recommendedAction()); + } + + @Test + void testParseExitPlanModeCompletedEvent() throws Exception { + String json = """ + { + "type": "exit_plan_mode.completed", + "data": { + "requestId": "plan-req-001" + } + } + """; + + var event = (ExitPlanModeCompletedEvent) parseJson(json); + assertNotNull(event); + assertEquals("exit_plan_mode.completed", event.getType()); + assertEquals("plan-req-001", event.getData().requestId()); + } + + @Test + void testParseSystemNotificationEvent() throws Exception { + String json = """ + { + "type": "system.notification", + "data": { + "content": "Agent completed", + "kind": {"type": "agent_completed", "agentId": "agent-1", "agentType": "task", "status": "completed"} + } + } + """; + + var event = (SystemNotificationEvent) parseJson(json); + assertNotNull(event); + assertEquals("system.notification", event.getType()); + assertNotNull(event.getData()); + assertTrue(event.getData().content().contains("Agent completed")); + } } diff --git a/src/test/java/com/github/copilot/sdk/SessionRequestBuilderTest.java b/src/test/java/com/github/copilot/sdk/SessionRequestBuilderTest.java index 7e9d5ee69..c00355211 100644 --- a/src/test/java/com/github/copilot/sdk/SessionRequestBuilderTest.java +++ b/src/test/java/com/github/copilot/sdk/SessionRequestBuilderTest.java @@ -237,4 +237,18 @@ private CopilotSession createTestSession() throws Exception { constructor.setAccessible(true); return constructor.newInstance("builder-test-session", null, null); } + + @Test + void testBuildCreateRequestWithAgent() { + var config = new SessionConfig().setAgent("my-agent"); + CreateSessionRequest request = SessionRequestBuilder.buildCreateRequest(config, "test-session-id"); + assertEquals("my-agent", request.getAgent()); + } + + @Test + void testBuildResumeRequestWithAgent() { + var config = new ResumeSessionConfig().setAgent("my-agent"); + ResumeSessionRequest request = SessionRequestBuilder.buildResumeRequest("session-id", config); + assertEquals("my-agent", request.getAgent()); + } }