diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 000000000..9e5a9238f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -0,0 +1,41 @@ +name: Bug +description: File a bug report +title: "[BUG]: " +labels: ["Type: Bug", "Status: Triage"] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to fill out this bug report! + - type: textarea + id: what-happened + attributes: + label: What happened? + description: What did you do? What happened? What did you expect to happen? + placeholder: Put your description of the bug here. + validations: + required: true + - type: textarea + id: versions + attributes: + label: Versions + description: What versions of the relevant software are you running? + placeholder: copilot-sdk-java v1.0.0, Java 17.0.10, Maven 3.9.6, Copilot CLI v1.0.0 + validations: + required: true + - type: textarea + id: logs + attributes: + label: Relevant log output + description: | + Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks. + Please check your logs before submission to ensure sensitive information is redacted. + render: shell + - type: checkboxes + id: terms + attributes: + label: Code of Conduct + description: By submitting this issue, you agree to follow our [Code of Conduct](CODE_OF_CONDUCT.md) + options: + - label: I agree to follow this project's Code of Conduct + required: true diff --git a/.github/ISSUE_TEMPLATE/documentation.yml b/.github/ISSUE_TEMPLATE/documentation.yml new file mode 100644 index 000000000..7f049a2ed --- /dev/null +++ b/.github/ISSUE_TEMPLATE/documentation.yml @@ -0,0 +1,41 @@ +name: Documentation +description: Update or add documentation +title: "[DOCS]: " +labels: ["Type: Documentation", "Status: Triage"] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to fill this out! + - type: textarea + id: describe-need + attributes: + label: Describe the need + description: What do you wish was different about our docs? + placeholder: Describe the need for documentation updates here. + validations: + required: true + - type: input + id: sdk_version + attributes: + label: SDK Version + description: Do these docs apply to a specific SDK version? + placeholder: copilot-sdk-java v1.0.0 + validations: + required: false + - type: textarea + id: logs + attributes: + label: Relevant log output + description: | + Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks. + Please check your logs before submission to ensure sensitive information is redacted. + render: shell + - type: checkboxes + id: terms + attributes: + label: Code of Conduct + description: By submitting this issue, you agree to follow our [Code of Conduct](CODE_OF_CONDUCT.md) + options: + - label: I agree to follow this project's Code of Conduct + required: true diff --git a/.github/ISSUE_TEMPLATE/feature.yml b/.github/ISSUE_TEMPLATE/feature.yml new file mode 100644 index 000000000..377cd12ab --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature.yml @@ -0,0 +1,41 @@ +name: Feature +description: Suggest an idea for a new feature or enhancement +title: "[FEAT]: " +labels: ["Type: Feature", "Status: Triage"] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to fill this out! + - type: textarea + id: describe-need + attributes: + label: Describe the need + description: What do you want to happen? What problem are you trying to solve? + placeholder: Describe the need for the feature. + validations: + required: true + - type: input + id: sdk_version + attributes: + label: SDK Version + description: Does this feature suggestion apply to a specific SDK version? + placeholder: copilot-sdk-java v1.0.0 + validations: + required: false + - type: textarea + id: logs + attributes: + label: Relevant log output + description: | + Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks. + Please check your logs before submission to ensure sensitive information is redacted. + render: shell + - type: checkboxes + id: terms + attributes: + label: Code of Conduct + description: By submitting this issue, you agree to follow our [Code of Conduct](CODE_OF_CONDUCT.md) + options: + - label: I agree to follow this project's Code of Conduct + required: true diff --git a/.github/ISSUE_TEMPLATE/maintenance.yml b/.github/ISSUE_TEMPLATE/maintenance.yml new file mode 100644 index 000000000..de21996db --- /dev/null +++ b/.github/ISSUE_TEMPLATE/maintenance.yml @@ -0,0 +1,41 @@ +name: Maintenance +description: Dependencies, cleanup, refactoring, reworking of code +title: "[MAINT]: " +labels: ["Type: Maintenance", "Status: Triage"] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to fill this out! + - type: textarea + id: describe-need + attributes: + label: Describe the need + description: What do you want to happen? + placeholder: Describe the maintenance need here. + validations: + required: true + - type: input + id: sdk_version + attributes: + label: SDK Version + description: Does this maintenance apply to a specific SDK version? + placeholder: copilot-sdk-java v1.0.0 + validations: + required: false + - type: textarea + id: logs + attributes: + label: Relevant log output + description: | + Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks. + Please check your logs before submission to ensure sensitive information is redacted. + render: shell + - type: checkboxes + id: terms + attributes: + label: Code of Conduct + description: By submitting this issue, you agree to follow our [Code of Conduct](CODE_OF_CONDUCT.md) + options: + - label: I agree to follow this project's Code of Conduct + required: true diff --git a/.github/badges/jacoco.svg b/.github/badges/jacoco.svg index 738a6ad1e..8ef59f460 100644 --- a/.github/badges/jacoco.svg +++ b/.github/badges/jacoco.svg @@ -12,7 +12,7 @@ coverage coverage - 88.6% - 88.6% + 87.5% + 87.5% diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 000000000..8993ca146 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,29 @@ + + +Resolves #ISSUE_NUMBER + +---- + +### Before the change? + + +* + +### After the change? + + +* + +### Pull request checklist +- [ ] Tests for the changes have been added (for bug fixes / features) +- [ ] Docs have been reviewed and added / updated if needed (for bug fixes / features) +- [ ] `mvn spotless:apply` has been run to format the code +- [ ] `mvn clean verify` passes locally + +### Does this introduce a breaking change? + + +- [ ] Yes +- [ ] No + +---- diff --git a/.lastmerge b/.lastmerge index 2a538cb8c..c5649a512 100644 --- a/.lastmerge +++ b/.lastmerge @@ -1 +1 @@ -dcd86c189501ce1b46b787ca60d90f3f315f3079 +062b61c8aa63b9b5d45fa1d7b01723e6660ffa83 diff --git a/CHANGELOG.md b/CHANGELOG.md index 4845a214e..29e6b6281 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,25 @@ 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@062b61c`](https://github.com/github/copilot-sdk/commit/062b61c8aa63b9b5d45fa1d7b01723e6660ffa83) + +## [1.0.11] - 2026-03-12 + +> **Upstream sync:** [`github/copilot-sdk@062b61c`](https://github.com/github/copilot-sdk/commit/062b61c8aa63b9b5d45fa1d7b01723e6660ffa83) +### 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 @@ -409,9 +427,13 @@ New types: `GetForegroundSessionResponse`, `SetForegroundSessionResponse` - Pre-commit hook for Spotless code formatting - Comprehensive API documentation -[Unreleased]: https://github.com/copilot-community-sdk/copilot-sdk-java/compare/v1.0.10...HEAD +[Unreleased]: https://github.com/copilot-community-sdk/copilot-sdk-java/compare/v1.0.11...HEAD +[1.0.11]: https://github.com/copilot-community-sdk/copilot-sdk-java/compare/v1.0.10...v1.0.11 +[Unreleased]: https://github.com/copilot-community-sdk/copilot-sdk-java/compare/v1.0.11...HEAD +[1.0.11]: https://github.com/copilot-community-sdk/copilot-sdk-java/compare/v1.0.10...v1.0.11 [1.0.10]: https://github.com/copilot-community-sdk/copilot-sdk-java/compare/v1.0.9...v1.0.10 -[Unreleased]: https://github.com/copilot-community-sdk/copilot-sdk-java/compare/v1.0.10...HEAD +[Unreleased]: https://github.com/copilot-community-sdk/copilot-sdk-java/compare/v1.0.11...HEAD +[1.0.11]: https://github.com/copilot-community-sdk/copilot-sdk-java/compare/v1.0.10...v1.0.11 [1.0.10]: https://github.com/copilot-community-sdk/copilot-sdk-java/compare/v1.0.9...v1.0.10 [1.0.9]: https://github.com/copilot-community-sdk/copilot-sdk-java/compare/v1.0.8...v1.0.9 [1.0.8]: https://github.com/copilot-community-sdk/copilot-sdk-java/compare/v1.0.7...v1.0.8 diff --git a/README.md b/README.md index 50d5cc4fd..4d31e5b33 100644 --- a/README.md +++ b/README.md @@ -33,14 +33,14 @@ Java SDK for programmatic control of GitHub Copilot CLI, enabling you to build A io.github.copilot-community-sdk copilot-sdk - 1.0.10 + 1.0.11 ``` ### Gradle ```groovy -implementation 'io.github.copilot-community-sdk:copilot-sdk:1.0.10' +implementation 'io.github.copilot-community-sdk:copilot-sdk:1.0.11' ``` ## Quick Start @@ -102,9 +102,9 @@ jbang https://github.com/copilot-community-sdk/copilot-sdk-java/blob/latest/jban ### Quick Links -- [Getting Started](https://copilot-community-sdk.github.io/copilot-sdk-java/documentation.html) -- [Javadoc API Reference](https://copilot-community-sdk.github.io/copilot-sdk-java/apidocs/) -- [MCP Servers Integration](https://copilot-community-sdk.github.io/copilot-sdk-java/mcp.html) +- [Getting Started](https://copilot-community-sdk.github.io/copilot-sdk-java/latest/documentation.html) +- [Javadoc API Reference](https://copilot-community-sdk.github.io/copilot-sdk-java/latest/apidocs/) +- [MCP Servers Integration](https://copilot-community-sdk.github.io/copilot-sdk-java/latest/mcp.html) - [Cookbook](src/site/markdown/cookbook/) — Practical recipes for common use cases ## Projects Using This SDK diff --git a/jbang-example.java b/jbang-example.java index d0b307e4e..c99178c6d 100644 --- a/jbang-example.java +++ b/jbang-example.java @@ -1,5 +1,5 @@ -//DEPS io.github.copilot-community-sdk:copilot-sdk:1.0.10 +//DEPS io.github.copilot-community-sdk:copilot-sdk:1.0.11 import com.github.copilot.sdk.*; import com.github.copilot.sdk.events.*; import com.github.copilot.sdk.json.*; diff --git a/pom.xml b/pom.xml index 19f69979c..51c08825f 100644 --- a/pom.xml +++ b/pom.xml @@ -7,7 +7,7 @@ io.github.copilot-community-sdk copilot-sdk - 1.0.10 + 1.0.11 jar GitHub Copilot Community SDK :: Java @@ -33,7 +33,7 @@ scm:git:https://github.com/copilot-community-sdk/copilot-sdk-java.git scm:git:https://github.com/copilot-community-sdk/copilot-sdk-java.git https://github.com/copilot-community-sdk/copilot-sdk-java - v1.0.10 + v1.0.11 diff --git a/src/main/java/com/github/copilot/sdk/CopilotClient.java b/src/main/java/com/github/copilot/sdk/CopilotClient.java index 5c4fcfe21..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,15 +202,27 @@ 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."); } } /** - * Stops the client and closes all sessions. + * Disconnects from the Copilot server and closes all active sessions. + *

+ * This method performs graceful cleanup: + *

    + *
  1. Closes all active sessions (releases in-memory resources)
  2. + *
  3. Closes the JSON-RPC connection
  4. + *
  5. Terminates the CLI server process (if spawned by this client)
  6. + *
+ *

+ * Note: session data on disk is preserved, so sessions can be resumed later. To + * permanently remove session data before stopping, call + * {@link #deleteSession(String)} for each session first. * * @return A future that completes when the client is stopped */ @@ -308,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); }); }); } @@ -352,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); }); }); } @@ -423,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 @@ -434,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) { @@ -469,9 +535,12 @@ public CompletableFuture getLastSessionId() { } /** - * Deletes a session by ID. + * Permanently deletes a session and all its data from disk, including + * conversation history, planning state, and artifacts. *

- * This permanently removes the session and its conversation history. + * Unlike {@link CopilotSession#close()}, which only releases in-memory + * resources and preserves session data for later resumption, this method is + * irreversible. The session cannot be resumed after deletion. * * @param sessionId * the ID of the session to delete diff --git a/src/main/java/com/github/copilot/sdk/CopilotSession.java b/src/main/java/com/github/copilot/sdk/CopilotSession.java index 1af22d803..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; @@ -58,6 +62,13 @@ * A session maintains conversation state, handles events, and manages tool * execution. Sessions are created via {@link CopilotClient#createSession} or * resumed via {@link CopilotClient#resumeSession}. + *

+ * {@code CopilotSession} implements {@link AutoCloseable}. Use the + * try-with-resources pattern for automatic cleanup, or call {@link #close()} + * explicitly. Closing a session releases in-memory resources but preserves + * session data on disk — the conversation can be resumed later via + * {@link CopilotClient#resumeSession}. To permanently delete session data, use + * {@link CopilotClient#deleteSession}. * *

Example Usage

* @@ -93,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<>(); @@ -150,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. @@ -163,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. *

@@ -544,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); @@ -565,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. *

@@ -830,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/RpcHandlerDispatcher.java b/src/main/java/com/github/copilot/sdk/RpcHandlerDispatcher.java index 671aea593..2f041c7e4 100644 --- a/src/main/java/com/github/copilot/sdk/RpcHandlerDispatcher.java +++ b/src/main/java/com/github/copilot/sdk/RpcHandlerDispatcher.java @@ -17,6 +17,7 @@ import com.github.copilot.sdk.events.AbstractSessionEvent; import com.github.copilot.sdk.events.SessionEventParser; import com.github.copilot.sdk.json.PermissionRequestResult; +import com.github.copilot.sdk.json.PermissionRequestResultKind; import com.github.copilot.sdk.json.SessionLifecycleEvent; import com.github.copilot.sdk.json.SessionLifecycleEventMetadata; import com.github.copilot.sdk.json.ToolDefinition; @@ -183,7 +184,7 @@ private void handlePermissionRequest(JsonRpcClient rpc, String requestId, JsonNo CopilotSession session = sessions.get(sessionId); if (session == null) { var result = new PermissionRequestResult() - .setKind("denied-no-approval-rule-and-could-not-request-from-user"); + .setKind(PermissionRequestResultKind.DENIED_COULD_NOT_REQUEST_FROM_USER); rpc.sendResponse(Long.parseLong(requestId), Map.of("result", result)); return; } @@ -197,7 +198,7 @@ private void handlePermissionRequest(JsonRpcClient rpc, String requestId, JsonNo }).exceptionally(ex -> { try { var result = new PermissionRequestResult() - .setKind("denied-no-approval-rule-and-could-not-request-from-user"); + .setKind(PermissionRequestResultKind.DENIED_COULD_NOT_REQUEST_FROM_USER); rpc.sendResponse(Long.parseLong(requestId), Map.of("result", result)); } catch (IOException e) { LOG.log(Level.SEVERE, "Error sending permission denied", e); 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/PermissionHandler.java b/src/main/java/com/github/copilot/sdk/json/PermissionHandler.java index e987e41ae..21b822e41 100644 --- a/src/main/java/com/github/copilot/sdk/json/PermissionHandler.java +++ b/src/main/java/com/github/copilot/sdk/json/PermissionHandler.java @@ -46,7 +46,7 @@ public interface PermissionHandler { * @since 1.0.11 */ PermissionHandler APPROVE_ALL = (request, invocation) -> CompletableFuture - .completedFuture(new PermissionRequestResult().setKind("approved")); + .completedFuture(new PermissionRequestResult().setKind(PermissionRequestResultKind.APPROVED)); /** * Handles a permission request from the assistant. diff --git a/src/main/java/com/github/copilot/sdk/json/PermissionRequestResult.java b/src/main/java/com/github/copilot/sdk/json/PermissionRequestResult.java index 12e2ec2c2..3d7390f03 100644 --- a/src/main/java/com/github/copilot/sdk/json/PermissionRequestResult.java +++ b/src/main/java/com/github/copilot/sdk/json/PermissionRequestResult.java @@ -17,13 +17,17 @@ * *

Common Result Kinds

*
    - *
  • "user-approved" - User approved the permission request
  • - *
  • "user-denied" - User denied the permission request
  • - *
  • "denied-no-approval-rule-and-could-not-request-from-user" - No handler - * and couldn't ask user
  • + *
  • {@link PermissionRequestResultKind#APPROVED} — approved
  • + *
  • {@link PermissionRequestResultKind#DENIED_BY_RULES} — denied by + * rules
  • + *
  • {@link PermissionRequestResultKind#DENIED_COULD_NOT_REQUEST_FROM_USER} — + * no handler and couldn't ask user
  • + *
  • {@link PermissionRequestResultKind#DENIED_INTERACTIVELY_BY_USER} — denied + * by the user interactively
  • *
* * @see PermissionHandler + * @see PermissionRequestResultKind * @since 1.0.0 */ @JsonInclude(JsonInclude.Include.NON_NULL) @@ -36,7 +40,7 @@ public final class PermissionRequestResult { private List rules; /** - * Gets the result kind. + * Gets the result kind as a string. * * @return the result kind indicating approval or denial */ @@ -45,11 +49,24 @@ public String getKind() { } /** - * Sets the result kind. + * Sets the result kind using a {@link PermissionRequestResultKind} value. * * @param kind * the result kind * @return this result for method chaining + * @since 1.1.0 + */ + public PermissionRequestResult setKind(PermissionRequestResultKind kind) { + this.kind = kind != null ? kind.getValue() : null; + return this; + } + + /** + * Sets the result kind using a raw string value. + * + * @param kind + * the result kind string + * @return this result for method chaining */ public PermissionRequestResult setKind(String kind) { this.kind = kind; diff --git a/src/main/java/com/github/copilot/sdk/json/PermissionRequestResultKind.java b/src/main/java/com/github/copilot/sdk/json/PermissionRequestResultKind.java new file mode 100644 index 000000000..f85a0df1c --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/json/PermissionRequestResultKind.java @@ -0,0 +1,99 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk.json; + +import java.util.Objects; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +/** + * Describes the outcome kind of a permission request result. + * + *

+ * This is a string-backed value type that can hold both well-known kinds (via + * the static constants) and arbitrary extension values forwarded by the server. + * Comparisons are case-insensitive to match server behaviour. + * + *

Well-known kinds

+ *
    + *
  • {@link #APPROVED} — the permission was approved.
  • + *
  • {@link #DENIED_BY_RULES} — the permission was denied by policy + * rules.
  • + *
  • {@link #DENIED_COULD_NOT_REQUEST_FROM_USER} — the permission was denied + * because no approval rule was found and the user could not be prompted.
  • + *
  • {@link #DENIED_INTERACTIVELY_BY_USER} — the permission was denied + * interactively by the user.
  • + *
+ * + * @see PermissionRequestResult + * @since 1.1.0 + */ +public final class PermissionRequestResultKind { + + /** The permission was approved. */ + public static final PermissionRequestResultKind APPROVED = new PermissionRequestResultKind("approved"); + + /** The permission was denied by policy rules. */ + public static final PermissionRequestResultKind DENIED_BY_RULES = new PermissionRequestResultKind( + "denied-by-rules"); + + /** + * The permission was denied because no approval rule was found and the user + * could not be prompted. + */ + public static final PermissionRequestResultKind DENIED_COULD_NOT_REQUEST_FROM_USER = new PermissionRequestResultKind( + "denied-no-approval-rule-and-could-not-request-from-user"); + + /** The permission was denied interactively by the user. */ + public static final PermissionRequestResultKind DENIED_INTERACTIVELY_BY_USER = new PermissionRequestResultKind( + "denied-interactively-by-user"); + + private final String value; + + /** + * Creates a new {@code PermissionRequestResultKind} with the given string + * value. Useful for extension kinds not covered by the well-known constants. + * + * @param value + * the string value; {@code null} is treated as an empty string + */ + @JsonCreator + public PermissionRequestResultKind(String value) { + this.value = value != null ? value : ""; + } + + /** + * Returns the underlying string value of this kind. + * + * @return the string value, never {@code null} + */ + @JsonValue + public String getValue() { + return value; + } + + @Override + public String toString() { + return value; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (!(obj instanceof PermissionRequestResultKind)) { + return false; + } + PermissionRequestResultKind other = (PermissionRequestResultKind) obj; + return value.equalsIgnoreCase(other.value); + } + + @Override + public int hashCode() { + return Objects.hashCode(value.toLowerCase(java.util.Locale.ROOT)); + } +} 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/site/markdown/advanced.md b/src/site/markdown/advanced.md index 1b8724e96..42b2c8964 100644 --- a/src/site/markdown/advanced.md +++ b/src/site/markdown/advanced.md @@ -24,6 +24,8 @@ This guide covers advanced scenarios for extending and customizing your Copilot - [Loading Skills](#Loading_Skills) - [Disabling Skills](#Disabling_Skills) - [Custom Configuration Directory](#Custom_Configuration_Directory) +- [Session Logging](#Session_Logging) +- [Early Event Registration](#Early_Event_Registration) - [User Input Handling](#User_Input_Handling) - [Permission Handling](#Permission_Handling) - [Session Hooks](#Session_Hooks) @@ -506,6 +508,54 @@ This is useful when you need to isolate session configuration or use different s --- +## Session Logging + +Send log messages to the session for debugging, status updates, or UI feedback. + +```java +// Simple log message (defaults to "info" level) +session.log("Processing step 1 of 3").get(); + +// Log with explicit level and ephemeral flag +session.log("Downloading dependencies...", "info", true).get(); +``` + +| Parameter | Type | Description | +|-----------|------|-------------| +| `message` | String | The log message text | +| `level` | String | Log level: `"info"`, `"warning"`, `"error"` | +| `ephemeral` | Boolean | If `true`, the message is transient and may not be persisted | + +Use cases: +- Displaying progress in a UI while the session processes a request +- Sending status updates to the session log +- Debugging session behavior with contextual messages + +See [CopilotSession.log()](apidocs/com/github/copilot/sdk/CopilotSession.html#log(java.lang.String)) Javadoc for details. + +--- + +## Early Event Registration + +Register an event handler *before* the `session.create` RPC is issued, ensuring no early events are missed. + +When you register handlers with `session.on()` after `createSession()` returns, you may miss events emitted during session creation (e.g., `SessionStartEvent`). Use `SessionConfig.setOnEvent()` to guarantee delivery of all events from the very start: + +```java +var events = new CopyOnWriteArrayList(); + +var session = client.createSession( + new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL) + .setOnEvent(events::add) // Registered before session.create RPC +).get(); + +// events list now includes SessionStartEvent and any other early events +``` + +This is equivalent to calling `session.on(handler)` immediately after creation, but executes earlier in the lifecycle. The same option is available on `ResumeSessionConfig.setOnEvent()` for resumed sessions. + +--- + ## User Input Handling Handle user input requests when the AI uses the `ask_user` tool to gather information from the user. @@ -557,16 +607,28 @@ Approve or deny permission requests from the AI. ```java var session = client.createSession( - new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL) - .setOnPermissionRequest((request, invocation) -> { - // Inspect request and approve/deny - var result = new PermissionRequestResult(); - result.setKind("user-approved"); - return CompletableFuture.completedFuture(result); - }) + new SessionConfig().setOnPermissionRequest((request, invocation) -> { + // Inspect request and approve/deny using typed constants + var result = new PermissionRequestResult(); + result.setKind(PermissionRequestResultKind.APPROVED); + return CompletableFuture.completedFuture(result); + }) ).get(); ``` +The `PermissionRequestResultKind` class provides well-known constants for common outcomes: + +| Constant | Value | Meaning | +|---|---|---| +| `PermissionRequestResultKind.APPROVED` | `"approved"` | The permission was approved | +| `PermissionRequestResultKind.DENIED_BY_RULES` | `"denied-by-rules"` | Denied by policy rules | +| `PermissionRequestResultKind.DENIED_COULD_NOT_REQUEST_FROM_USER` | `"denied-no-approval-rule-and-could-not-request-from-user"` | No rule and user could not be prompted | +| `PermissionRequestResultKind.DENIED_INTERACTIVELY_BY_USER` | `"denied-interactively-by-user"` | User denied interactively | + +You can also pass a raw string to `setKind(String)` for custom or extension values. Use +[`PermissionHandler.APPROVE_ALL`](apidocs/com/github/copilot/sdk/json/PermissionHandler.html) to approve all +requests without writing a handler. + --- ## Session Hooks diff --git a/src/site/markdown/documentation.md b/src/site/markdown/documentation.md index 01aba950f..5e6913dbd 100644 --- a/src/site/markdown/documentation.md +++ b/src/site/markdown/documentation.md @@ -162,6 +162,9 @@ The SDK supports event types organized by category. All events extend `AbstractS | `SessionInfoEvent` | `session.info` | Informational message from the session | | `SessionShutdownEvent` | `session.shutdown` | Session is shutting down (includes reason and exit code) | | `SessionModelChangeEvent` | `session.model_change` | The model was changed mid-session | +| `SessionModeChangedEvent` | `session.mode_changed` | Session mode changed (e.g., plan mode) | +| `SessionPlanChangedEvent` | `session.plan_changed` | Session plan was updated | +| `SessionWorkspaceFileChangedEvent` | `session.workspace_file_changed` | A file in the workspace was modified | | `SessionHandoffEvent` | `session.handoff` | Session handed off to another agent | | `SessionTruncationEvent` | `session.truncation` | Context was truncated due to limits | | `SessionSnapshotRewindEvent` | `session.snapshot_rewind` | Session rewound to a previous snapshot | @@ -220,8 +223,37 @@ The SDK supports event types organized by category. All events extend `AbstractS | `HookStartEvent` | `hook.start` | Hook execution started | | `HookEndEvent` | `hook.end` | Hook execution completed | | `SystemMessageEvent` | `system.message` | System-level message | +| `SystemNotificationEvent` | `system.notification` | System notification (informational) | | `SkillInvokedEvent` | `skill.invoked` | A skill was invoked | +### External Tool Events + +| Event | Type String | Description | +|-------|-------------|-------------| +| `ExternalToolRequestedEvent` | `external_tool.requested` | An external tool invocation was requested | +| `ExternalToolCompletedEvent` | `external_tool.completed` | An external tool invocation completed | + +### Permission Events + +| Event | Type String | Description | +|-------|-------------|-------------| +| `PermissionRequestedEvent` | `permission.requested` | A permission request was issued | +| `PermissionCompletedEvent` | `permission.completed` | A permission request was resolved | + +### Command Events + +| Event | Type String | Description | +|-------|-------------|-------------| +| `CommandQueuedEvent` | `command.queued` | A command was queued for execution | +| `CommandCompletedEvent` | `command.completed` | A queued command completed | + +### Plan Mode Events + +| Event | Type String | Description | +|-------|-------------|-------------| +| `ExitPlanModeRequestedEvent` | `exit_plan_mode.requested` | Exit from plan mode was requested | +| `ExitPlanModeCompletedEvent` | `exit_plan_mode.completed` | Exit from plan mode completed | + See the [events package Javadoc](apidocs/com/github/copilot/sdk/events/package-summary.html) for detailed event data structures. --- @@ -598,9 +630,12 @@ When resuming a session, you can optionally reconfigure many settings. This is u | `configDir` | Override configuration directory | | `mcpServers` | Configure MCP servers | | `customAgents` | Configure custom agents | +| `agent` | Pre-select a custom agent at session start | | `skillDirectories` | Directories to load skills from | | `disabledSkills` | Skills to disable | | `infiniteSessions` | Configure infinite session behavior | +| `disableResume` | When `true`, resumes without emitting a `session.resume` event | +| `onEvent` | Event handler registered before session resumption | **Example: Changing Model on Resume** @@ -617,8 +652,14 @@ See [ResumeSessionConfig](apidocs/com/github/copilot/sdk/json/ResumeSessionConfi ### Clean Up Sessions +Closing a session releases its in-memory resources but **preserves session data on disk**, so +it can be resumed later. Use `deleteSession()` to permanently remove session data from disk: + ```java -// Delete a specific session +// Close a session (releases in-memory resources; session can be resumed later) +session.close(); + +// Permanently delete a session and all its data from disk (cannot be resumed) client.deleteSession(sessionId).get(); ``` @@ -631,6 +672,7 @@ Complete list of all `SessionConfig` options for `createSession()`: | Option | Type | Description | Guide | |--------|------|-------------|-------| | `sessionId` | String | Custom session ID (auto-generated if omitted) | — | +| `clientName` | String | Client name for User-Agent identification | — | | `model` | String | AI model to use | [Choosing a Model](#Choosing_a_Model) | | `reasoningEffort` | String | Reasoning depth: `"low"`, `"medium"`, `"high"`, `"xhigh"` | [Reasoning Effort](#Reasoning_Effort) | | `tools` | List<ToolDefinition> | Custom tools the assistant can invoke | [Custom Tools](advanced.html#Custom_Tools) | @@ -645,10 +687,12 @@ Complete list of all `SessionConfig` options for `createSession()`: | `streaming` | boolean | Enable streaming response chunks | [Streaming Responses](#Streaming_Responses) | | `mcpServers` | Map<String, Object> | MCP server configurations | [MCP Servers](mcp.html) | | `customAgents` | List<CustomAgentConfig> | Custom agent definitions | [Custom Agents](advanced.html#Custom_Agents) | +| `agent` | String | Pre-select a custom agent at session start | [Custom Agents](advanced.html#Custom_Agents) | | `infiniteSessions` | InfiniteSessionConfig | Auto-compaction for long conversations | [Infinite Sessions](advanced.html#Infinite_Sessions) | | `skillDirectories` | List<String> | Directories to load skills from | [Skills](advanced.html#Skills_Configuration) | | `disabledSkills` | List<String> | Skills to disable by name | [Skills](advanced.html#Skills_Configuration) | | `configDir` | String | Custom configuration directory | [Config Dir](advanced.html#Custom_Configuration_Directory) | +| `onEvent` | Consumer<AbstractSessionEvent> | Event handler registered before session creation | [Early Event Registration](advanced.html#Early_Event_Registration) | ### Cloning SessionConfig diff --git a/src/site/markdown/setup.md b/src/site/markdown/setup.md index 56a2df4e5..10c1cb3d8 100644 --- a/src/site/markdown/setup.md +++ b/src/site/markdown/setup.md @@ -349,6 +349,7 @@ Complete list of `CopilotClientOptions` settings: | `logLevel` | String | CLI log level | `"info"` | | `environment` | Map | Environment variables | inherited | | `cwd` | String | Working directory | current dir | +| `onListModels` | Supplier | Custom model listing implementation | `null` (use CLI) | ### Extra CLI Arguments 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/CopilotSessionTest.java b/src/test/java/com/github/copilot/sdk/CopilotSessionTest.java index 332dfe5b7..787312cef 100644 --- a/src/test/java/com/github/copilot/sdk/CopilotSessionTest.java +++ b/src/test/java/com/github/copilot/sdk/CopilotSessionTest.java @@ -23,7 +23,6 @@ import com.github.copilot.sdk.events.AbstractSessionEvent; import com.github.copilot.sdk.events.AbortEvent; -import com.github.copilot.sdk.events.AssistantMessageDeltaEvent; import com.github.copilot.sdk.events.AssistantMessageEvent; import com.github.copilot.sdk.events.SessionIdleEvent; import com.github.copilot.sdk.events.SessionStartEvent; @@ -61,7 +60,7 @@ static void teardown() throws Exception { } /** - * Verifies that a session can be created and destroyed properly. + * Verifies that a session can be created and closed properly. * * @see Snapshot: session/should_receive_session_events */ @@ -286,6 +285,14 @@ void testShouldResumeSessionUsingTheSameClient() throws Exception { .map(m -> (AssistantMessageEvent) m).anyMatch(m -> m.getData().content().contains("2")); assertTrue(hasAssistantMessage, "Should find previous assistant message containing 2"); + // Can continue the conversation statefully + AssistantMessageEvent answer2 = session2 + .sendAndWait(new MessageOptions().setPrompt("Now if you double that, what do you get?")) + .get(60, TimeUnit.SECONDS); + assertNotNull(answer2); + assertTrue(answer2.getData().content().contains("4"), + "Follow-up response should contain 4: " + answer2.getData().content()); + session2.close(); } } @@ -327,6 +334,14 @@ void testShouldResumeSessionUsingNewClient() throws Exception { assertTrue(messages.stream().anyMatch(m -> "session.resume".equals(m.getType())), "Should contain session.resume event"); + // Can continue the conversation statefully + AssistantMessageEvent answer2 = session2 + .sendAndWait(new MessageOptions().setPrompt("Now if you double that, what do you get?")) + .get(60, TimeUnit.SECONDS); + assertNotNull(answer2); + assertTrue(answer2.getData().content().contains("4"), + "Follow-up response should contain 4: " + answer2.getData().content()); + session2.close(); } } @@ -394,44 +409,6 @@ void testShouldCreateSessionWithReplacedSystemMessageConfig() throws Exception { } } - /** - * Verifies that streaming delta events are received when streaming is enabled. - * - * @see Snapshot: - * session/should_receive_streaming_delta_events_when_streaming_is_enabled - */ - @Test - void testShouldReceiveStreamingDeltaEventsWhenStreamingIsEnabled() throws Exception { - ctx.configureForTest("session", "should_receive_streaming_delta_events_when_streaming_is_enabled"); - - try (CopilotClient client = ctx.createClient()) { - SessionConfig config = new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL) - .setStreaming(true); - - CopilotSession session = client.createSession(config).get(); - - var receivedEvents = new ArrayList(); - var idleReceived = new CompletableFuture(); - - session.on(evt -> { - receivedEvents.add(evt); - if (evt instanceof SessionIdleEvent) { - idleReceived.complete(null); - } - }); - - session.send(new MessageOptions().setPrompt("What is 2+2?")).get(); - - idleReceived.get(60, TimeUnit.SECONDS); - - // Should have received delta events when streaming is enabled - boolean hasDeltaEvents = receivedEvents.stream().anyMatch(e -> e instanceof AssistantMessageDeltaEvent); - assertTrue(hasDeltaEvents, "Should receive streaming delta events when streaming is enabled"); - - session.close(); - } - } - /** * Verifies that a session can be aborted during tool execution. * @@ -764,29 +741,24 @@ void testShouldCreateSessionWithCustomTool() throws Exception { } /** - * Verifies that streaming option is passed to session creation. + * Verifies that getLastSessionId returns the ID of the most recently used + * session. * - * @see Snapshot: session/should_pass_streaming_option_to_session_creation + * @see Snapshot: session/should_get_last_session_id */ @Test - void testShouldPassStreamingOptionToSessionCreation() throws Exception { - ctx.configureForTest("session", "should_pass_streaming_option_to_session_creation"); + void testShouldGetLastSessionId() throws Exception { + ctx.configureForTest("session", "should_get_last_session_id"); try (CopilotClient client = ctx.createClient()) { - // Verify that the streaming option is accepted without errors - CopilotSession session = client.createSession( - new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL).setStreaming(true)).get(); - - assertNotNull(session.getSessionId()); - assertTrue(session.getSessionId().matches("^[a-f0-9-]+$")); + CopilotSession session = client + .createSession(new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL)).get(); - // Session should still work normally - AssistantMessageEvent response = session.sendAndWait(new MessageOptions().setPrompt("What is 1+1?")).get(60, - TimeUnit.SECONDS); + session.sendAndWait(new MessageOptions().setPrompt("Say hello")).get(60, TimeUnit.SECONDS); - assertNotNull(response); - assertTrue(response.getData().content().contains("2"), - "Response should contain 2: " + response.getData().content()); + String lastId = client.getLastSessionId().get(30, TimeUnit.SECONDS); + assertNotNull(lastId, "Last session ID should not be null"); + assertEquals(session.getSessionId(), lastId, "Last session ID should match the current session ID"); session.close(); } diff --git a/src/test/java/com/github/copilot/sdk/E2ETestContext.java b/src/test/java/com/github/copilot/sdk/E2ETestContext.java index 20dfce7c7..74c65245b 100644 --- a/src/test/java/com/github/copilot/sdk/E2ETestContext.java +++ b/src/test/java/com/github/copilot/sdk/E2ETestContext.java @@ -263,8 +263,8 @@ public CopilotClient createClient() { CopilotClientOptions options = new CopilotClientOptions().setCliPath(cliPath).setCwd(workDir.toString()) .setEnvironment(getEnvironment()); - // In CI, use a fake token to avoid auth issues - String ci = System.getenv("CI"); + // In CI (GitHub Actions), use a fake token to avoid auth issues + String ci = System.getenv("GITHUB_ACTIONS"); if (ci != null && !ci.isEmpty()) { options.setGitHubToken("fake-token-for-e2e-tests"); } 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/PermissionRequestResultKindTest.java b/src/test/java/com/github/copilot/sdk/PermissionRequestResultKindTest.java new file mode 100644 index 000000000..b21f96e83 --- /dev/null +++ b/src/test/java/com/github/copilot/sdk/PermissionRequestResultKindTest.java @@ -0,0 +1,124 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.copilot.sdk.json.PermissionRequestResult; +import com.github.copilot.sdk.json.PermissionRequestResultKind; + +/** + * Unit tests for {@link PermissionRequestResultKind}. + *

+ * Covers well-known kind values, equality, hash code, serialization, and + * backward-compatible {@link PermissionRequestResult} integration. + */ +public class PermissionRequestResultKindTest { + + @Test + void wellKnownKinds_haveExpectedValues() { + assertEquals("approved", PermissionRequestResultKind.APPROVED.getValue()); + assertEquals("denied-by-rules", PermissionRequestResultKind.DENIED_BY_RULES.getValue()); + assertEquals("denied-no-approval-rule-and-could-not-request-from-user", + PermissionRequestResultKind.DENIED_COULD_NOT_REQUEST_FROM_USER.getValue()); + assertEquals("denied-interactively-by-user", + PermissionRequestResultKind.DENIED_INTERACTIVELY_BY_USER.getValue()); + } + + @Test + void equals_sameValue_returnsTrue() { + var a = new PermissionRequestResultKind("approved"); + assertEquals(PermissionRequestResultKind.APPROVED, a); + assertEquals(a, PermissionRequestResultKind.APPROVED); + } + + @Test + void equals_differentValue_returnsFalse() { + assertNotEquals(PermissionRequestResultKind.APPROVED, PermissionRequestResultKind.DENIED_BY_RULES); + } + + @Test + void equals_isCaseInsensitive() { + var upper = new PermissionRequestResultKind("APPROVED"); + assertEquals(PermissionRequestResultKind.APPROVED, upper); + } + + @Test + void hashCode_isCaseInsensitive() { + var upper = new PermissionRequestResultKind("APPROVED"); + assertEquals(PermissionRequestResultKind.APPROVED.hashCode(), upper.hashCode()); + } + + @Test + void toString_returnsValue() { + assertEquals("approved", PermissionRequestResultKind.APPROVED.toString()); + assertEquals("denied-by-rules", PermissionRequestResultKind.DENIED_BY_RULES.toString()); + } + + @Test + void customValue_isPreserved() { + var custom = new PermissionRequestResultKind("custom-kind"); + assertEquals("custom-kind", custom.getValue()); + assertEquals("custom-kind", custom.toString()); + } + + @Test + void constructor_nullValue_treatedAsEmpty() { + var kind = new PermissionRequestResultKind(null); + assertEquals("", kind.getValue()); + assertEquals("", kind.toString()); + } + + @Test + void equals_nonKindObject_returnsFalse() { + assertNotEquals(PermissionRequestResultKind.APPROVED, "approved"); + } + + @Test + void jsonSerialize_writesStringValue() throws Exception { + ObjectMapper mapper = new ObjectMapper(); + var result = new PermissionRequestResult().setKind(PermissionRequestResultKind.APPROVED); + String json = mapper.writeValueAsString(result); + assertTrue(json.contains("\"kind\":\"approved\""), "Expected kind to be serialized as string: " + json); + } + + @Test + void jsonDeserialize_readsStringValue() throws Exception { + ObjectMapper mapper = new ObjectMapper(); + String json = "{\"kind\":\"denied-by-rules\"}"; + var result = mapper.readValue(json, PermissionRequestResult.class); + assertEquals("denied-by-rules", result.getKind()); + } + + @Test + void permissionRequestResult_setKindWithKindType() { + var result = new PermissionRequestResult().setKind(PermissionRequestResultKind.APPROVED); + assertEquals("approved", result.getKind()); + } + + @Test + void permissionRequestResult_setKindWithString_backwardCompatible() { + var result = new PermissionRequestResult().setKind("approved"); + assertEquals("approved", result.getKind()); + } + + @Test + void jsonRoundTrip_allWellKnownKinds() throws Exception { + ObjectMapper mapper = new ObjectMapper(); + PermissionRequestResultKind[] kinds = {PermissionRequestResultKind.APPROVED, + PermissionRequestResultKind.DENIED_BY_RULES, + PermissionRequestResultKind.DENIED_COULD_NOT_REQUEST_FROM_USER, + PermissionRequestResultKind.DENIED_INTERACTIVELY_BY_USER,}; + for (PermissionRequestResultKind kind : kinds) { + var result = new PermissionRequestResult().setKind(kind); + String json = mapper.writeValueAsString(result); + var deserialized = mapper.readValue(json, PermissionRequestResult.class); + assertEquals(kind.getValue(), deserialized.getKind()); + } + } +} 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()); + } } diff --git a/src/test/java/com/github/copilot/sdk/StreamingFidelityTest.java b/src/test/java/com/github/copilot/sdk/StreamingFidelityTest.java new file mode 100644 index 000000000..d6bae399d --- /dev/null +++ b/src/test/java/com/github/copilot/sdk/StreamingFidelityTest.java @@ -0,0 +1,185 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import com.github.copilot.sdk.events.AbstractSessionEvent; +import com.github.copilot.sdk.events.AssistantMessageDeltaEvent; +import com.github.copilot.sdk.events.AssistantMessageEvent; +import com.github.copilot.sdk.json.MessageOptions; +import com.github.copilot.sdk.json.PermissionHandler; +import com.github.copilot.sdk.json.ResumeSessionConfig; +import com.github.copilot.sdk.json.SessionConfig; + +/** + * E2E tests for streaming fidelity — verifying that delta events are produced + * when streaming is enabled and absent when it is disabled. + * + *

+ * Snapshots are stored in {@code test/snapshots/streaming_fidelity/}. + *

+ */ +public class StreamingFidelityTest { + + private static E2ETestContext ctx; + + @BeforeAll + static void setup() throws Exception { + ctx = E2ETestContext.create(); + } + + @AfterAll + static void teardown() throws Exception { + if (ctx != null) { + ctx.close(); + } + } + + /** + * Verifies that assistant.message_delta events are produced when streaming is + * enabled. + * + * @see Snapshot: + * streaming_fidelity/should_produce_delta_events_when_streaming_is_enabled + */ + @Test + void testShouldProduceDeltaEventsWhenStreamingIsEnabled() throws Exception { + ctx.configureForTest("streaming_fidelity", "should_produce_delta_events_when_streaming_is_enabled"); + + try (CopilotClient client = ctx.createClient()) { + CopilotSession session = client.createSession( + new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL).setStreaming(true)).get(); + + List events = new ArrayList<>(); + session.on(events::add); + + session.sendAndWait(new MessageOptions().setPrompt("Count from 1 to 5, separated by commas.")).get(60, + TimeUnit.SECONDS); + + List types = events.stream().map(AbstractSessionEvent::getType).toList(); + + // Should have streaming deltas before the final message + List deltaEvents = events.stream() + .filter(e -> e instanceof AssistantMessageDeltaEvent).map(e -> (AssistantMessageDeltaEvent) e) + .toList(); + assertFalse(deltaEvents.isEmpty(), "Should have received delta events when streaming is enabled"); + + // Deltas should have content + for (AssistantMessageDeltaEvent delta : deltaEvents) { + assertFalse(delta.getData().deltaContent() == null || delta.getData().deltaContent().isEmpty(), + "Delta event should have content"); + } + + // Should still have a final assistant.message + assertTrue(types.contains("assistant.message"), "Should have a final assistant.message event"); + + // Deltas should come before the final message + int firstDeltaIdx = types.indexOf("assistant.message_delta"); + int lastAssistantIdx = types.lastIndexOf("assistant.message"); + assertTrue(firstDeltaIdx < lastAssistantIdx, "Delta events should come before the final assistant.message"); + + session.close(); + } + } + + /** + * Verifies that no delta events are produced when streaming is disabled. + * + * @see Snapshot: + * streaming_fidelity/should_not_produce_deltas_when_streaming_is_disabled + */ + @Test + void testShouldNotProduceDeltasWhenStreamingIsDisabled() throws Exception { + ctx.configureForTest("streaming_fidelity", "should_not_produce_deltas_when_streaming_is_disabled"); + + try (CopilotClient client = ctx.createClient()) { + CopilotSession session = client.createSession( + new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL).setStreaming(false)) + .get(); + + List events = new ArrayList<>(); + session.on(events::add); + + session.sendAndWait(new MessageOptions().setPrompt("Say 'hello world'.")).get(60, TimeUnit.SECONDS); + + List deltaEvents = events.stream() + .filter(e -> e instanceof AssistantMessageDeltaEvent).map(e -> (AssistantMessageDeltaEvent) e) + .toList(); + + // No deltas when streaming is off + assertTrue(deltaEvents.isEmpty(), "Should not receive delta events when streaming is disabled"); + + // But should still have a final assistant.message + List assistantEvents = events.stream() + .filter(e -> e instanceof AssistantMessageEvent).map(e -> (AssistantMessageEvent) e).toList(); + assertFalse(assistantEvents.isEmpty(), + "Should still have a final assistant.message when streaming is disabled"); + + session.close(); + } + } + + /** + * Verifies that delta events are produced after resuming a session with + * streaming enabled. + * + * @see Snapshot: streaming_fidelity/should_produce_deltas_after_session_resume + */ + @Test + void testShouldProduceDeltasAfterSessionResume() throws Exception { + ctx.configureForTest("streaming_fidelity", "should_produce_deltas_after_session_resume"); + + try (CopilotClient client = ctx.createClient()) { + // Create a non-streaming session and send an initial message + CopilotSession session = client.createSession( + new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL).setStreaming(false)) + .get(); + session.sendAndWait(new MessageOptions().setPrompt("What is 3 + 6?")).get(60, TimeUnit.SECONDS); + String sessionId = session.getSessionId(); + session.close(); + + // Resume using a new client with streaming enabled + try (CopilotClient newClient = ctx.createClient()) { + CopilotSession session2 = newClient.resumeSession(sessionId, new ResumeSessionConfig() + .setOnPermissionRequest(PermissionHandler.APPROVE_ALL).setStreaming(true)).get(); + + List events = new ArrayList<>(); + session2.on(events::add); + + AssistantMessageEvent answer = session2 + .sendAndWait(new MessageOptions().setPrompt("Now if you double that, what do you get?")) + .get(60, TimeUnit.SECONDS); + assertNotNull(answer); + assertTrue(answer.getData().content().contains("18"), + "Follow-up response should contain 18: " + answer.getData().content()); + + // Should have streaming deltas before the final message + List deltaEvents = events.stream() + .filter(e -> e instanceof AssistantMessageDeltaEvent).map(e -> (AssistantMessageDeltaEvent) e) + .toList(); + assertFalse(deltaEvents.isEmpty(), "Should have received delta events after session resume"); + + // Deltas should have content + for (AssistantMessageDeltaEvent delta : deltaEvents) { + assertFalse(delta.getData().deltaContent() == null || delta.getData().deltaContent().isEmpty(), + "Delta event should have content"); + } + + session2.close(); + } + } + } +}