From 615c065cc073be1bc30a85fe2c431930ee8803f1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 3 Mar 2026 20:52:44 +0000 Subject: [PATCH 01/22] [maven-release-plugin] prepare for next development iteration --- pom.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pom.xml b/pom.xml index 19f69979c..7ab031ce7 100644 --- a/pom.xml +++ b/pom.xml @@ -7,7 +7,7 @@ io.github.copilot-community-sdk copilot-sdk - 1.0.10 + 1.0.11-SNAPSHOT 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 + HEAD From 13fca3f7937d41f7d40bdec01a4c3373703b621f Mon Sep 17 00:00:00 2001 From: Claudenir Freitas Date: Tue, 3 Mar 2026 22:31:12 +0000 Subject: [PATCH 02/22] issue 170: fix quick links --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 50d5cc4fd..5943afab4 100644 --- a/README.md +++ b/README.md @@ -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 From 90da1c97dbb0f48e1f6a70f461ae369b20b2f924 Mon Sep 17 00:00:00 2001 From: Ed Burns Date: Tue, 3 Mar 2026 18:21:19 -0500 Subject: [PATCH 03/22] On branch dd-2790313-pr-templates Adapted from https://github.com/octokit/octokit-next.js new file: .github/ISSUE_TEMPLATE/bug.yml new file: .github/ISSUE_TEMPLATE/documentation.yml new file: .github/ISSUE_TEMPLATE/feature.yml new file: .github/ISSUE_TEMPLATE/maintenance.yml new file: .github/pull_request_template.md --- .github/ISSUE_TEMPLATE/bug.yml | 41 ++++++++++++++++++++++++ .github/ISSUE_TEMPLATE/documentation.yml | 41 ++++++++++++++++++++++++ .github/ISSUE_TEMPLATE/feature.yml | 41 ++++++++++++++++++++++++ .github/ISSUE_TEMPLATE/maintenance.yml | 41 ++++++++++++++++++++++++ .github/pull_request_template.md | 29 +++++++++++++++++ 5 files changed, 193 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/bug.yml create mode 100644 .github/ISSUE_TEMPLATE/documentation.yml create mode 100644 .github/ISSUE_TEMPLATE/feature.yml create mode 100644 .github/ISSUE_TEMPLATE/maintenance.yml create mode 100644 .github/pull_request_template.md 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/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 + +---- From b29869ae5d1a7c4d13ce18f9c2b151f35615ebd2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Mar 2026 19:57:34 +0000 Subject: [PATCH 04/22] Initial plan From c93a9905fa88a2cf8ef6ad4fa4ba8851d349a381 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Mar 2026 20:11:00 +0000 Subject: [PATCH 05/22] Update .lastmerge to 4246289e484d42155c75267660d448d9ac4f9158 --- .lastmerge | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.lastmerge b/.lastmerge index 2a538cb8c..92e0dd366 100644 --- a/.lastmerge +++ b/.lastmerge @@ -1 +1 @@ -dcd86c189501ce1b46b787ca60d90f3f315f3079 +4246289e484d42155c75267660d448d9ac4f9158 From b523d0a69da6ad8f175a7d75a04169bfca04211a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Mar 2026 20:11:44 +0000 Subject: [PATCH 06/22] Port upstream changes: PermissionRequestResultKind, CI detection fix, E2E test updates Co-authored-by: brunoborges <129743+brunoborges@users.noreply.github.com> --- .../copilot/sdk/RpcHandlerDispatcher.java | 5 +- .../copilot/sdk/json/PermissionHandler.java | 2 +- .../sdk/json/PermissionRequestResult.java | 29 ++- .../sdk/json/PermissionRequestResultKind.java | 99 ++++++++++ src/site/markdown/advanced.md | 26 ++- .../copilot/sdk/CopilotSessionTest.java | 82 +++----- .../github/copilot/sdk/E2ETestContext.java | 4 +- .../sdk/PermissionRequestResultKindTest.java | 124 ++++++++++++ .../copilot/sdk/StreamingFidelityTest.java | 185 ++++++++++++++++++ 9 files changed, 483 insertions(+), 73 deletions(-) create mode 100644 src/main/java/com/github/copilot/sdk/json/PermissionRequestResultKind.java create mode 100644 src/test/java/com/github/copilot/sdk/PermissionRequestResultKindTest.java create mode 100644 src/test/java/com/github/copilot/sdk/StreamingFidelityTest.java 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/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/site/markdown/advanced.md b/src/site/markdown/advanced.md index 1b8724e96..dc57f6258 100644 --- a/src/site/markdown/advanced.md +++ b/src/site/markdown/advanced.md @@ -557,16 +557,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/test/java/com/github/copilot/sdk/CopilotSessionTest.java b/src/test/java/com/github/copilot/sdk/CopilotSessionTest.java index 332dfe5b7..1dec8c881 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; @@ -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/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/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(); + } + } + } +} From b5e56efe8253000b3a050623ebaa22b739fc7d16 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 5 Mar 2026 23:21:47 +0000 Subject: [PATCH 07/22] Update JaCoCo coverage badge --- .github/badges/jacoco.svg | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/badges/jacoco.svg b/.github/badges/jacoco.svg index 738a6ad1e..c80aa997f 100644 --- a/.github/badges/jacoco.svg +++ b/.github/badges/jacoco.svg @@ -12,7 +12,7 @@ coverage coverage - 88.6% - 88.6% + 88.9% + 88.9% From 3b84ce811f40f2e03b5539db7ca9e68fb40c9db9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Mar 2026 18:49:55 +0000 Subject: [PATCH 08/22] Initial plan From d74170f4faefdb4f973e9b9a3c7678858f263953 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Mar 2026 18:59:16 +0000 Subject: [PATCH 09/22] Update .lastmerge to 4e1499dd23709022c720eaaa5457d00bf0cb3977 --- .lastmerge | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.lastmerge b/.lastmerge index 92e0dd366..df5b85477 100644 --- a/.lastmerge +++ b/.lastmerge @@ -1 +1 @@ -4246289e484d42155c75267660d448d9ac4f9158 +4e1499dd23709022c720eaaa5457d00bf0cb3977 From 1daa34649fb9b5146d62002062b1af8c677e0453 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Mar 2026 19:01:05 +0000 Subject: [PATCH 10/22] docs: clarify session close vs delete semantics (upstream sync #599) Co-authored-by: brunoborges <129743+brunoborges@users.noreply.github.com> --- .../com/github/copilot/sdk/CopilotClient.java | 20 ++++++++++++++++--- .../github/copilot/sdk/CopilotSession.java | 7 +++++++ src/site/markdown/documentation.md | 8 +++++++- .../copilot/sdk/CopilotSessionTest.java | 2 +- 4 files changed, 32 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/github/copilot/sdk/CopilotClient.java b/src/main/java/com/github/copilot/sdk/CopilotClient.java index 5c4fcfe21..023051edd 100644 --- a/src/main/java/com/github/copilot/sdk/CopilotClient.java +++ b/src/main/java/com/github/copilot/sdk/CopilotClient.java @@ -208,7 +208,18 @@ private void verifyProtocolVersion(Connection connection) throws Exception { } /** - * 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 */ @@ -469,9 +480,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..80b2e85ea 100644 --- a/src/main/java/com/github/copilot/sdk/CopilotSession.java +++ b/src/main/java/com/github/copilot/sdk/CopilotSession.java @@ -58,6 +58,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

* diff --git a/src/site/markdown/documentation.md b/src/site/markdown/documentation.md index 01aba950f..26f17ce0f 100644 --- a/src/site/markdown/documentation.md +++ b/src/site/markdown/documentation.md @@ -617,8 +617,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(); ``` diff --git a/src/test/java/com/github/copilot/sdk/CopilotSessionTest.java b/src/test/java/com/github/copilot/sdk/CopilotSessionTest.java index 1dec8c881..787312cef 100644 --- a/src/test/java/com/github/copilot/sdk/CopilotSessionTest.java +++ b/src/test/java/com/github/copilot/sdk/CopilotSessionTest.java @@ -60,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 */ From 1bfb71ac64840053ba70f0bbf0218cc36b354503 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Mar 2026 12:56:15 +0000 Subject: [PATCH 11/22] Initial plan From 2aea3a02eb0f008d6c77b15067ac700dc0e83409 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Mar 2026 13:48:34 +0000 Subject: [PATCH 12/22] Port upstream sync: protocol v3, OnListModels, agent, onEvent, new events, register-before-RPC Co-authored-by: brunoborges <129743+brunoborges@users.noreply.github.com> --- .lastmerge | 2 +- CHANGELOG.md | 17 +- .../com/github/copilot/sdk/CopilotClient.java | 73 +++++- .../github/copilot/sdk/CopilotSession.java | 209 +++++++++++++++++- .../copilot/sdk/SdkProtocolVersion.java | 2 +- .../copilot/sdk/SessionRequestBuilder.java | 30 ++- .../sdk/events/AbstractSessionEvent.java | 4 + .../sdk/events/CommandCompletedEvent.java | 37 ++++ .../sdk/events/CommandQueuedEvent.java | 38 ++++ .../events/ExitPlanModeCompletedEvent.java | 37 ++++ .../events/ExitPlanModeRequestedEvent.java | 39 ++++ .../events/ExternalToolCompletedEvent.java | 40 ++++ .../events/ExternalToolRequestedEvent.java | 43 ++++ .../sdk/events/PermissionCompletedEvent.java | 45 ++++ .../sdk/events/PermissionRequestedEvent.java | 43 ++++ .../sdk/events/SessionEventParser.java | 9 + .../sdk/events/SystemNotificationEvent.java | 37 ++++ .../sdk/json/CopilotClientOptions.java | 30 +++ .../sdk/json/CreateSessionRequest.java | 13 ++ .../copilot/sdk/json/ResumeSessionConfig.java | 56 +++++ .../sdk/json/ResumeSessionRequest.java | 13 ++ .../copilot/sdk/json/SessionConfig.java | 59 +++++ .../github/copilot/sdk/ConfigCloneTest.java | 48 +++- .../github/copilot/sdk/CopilotClientTest.java | 65 ++++++ .../github/copilot/sdk/MetadataApiTest.java | 4 +- .../copilot/sdk/SessionEventParserTest.java | 182 +++++++++++++++ .../sdk/SessionRequestBuilderTest.java | 14 ++ 27 files changed, 1169 insertions(+), 20 deletions(-) create mode 100644 src/main/java/com/github/copilot/sdk/events/CommandCompletedEvent.java create mode 100644 src/main/java/com/github/copilot/sdk/events/CommandQueuedEvent.java create mode 100644 src/main/java/com/github/copilot/sdk/events/ExitPlanModeCompletedEvent.java create mode 100644 src/main/java/com/github/copilot/sdk/events/ExitPlanModeRequestedEvent.java create mode 100644 src/main/java/com/github/copilot/sdk/events/ExternalToolCompletedEvent.java create mode 100644 src/main/java/com/github/copilot/sdk/events/ExternalToolRequestedEvent.java create mode 100644 src/main/java/com/github/copilot/sdk/events/PermissionCompletedEvent.java create mode 100644 src/main/java/com/github/copilot/sdk/events/PermissionRequestedEvent.java create mode 100644 src/main/java/com/github/copilot/sdk/events/SystemNotificationEvent.java diff --git a/.lastmerge b/.lastmerge index df5b85477..fd2e8b676 100644 --- a/.lastmerge +++ b/.lastmerge @@ -1 +1 @@ -4e1499dd23709022c720eaaa5457d00bf0cb3977 +9a0a1a5f21111f4ad02b5ce911750ecc75e054c3 diff --git a/CHANGELOG.md b/CHANGELOG.md index 4845a214e..b95104175 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,22 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] -> **Upstream sync:** [`github/copilot-sdk@dcd86c1`](https://github.com/github/copilot-sdk/commit/dcd86c189501ce1b46b787ca60d90f3f315f3079) +> **Upstream sync:** [`github/copilot-sdk@9a0a1a5`](https://github.com/github/copilot-sdk/commit/9a0a1a5f21111f4ad02b5ce911750ecc75e054c3) + +### Added + +- `CopilotClientOptions.setOnListModels(Supplier>>)` — custom handler for `listModels()` used in BYOK mode to return models from a custom provider instead of querying the CLI (upstream: [`e478657`](https://github.com/github/copilot-sdk/commit/e478657)) +- `SessionConfig.setAgent(String)` — pre-selects a custom agent by name when creating a session (upstream: [`7766b1a`](https://github.com/github/copilot-sdk/commit/7766b1a)) +- `ResumeSessionConfig.setAgent(String)` — pre-selects a custom agent by name when resuming a session (upstream: [`7766b1a`](https://github.com/github/copilot-sdk/commit/7766b1a)) +- `SessionConfig.setOnEvent(Consumer)` — registers an event handler before the `session.create` RPC is issued, ensuring no early events are missed (upstream: [`4125fe7`](https://github.com/github/copilot-sdk/commit/4125fe7)) +- `ResumeSessionConfig.setOnEvent(Consumer)` — registers an event handler before the `session.resume` RPC is issued (upstream: [`4125fe7`](https://github.com/github/copilot-sdk/commit/4125fe7)) +- New broadcast session event types (protocol v3): `ExternalToolRequestedEvent` (`external_tool.requested`), `ExternalToolCompletedEvent` (`external_tool.completed`), `PermissionRequestedEvent` (`permission.requested`), `PermissionCompletedEvent` (`permission.completed`), `CommandQueuedEvent` (`command.queued`), `CommandCompletedEvent` (`command.completed`), `ExitPlanModeRequestedEvent` (`exit_plan_mode.requested`), `ExitPlanModeCompletedEvent` (`exit_plan_mode.completed`), `SystemNotificationEvent` (`system.notification`) (upstream: [`1653812`](https://github.com/github/copilot-sdk/commit/1653812), [`396e8b3`](https://github.com/github/copilot-sdk/commit/396e8b3)) +- `CopilotSession.log(String)` and `CopilotSession.log(String, String, Boolean)` — log a message to the session timeline (upstream: [`4125fe7`](https://github.com/github/copilot-sdk/commit/4125fe7)) + +### Changed + +- **Protocol version bumped to v3.** The SDK now supports CLI servers running v2 or v3 (backward-compatible range). Sessions are now registered in the client's session map *before* the `session.create`/`session.resume` RPC is issued, ensuring broadcast events emitted immediately on session start are never dropped (upstream: [`4125fe7`](https://github.com/github/copilot-sdk/commit/4125fe7), [`1653812`](https://github.com/github/copilot-sdk/commit/1653812)) +- In protocol v3, tool calls and permission requests that have a registered handler are now handled automatically via `ExternalToolRequestedEvent` and `PermissionRequestedEvent` broadcast events; results are sent back via `session.tools.handlePendingToolCall` and `session.permissions.handlePendingPermissionRequest` RPC calls (upstream: [`1653812`](https://github.com/github/copilot-sdk/commit/1653812)) ## [1.0.10] - 2026-03-03 diff --git a/src/main/java/com/github/copilot/sdk/CopilotClient.java b/src/main/java/com/github/copilot/sdk/CopilotClient.java index 023051edd..39034c910 100644 --- a/src/main/java/com/github/copilot/sdk/CopilotClient.java +++ b/src/main/java/com/github/copilot/sdk/CopilotClient.java @@ -188,6 +188,8 @@ private CompletableFuture startCore() { }); } + private static final int MIN_PROTOCOL_VERSION = 2; + private void verifyProtocolVersion(Connection connection) throws Exception { int expectedVersion = SdkProtocolVersion.get(); var params = new HashMap(); @@ -200,9 +202,10 @@ private void verifyProtocolVersion(Connection connection) throws Exception { + "Please update your server to ensure compatibility."); } - if (pingResponse.protocolVersion() != expectedVersion) { + int serverVersion = pingResponse.protocolVersion(); + if (serverVersion < MIN_PROTOCOL_VERSION || serverVersion > expectedVersion) { throw new RuntimeException("SDK protocol version mismatch: SDK expects version " + expectedVersion - + ", but server reports version " + pingResponse.protocolVersion() + ". " + + " (minimum " + MIN_PROTOCOL_VERSION + "), but server reports version " + serverVersion + ". " + "Please update your SDK or server to ensure compatibility."); } } @@ -319,13 +322,32 @@ public CompletableFuture createSession(SessionConfig config) { + "new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL)")); } return ensureConnected().thenCompose(connection -> { - var request = SessionRequestBuilder.buildCreateRequest(config); + // Pre-generate session ID so the session can be registered before the RPC call, + // ensuring no events emitted by the CLI during creation are lost. + String sessionId = config.getSessionId() != null + ? config.getSessionId() + : java.util.UUID.randomUUID().toString(); + + var session = new CopilotSession(sessionId, connection.rpc); + SessionRequestBuilder.configureSession(session, config); + sessions.put(sessionId, session); + + var request = SessionRequestBuilder.buildCreateRequest(config, sessionId); return connection.rpc.invoke("session.create", request, CreateSessionResponse.class).thenApply(response -> { - var session = new CopilotSession(response.sessionId(), connection.rpc, response.workspacePath()); - SessionRequestBuilder.configureSession(session, config); - sessions.put(response.sessionId(), session); + session.setWorkspacePath(response.workspacePath()); + // If the server returned a different sessionId (e.g. a v2 CLI that ignores + // the client-supplied ID), re-key the sessions map. + String returnedId = response.sessionId(); + if (returnedId != null && !returnedId.equals(sessionId)) { + sessions.remove(sessionId); + session.setActiveSessionId(returnedId); + sessions.put(returnedId, session); + } return session; + }).exceptionally(ex -> { + sessions.remove(sessionId); + throw ex instanceof RuntimeException re ? re : new RuntimeException(ex); }); }); } @@ -363,13 +385,26 @@ public CompletableFuture resumeSession(String sessionId, ResumeS + "new ResumeSessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL)")); } return ensureConnected().thenCompose(connection -> { + // Register the session before the RPC call to avoid missing early events. + var session = new CopilotSession(sessionId, connection.rpc); + SessionRequestBuilder.configureSession(session, config); + sessions.put(sessionId, session); + var request = SessionRequestBuilder.buildResumeRequest(sessionId, config); return connection.rpc.invoke("session.resume", request, ResumeSessionResponse.class).thenApply(response -> { - var session = new CopilotSession(response.sessionId(), connection.rpc, response.workspacePath()); - SessionRequestBuilder.configureSession(session, config); - sessions.put(response.sessionId(), session); + session.setWorkspacePath(response.workspacePath()); + // If the server returned a different sessionId than what was requested, re-key. + String returnedId = response.sessionId(); + if (returnedId != null && !returnedId.equals(sessionId)) { + sessions.remove(sessionId); + session.setActiveSessionId(returnedId); + sessions.put(returnedId, session); + } return session; + }).exceptionally(ex -> { + sessions.remove(sessionId); + throw ex instanceof RuntimeException re ? re : new RuntimeException(ex); }); }); } @@ -434,6 +469,10 @@ public CompletableFuture getAuthStatus() { *

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

+ * If an {@code onListModels} handler was provided in + * {@link com.github.copilot.sdk.json.CopilotClientOptions}, it is called + * instead of querying the CLI server. This is useful in BYOK mode. * * @return a future that resolves with a list of available models * @see ModelInfo @@ -445,6 +484,22 @@ public CompletableFuture> listModels() { return CompletableFuture.completedFuture(new ArrayList<>(cached)); } + // If a custom handler is configured, use it instead of querying the CLI server + var onListModels = options.getOnListModels(); + if (onListModels != null) { + synchronized (modelsCacheLock) { + if (modelsCache != null) { + return CompletableFuture.completedFuture(new ArrayList<>(modelsCache)); + } + } + return onListModels.get().thenApply(models -> { + synchronized (modelsCacheLock) { + modelsCache = models; + } + return new ArrayList<>(models); + }); + } + return ensureConnected().thenCompose(connection -> { // Double-check cache inside lock synchronized (modelsCacheLock) { diff --git a/src/main/java/com/github/copilot/sdk/CopilotSession.java b/src/main/java/com/github/copilot/sdk/CopilotSession.java index 80b2e85ea..c19fe72f3 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; @@ -46,6 +48,7 @@ import com.github.copilot.sdk.json.SessionHooks; import com.github.copilot.sdk.json.SessionStartHookInput; import com.github.copilot.sdk.json.ToolDefinition; +import com.github.copilot.sdk.json.ToolResultObject; import com.github.copilot.sdk.json.UserInputHandler; import com.github.copilot.sdk.json.UserInputInvocation; import com.github.copilot.sdk.json.UserInputRequest; @@ -100,8 +103,14 @@ public final class CopilotSession implements AutoCloseable { private static final Logger LOG = Logger.getLogger(CopilotSession.class.getName()); private static final ObjectMapper MAPPER = JsonRpcClient.getObjectMapper(); - private final String sessionId; - private final String workspacePath; + /** + * The current active session ID. Initialized to the pre-generated value and may + * be updated after session.create / session.resume if the server returns a + * different ID (e.g. when working against a v2 CLI that ignores the + * client-supplied sessionId). + */ + private volatile String sessionId; + private volatile String workspacePath; private final JsonRpcClient rpc; private final Set> eventHandlers = ConcurrentHashMap.newKeySet(); private final Map toolHandlers = new ConcurrentHashMap<>(); @@ -157,6 +166,18 @@ public String getSessionId() { return sessionId; } + /** + * Updates the active session ID. Package-private; called by CopilotClient if + * the server returns a different session ID than the pre-generated one (e.g. + * when a v2 CLI ignores the client-supplied sessionId). + * + * @param sessionId + * the server-confirmed session ID + */ + void setActiveSessionId(String sessionId) { + this.sessionId = sessionId; + } + /** * Gets the path to the session workspace directory when infinite sessions are * enabled. @@ -170,6 +191,17 @@ public String getWorkspacePath() { return workspacePath; } + /** + * Sets the workspace path. Package-private; called by CopilotClient after + * session.create or session.resume RPC response. + * + * @param workspacePath + * the workspace path + */ + void setWorkspacePath(String workspacePath) { + this.workspacePath = workspacePath; + } + /** * Sets a custom error handler for exceptions thrown by event handlers. *

@@ -551,6 +583,10 @@ public Closeable on(Class eventType, Consume * @see #setEventErrorPolicy(EventErrorPolicy) */ void dispatchEvent(AbstractSessionEvent event) { + // Handle broadcast request events (protocol v3) before dispatching to user + // handlers. These are fire-and-forget: the response is sent asynchronously. + handleBroadcastEventAsync(event); + for (Consumer handler : eventHandlers) { try { handler.accept(event); @@ -572,6 +608,120 @@ 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); + } + }); + } + + /** + * 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("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); + } + }); + } + /** * Registers custom tool handlers for this session. *

@@ -837,6 +987,61 @@ public CompletableFuture setModel(String model) { return rpc.invoke("session.model.switchTo", Map.of("sessionId", sessionId, "modelId", model), Void.class); } + /** + * Logs a message to the session timeline. + *

+ * The message appears in the session event stream and is visible to SDK + * consumers. Non-ephemeral messages are also persisted to the session event log + * on disk. + * + *

Example Usage

+ * + *
{@code
+     * session.log("Build completed successfully").get();
+     * session.log("Disk space low", "warning", null).get();
+     * session.log("Temporary status", null, true).get();
+     * }
+ * + * @param message + * the message to log + * @param level + * the log severity level ({@code "info"}, {@code "warning"}, + * {@code "error"}), or {@code null} to use the default + * ({@code "info"}) + * @param ephemeral + * when {@code true}, the message is transient and not persisted to + * disk; {@code null} uses default behavior + * @return a future that completes when the message is logged + * @throws IllegalStateException + * if this session has been terminated + */ + public CompletableFuture log(String message, String level, Boolean ephemeral) { + ensureNotTerminated(); + var params = new java.util.HashMap(); + params.put("sessionId", sessionId); + params.put("message", message); + if (level != null) { + params.put("level", level); + } + if (ephemeral != null) { + params.put("ephemeral", ephemeral); + } + return rpc.invoke("session.log", params, Void.class); + } + + /** + * Logs an informational message to the session timeline. + * + * @param message + * the message to log + * @return a future that completes when the message is logged + * @throws IllegalStateException + * if this session has been terminated + */ + public CompletableFuture log(String message) { + return log(message, null, null); + } + /** * Lists the custom agents available for selection in this session. * diff --git a/src/main/java/com/github/copilot/sdk/SdkProtocolVersion.java b/src/main/java/com/github/copilot/sdk/SdkProtocolVersion.java index 40a7f9d56..3b00a88ae 100644 --- a/src/main/java/com/github/copilot/sdk/SdkProtocolVersion.java +++ b/src/main/java/com/github/copilot/sdk/SdkProtocolVersion.java @@ -14,7 +14,7 @@ */ public enum SdkProtocolVersion { - LATEST(2); + LATEST(3); private int versionNumber; diff --git a/src/main/java/com/github/copilot/sdk/SessionRequestBuilder.java b/src/main/java/com/github/copilot/sdk/SessionRequestBuilder.java index 90f3c71d8..7ea0be880 100644 --- a/src/main/java/com/github/copilot/sdk/SessionRequestBuilder.java +++ b/src/main/java/com/github/copilot/sdk/SessionRequestBuilder.java @@ -27,20 +27,22 @@ private SessionRequestBuilder() { * * @param config * the session configuration (may be null) + * @param sessionId + * the pre-generated session ID to use * @return the built request object */ - static CreateSessionRequest buildCreateRequest(SessionConfig config) { + static CreateSessionRequest buildCreateRequest(SessionConfig config, String sessionId) { var request = new CreateSessionRequest(); // Always request permission callbacks to enable deny-by-default behavior request.setRequestPermission(true); // Always send envValueMode=direct for MCP servers request.setEnvValueMode("direct"); + request.setSessionId(sessionId); if (config == null) { return request; } request.setModel(config.getModel()); - request.setSessionId(config.getSessionId()); request.setClientName(config.getClientName()); request.setReasoningEffort(config.getReasoningEffort()); request.setTools(config.getTools()); @@ -54,6 +56,7 @@ static CreateSessionRequest buildCreateRequest(SessionConfig config) { request.setStreaming(config.isStreaming() ? true : null); request.setMcpServers(config.getMcpServers()); request.setCustomAgents(config.getCustomAgents()); + request.setAgent(config.getAgent()); request.setInfiniteSessions(config.getInfiniteSessions()); request.setSkillDirectories(config.getSkillDirectories()); request.setDisabledSkills(config.getDisabledSkills()); @@ -62,6 +65,22 @@ static CreateSessionRequest buildCreateRequest(SessionConfig config) { return request; } + /** + * Builds a CreateSessionRequest from the given configuration. + * + * @param config + * the session configuration (may be null) + * @return the built request object + * @deprecated Use {@link #buildCreateRequest(SessionConfig, String)} instead. + */ + @Deprecated + static CreateSessionRequest buildCreateRequest(SessionConfig config) { + String sessionId = (config != null && config.getSessionId() != null) + ? config.getSessionId() + : java.util.UUID.randomUUID().toString(); + return buildCreateRequest(config, sessionId); + } + /** * Builds a ResumeSessionRequest from the given session ID and configuration. * @@ -99,6 +118,7 @@ static ResumeSessionRequest buildResumeRequest(String sessionId, ResumeSessionCo request.setStreaming(config.isStreaming() ? true : null); request.setMcpServers(config.getMcpServers()); request.setCustomAgents(config.getCustomAgents()); + request.setAgent(config.getAgent()); request.setSkillDirectories(config.getSkillDirectories()); request.setDisabledSkills(config.getDisabledSkills()); request.setInfiniteSessions(config.getInfiniteSessions()); @@ -131,6 +151,9 @@ static void configureSession(CopilotSession session, SessionConfig config) { if (config.getHooks() != null) { session.registerHooks(config.getHooks()); } + if (config.getOnEvent() != null) { + session.on(config.getOnEvent()); + } } /** @@ -158,5 +181,8 @@ static void configureSession(CopilotSession session, ResumeSessionConfig config) if (config.getHooks() != null) { session.registerHooks(config.getHooks()); } + if (config.getOnEvent() != null) { + session.on(config.getOnEvent()); + } } } diff --git a/src/main/java/com/github/copilot/sdk/events/AbstractSessionEvent.java b/src/main/java/com/github/copilot/sdk/events/AbstractSessionEvent.java index 6c9d76e99..5127f6eee 100644 --- a/src/main/java/com/github/copilot/sdk/events/AbstractSessionEvent.java +++ b/src/main/java/com/github/copilot/sdk/events/AbstractSessionEvent.java @@ -63,6 +63,10 @@ public abstract sealed class AbstractSessionEvent permits // Tool events ToolUserRequestedEvent, ToolExecutionStartEvent, ToolExecutionPartialResultEvent, ToolExecutionProgressEvent, ToolExecutionCompleteEvent, + // Broadcast request/completion events (protocol v3) + ExternalToolRequestedEvent, ExternalToolCompletedEvent, PermissionRequestedEvent, PermissionCompletedEvent, + CommandQueuedEvent, CommandCompletedEvent, ExitPlanModeRequestedEvent, ExitPlanModeCompletedEvent, + SystemNotificationEvent, // User events UserMessageEvent, PendingMessagesModifiedEvent, // Skill events diff --git a/src/main/java/com/github/copilot/sdk/events/CommandCompletedEvent.java b/src/main/java/com/github/copilot/sdk/events/CommandCompletedEvent.java new file mode 100644 index 000000000..f9aeb0f3f --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/events/CommandCompletedEvent.java @@ -0,0 +1,37 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk.events; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Event: command.completed + * + * @since 1.0.0 + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class CommandCompletedEvent extends AbstractSessionEvent { + + @JsonProperty("data") + private CommandCompletedData data; + + @Override + public String getType() { + return "command.completed"; + } + + public CommandCompletedData getData() { + return data; + } + + public void setData(CommandCompletedData data) { + this.data = data; + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record CommandCompletedData(@JsonProperty("requestId") String requestId) { + } +} diff --git a/src/main/java/com/github/copilot/sdk/events/CommandQueuedEvent.java b/src/main/java/com/github/copilot/sdk/events/CommandQueuedEvent.java new file mode 100644 index 000000000..acd35a89c --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/events/CommandQueuedEvent.java @@ -0,0 +1,38 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk.events; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Event: command.queued + * + * @since 1.0.0 + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class CommandQueuedEvent extends AbstractSessionEvent { + + @JsonProperty("data") + private CommandQueuedData data; + + @Override + public String getType() { + return "command.queued"; + } + + public CommandQueuedData getData() { + return data; + } + + public void setData(CommandQueuedData data) { + this.data = data; + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record CommandQueuedData(@JsonProperty("requestId") String requestId, + @JsonProperty("command") String command) { + } +} diff --git a/src/main/java/com/github/copilot/sdk/events/ExitPlanModeCompletedEvent.java b/src/main/java/com/github/copilot/sdk/events/ExitPlanModeCompletedEvent.java new file mode 100644 index 000000000..217859e43 --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/events/ExitPlanModeCompletedEvent.java @@ -0,0 +1,37 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk.events; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Event: exit_plan_mode.completed + * + * @since 1.0.0 + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class ExitPlanModeCompletedEvent extends AbstractSessionEvent { + + @JsonProperty("data") + private ExitPlanModeCompletedData data; + + @Override + public String getType() { + return "exit_plan_mode.completed"; + } + + public ExitPlanModeCompletedData getData() { + return data; + } + + public void setData(ExitPlanModeCompletedData data) { + this.data = data; + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record ExitPlanModeCompletedData(@JsonProperty("requestId") String requestId) { + } +} diff --git a/src/main/java/com/github/copilot/sdk/events/ExitPlanModeRequestedEvent.java b/src/main/java/com/github/copilot/sdk/events/ExitPlanModeRequestedEvent.java new file mode 100644 index 000000000..b0019c3ce --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/events/ExitPlanModeRequestedEvent.java @@ -0,0 +1,39 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk.events; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Event: exit_plan_mode.requested + * + * @since 1.0.0 + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class ExitPlanModeRequestedEvent extends AbstractSessionEvent { + + @JsonProperty("data") + private ExitPlanModeRequestedData data; + + @Override + public String getType() { + return "exit_plan_mode.requested"; + } + + public ExitPlanModeRequestedData getData() { + return data; + } + + public void setData(ExitPlanModeRequestedData data) { + this.data = data; + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record ExitPlanModeRequestedData(@JsonProperty("requestId") String requestId, + @JsonProperty("summary") String summary, @JsonProperty("planContent") String planContent, + @JsonProperty("actions") String[] actions, @JsonProperty("recommendedAction") String recommendedAction) { + } +} diff --git a/src/main/java/com/github/copilot/sdk/events/ExternalToolCompletedEvent.java b/src/main/java/com/github/copilot/sdk/events/ExternalToolCompletedEvent.java new file mode 100644 index 000000000..83a582720 --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/events/ExternalToolCompletedEvent.java @@ -0,0 +1,40 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk.events; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Event: external_tool.completed + *

+ * Broadcast when a pending tool call has been resolved by a client (protocol + * v3). + * + * @since 1.0.0 + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class ExternalToolCompletedEvent extends AbstractSessionEvent { + + @JsonProperty("data") + private ExternalToolCompletedData data; + + @Override + public String getType() { + return "external_tool.completed"; + } + + public ExternalToolCompletedData getData() { + return data; + } + + public void setData(ExternalToolCompletedData data) { + this.data = data; + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record ExternalToolCompletedData(@JsonProperty("requestId") String requestId) { + } +} diff --git a/src/main/java/com/github/copilot/sdk/events/ExternalToolRequestedEvent.java b/src/main/java/com/github/copilot/sdk/events/ExternalToolRequestedEvent.java new file mode 100644 index 000000000..8eb11f5b8 --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/events/ExternalToolRequestedEvent.java @@ -0,0 +1,43 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk.events; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Event: external_tool.requested + *

+ * Broadcast when the CLI needs a client to handle a tool call (protocol v3). + * Clients that own the named tool should respond via + * {@code session.tools.handlePendingToolCall}. + * + * @since 1.0.0 + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class ExternalToolRequestedEvent extends AbstractSessionEvent { + + @JsonProperty("data") + private ExternalToolRequestedData data; + + @Override + public String getType() { + return "external_tool.requested"; + } + + public ExternalToolRequestedData getData() { + return data; + } + + public void setData(ExternalToolRequestedData data) { + this.data = data; + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record ExternalToolRequestedData(@JsonProperty("requestId") String requestId, + @JsonProperty("sessionId") String sessionId, @JsonProperty("toolCallId") String toolCallId, + @JsonProperty("toolName") String toolName, @JsonProperty("arguments") Object arguments) { + } +} diff --git a/src/main/java/com/github/copilot/sdk/events/PermissionCompletedEvent.java b/src/main/java/com/github/copilot/sdk/events/PermissionCompletedEvent.java new file mode 100644 index 000000000..90daf3b49 --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/events/PermissionCompletedEvent.java @@ -0,0 +1,45 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk.events; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Event: permission.completed + *

+ * Broadcast when a pending permission request has been resolved by a client + * (protocol v3). + * + * @since 1.0.0 + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class PermissionCompletedEvent extends AbstractSessionEvent { + + @JsonProperty("data") + private PermissionCompletedData data; + + @Override + public String getType() { + return "permission.completed"; + } + + public PermissionCompletedData getData() { + return data; + } + + public void setData(PermissionCompletedData data) { + this.data = data; + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record PermissionCompletedData(@JsonProperty("requestId") String requestId, + @JsonProperty("result") PermissionCompletedResult result) { + + @JsonIgnoreProperties(ignoreUnknown = true) + public record PermissionCompletedResult(@JsonProperty("kind") String kind) { + } + } +} diff --git a/src/main/java/com/github/copilot/sdk/events/PermissionRequestedEvent.java b/src/main/java/com/github/copilot/sdk/events/PermissionRequestedEvent.java new file mode 100644 index 000000000..d8f9ec147 --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/events/PermissionRequestedEvent.java @@ -0,0 +1,43 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk.events; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.github.copilot.sdk.json.PermissionRequest; + +/** + * Event: permission.requested + *

+ * Broadcast when the CLI needs a client to handle a permission request + * (protocol v3). Clients that have a permission handler should respond via + * {@code session.permissions.handlePendingPermissionRequest}. + * + * @since 1.0.0 + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class PermissionRequestedEvent extends AbstractSessionEvent { + + @JsonProperty("data") + private PermissionRequestedData data; + + @Override + public String getType() { + return "permission.requested"; + } + + public PermissionRequestedData getData() { + return data; + } + + public void setData(PermissionRequestedData data) { + this.data = data; + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record PermissionRequestedData(@JsonProperty("requestId") String requestId, + @JsonProperty("permissionRequest") PermissionRequest permissionRequest) { + } +} diff --git a/src/main/java/com/github/copilot/sdk/events/SessionEventParser.java b/src/main/java/com/github/copilot/sdk/events/SessionEventParser.java index cfe8d5711..75971b29e 100644 --- a/src/main/java/com/github/copilot/sdk/events/SessionEventParser.java +++ b/src/main/java/com/github/copilot/sdk/events/SessionEventParser.java @@ -93,6 +93,15 @@ public class SessionEventParser { TYPE_MAP.put("system.message", SystemMessageEvent.class); TYPE_MAP.put("session.shutdown", SessionShutdownEvent.class); TYPE_MAP.put("skill.invoked", SkillInvokedEvent.class); + TYPE_MAP.put("external_tool.requested", ExternalToolRequestedEvent.class); + TYPE_MAP.put("external_tool.completed", ExternalToolCompletedEvent.class); + TYPE_MAP.put("permission.requested", PermissionRequestedEvent.class); + TYPE_MAP.put("permission.completed", PermissionCompletedEvent.class); + TYPE_MAP.put("command.queued", CommandQueuedEvent.class); + TYPE_MAP.put("command.completed", CommandCompletedEvent.class); + TYPE_MAP.put("exit_plan_mode.requested", ExitPlanModeRequestedEvent.class); + TYPE_MAP.put("exit_plan_mode.completed", ExitPlanModeCompletedEvent.class); + TYPE_MAP.put("system.notification", SystemNotificationEvent.class); } /** diff --git a/src/main/java/com/github/copilot/sdk/events/SystemNotificationEvent.java b/src/main/java/com/github/copilot/sdk/events/SystemNotificationEvent.java new file mode 100644 index 000000000..38711f276 --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/events/SystemNotificationEvent.java @@ -0,0 +1,37 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk.events; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Event: system.notification + * + * @since 1.0.0 + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class SystemNotificationEvent extends AbstractSessionEvent { + + @JsonProperty("data") + private SystemNotificationData data; + + @Override + public String getType() { + return "system.notification"; + } + + public SystemNotificationData getData() { + return data; + } + + public void setData(SystemNotificationData data) { + this.data = data; + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record SystemNotificationData(@JsonProperty("content") String content, @JsonProperty("kind") Object kind) { + } +} diff --git a/src/main/java/com/github/copilot/sdk/json/CopilotClientOptions.java b/src/main/java/com/github/copilot/sdk/json/CopilotClientOptions.java index 70ce99850..4fd55d3ba 100644 --- a/src/main/java/com/github/copilot/sdk/json/CopilotClientOptions.java +++ b/src/main/java/com/github/copilot/sdk/json/CopilotClientOptions.java @@ -4,7 +4,10 @@ package com.github.copilot.sdk.json; +import java.util.List; import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.function.Supplier; import com.fasterxml.jackson.annotation.JsonInclude; @@ -43,6 +46,7 @@ public class CopilotClientOptions { private Map environment; private String gitHubToken; private Boolean useLoggedInUser; + private Supplier>> onListModels; /** * Gets the path to the Copilot CLI executable. @@ -349,6 +353,31 @@ public CopilotClientOptions setUseLoggedInUser(Boolean useLoggedInUser) { return this; } + /** + * Gets the custom handler for listing available models. + * + * @return the handler, or {@code null} if not set + */ + public Supplier>> getOnListModels() { + return onListModels; + } + + /** + * Sets a custom handler for listing available models. + *

+ * When provided, {@code listModels()} calls this handler instead of querying + * the CLI server. Useful in BYOK (Bring Your Own Key) mode to return models + * available from your custom provider. + * + * @param onListModels + * the handler that returns the list of available models + * @return this options instance for method chaining + */ + public CopilotClientOptions setOnListModels(Supplier>> onListModels) { + this.onListModels = onListModels; + return this; + } + /** * Creates a shallow clone of this {@code CopilotClientOptions} instance. *

@@ -374,6 +403,7 @@ public CopilotClientOptions clone() { copy.environment = this.environment != null ? new java.util.HashMap<>(this.environment) : null; copy.gitHubToken = this.gitHubToken; copy.useLoggedInUser = this.useLoggedInUser; + copy.onListModels = this.onListModels; return copy; } } diff --git a/src/main/java/com/github/copilot/sdk/json/CreateSessionRequest.java b/src/main/java/com/github/copilot/sdk/json/CreateSessionRequest.java index d73d82e6a..c0243f14b 100644 --- a/src/main/java/com/github/copilot/sdk/json/CreateSessionRequest.java +++ b/src/main/java/com/github/copilot/sdk/json/CreateSessionRequest.java @@ -76,6 +76,9 @@ public final class CreateSessionRequest { @JsonProperty("customAgents") private List customAgents; + @JsonProperty("agent") + private String agent; + @JsonProperty("infiniteSessions") private InfiniteSessionConfig infiniteSessions; @@ -260,6 +263,16 @@ public void setCustomAgents(List customAgents) { this.customAgents = customAgents; } + /** Gets the pre-selected agent name. @return the agent name */ + public String getAgent() { + return agent; + } + + /** Sets the pre-selected agent name. @param agent the agent name */ + public void setAgent(String agent) { + this.agent = agent; + } + /** Gets infinite sessions config. @return the config */ public InfiniteSessionConfig getInfiniteSessions() { return infiniteSessions; diff --git a/src/main/java/com/github/copilot/sdk/json/ResumeSessionConfig.java b/src/main/java/com/github/copilot/sdk/json/ResumeSessionConfig.java index 0682699bc..eab3c789c 100644 --- a/src/main/java/com/github/copilot/sdk/json/ResumeSessionConfig.java +++ b/src/main/java/com/github/copilot/sdk/json/ResumeSessionConfig.java @@ -8,9 +8,12 @@ import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.function.Consumer; import com.fasterxml.jackson.annotation.JsonInclude; +import com.github.copilot.sdk.events.AbstractSessionEvent; + /** * Configuration for resuming an existing Copilot session. *

@@ -50,9 +53,11 @@ public class ResumeSessionConfig { private boolean streaming; private Map mcpServers; private List customAgents; + private String agent; private List skillDirectories; private List disabledSkills; private InfiniteSessionConfig infiniteSessions; + private Consumer onEvent; /** * Gets the AI model to use. @@ -436,6 +441,29 @@ public ResumeSessionConfig setCustomAgents(List customAgents) return this; } + /** + * Gets the name of the custom agent to activate at session start. + * + * @return the agent name, or {@code null} if not set + */ + public String getAgent() { + return agent; + } + + /** + * Sets the name of the custom agent to activate when the session starts. + *

+ * Must match the name of one of the agents in {@link #setCustomAgents(List)}. + * + * @param agent + * the agent name to pre-select + * @return this config for method chaining + */ + public ResumeSessionConfig setAgent(String agent) { + this.agent = agent; + return this; + } + /** * Gets the skill directories. * @@ -501,6 +529,32 @@ public ResumeSessionConfig setInfiniteSessions(InfiniteSessionConfig infiniteSes return this; } + /** + * Gets the event handler registered before the session.resume RPC is issued. + * + * @return the event handler, or {@code null} if not set + */ + public Consumer getOnEvent() { + return onEvent; + } + + /** + * Sets an event handler that is registered on the session before the + * {@code session.resume} RPC is issued. + *

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

@@ -532,9 +586,11 @@ public ResumeSessionConfig clone() { copy.streaming = this.streaming; copy.mcpServers = this.mcpServers != null ? new java.util.HashMap<>(this.mcpServers) : null; copy.customAgents = this.customAgents != null ? new ArrayList<>(this.customAgents) : null; + copy.agent = this.agent; copy.skillDirectories = this.skillDirectories != null ? new ArrayList<>(this.skillDirectories) : null; copy.disabledSkills = this.disabledSkills != null ? new ArrayList<>(this.disabledSkills) : null; copy.infiniteSessions = this.infiniteSessions; + copy.onEvent = this.onEvent; return copy; } } diff --git a/src/main/java/com/github/copilot/sdk/json/ResumeSessionRequest.java b/src/main/java/com/github/copilot/sdk/json/ResumeSessionRequest.java index 4216e5eef..31d88399a 100644 --- a/src/main/java/com/github/copilot/sdk/json/ResumeSessionRequest.java +++ b/src/main/java/com/github/copilot/sdk/json/ResumeSessionRequest.java @@ -83,6 +83,9 @@ public final class ResumeSessionRequest { @JsonProperty("customAgents") private List customAgents; + @JsonProperty("agent") + private String agent; + @JsonProperty("skillDirectories") private List skillDirectories; @@ -287,6 +290,16 @@ public void setCustomAgents(List customAgents) { this.customAgents = customAgents; } + /** Gets the pre-selected agent name. @return the agent name */ + public String getAgent() { + return agent; + } + + /** Sets the pre-selected agent name. @param agent the agent name */ + public void setAgent(String agent) { + this.agent = agent; + } + /** Gets skill directories. @return the directories */ public List getSkillDirectories() { return skillDirectories == null ? null : Collections.unmodifiableList(skillDirectories); diff --git a/src/main/java/com/github/copilot/sdk/json/SessionConfig.java b/src/main/java/com/github/copilot/sdk/json/SessionConfig.java index bfed0608e..76c15660d 100644 --- a/src/main/java/com/github/copilot/sdk/json/SessionConfig.java +++ b/src/main/java/com/github/copilot/sdk/json/SessionConfig.java @@ -8,9 +8,12 @@ import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.function.Consumer; import com.fasterxml.jackson.annotation.JsonInclude; +import com.github.copilot.sdk.events.AbstractSessionEvent; + /** * Configuration for creating a new Copilot session. *

@@ -49,10 +52,12 @@ public class SessionConfig { private boolean streaming; private Map mcpServers; private List customAgents; + private String agent; private InfiniteSessionConfig infiniteSessions; private List skillDirectories; private List disabledSkills; private String configDir; + private Consumer onEvent; /** * Gets the custom session ID. @@ -438,6 +443,29 @@ public SessionConfig setCustomAgents(List customAgents) { return this; } + /** + * Gets the name of the custom agent to activate at session start. + * + * @return the agent name, or {@code null} if not set + */ + public String getAgent() { + return agent; + } + + /** + * Sets the name of the custom agent to activate when the session starts. + *

+ * Must match the name of one of the agents in {@link #setCustomAgents(List)}. + * + * @param agent + * the agent name to pre-select + * @return this config instance for method chaining + */ + public SessionConfig setAgent(String agent) { + this.agent = agent; + return this; + } + /** * Gets the infinite sessions configuration. * @@ -538,6 +566,35 @@ public SessionConfig setConfigDir(String configDir) { return this; } + /** + * Gets the event handler registered before the session.create RPC is issued. + * + * @return the event handler, or {@code null} if not set + */ + public Consumer getOnEvent() { + return onEvent; + } + + /** + * Sets an event handler that is registered on the session before the + * {@code session.create} RPC is issued. + *

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

@@ -568,10 +625,12 @@ public SessionConfig clone() { copy.streaming = this.streaming; copy.mcpServers = this.mcpServers != null ? new java.util.HashMap<>(this.mcpServers) : null; copy.customAgents = this.customAgents != null ? new ArrayList<>(this.customAgents) : null; + copy.agent = this.agent; copy.infiniteSessions = this.infiniteSessions; copy.skillDirectories = this.skillDirectories != null ? new ArrayList<>(this.skillDirectories) : null; copy.disabledSkills = this.disabledSkills != null ? new ArrayList<>(this.disabledSkills) : null; copy.configDir = this.configDir; + copy.onEvent = this.onEvent; return copy; } } diff --git a/src/test/java/com/github/copilot/sdk/ConfigCloneTest.java b/src/test/java/com/github/copilot/sdk/ConfigCloneTest.java index e1269a669..f3eceb4c2 100644 --- a/src/test/java/com/github/copilot/sdk/ConfigCloneTest.java +++ b/src/test/java/com/github/copilot/sdk/ConfigCloneTest.java @@ -10,13 +10,17 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.function.Consumer; import org.junit.jupiter.api.Test; +import com.github.copilot.sdk.events.AbstractSessionEvent; import com.github.copilot.sdk.json.CopilotClientOptions; -import com.github.copilot.sdk.json.SessionConfig; -import com.github.copilot.sdk.json.ResumeSessionConfig; import com.github.copilot.sdk.json.MessageOptions; +import com.github.copilot.sdk.json.ModelInfo; +import com.github.copilot.sdk.json.ResumeSessionConfig; +import com.github.copilot.sdk.json.SessionConfig; class ConfigCloneTest { @@ -68,6 +72,18 @@ void copilotClientOptionsEnvironmentIndependence() { assertEquals(2, original.getEnvironment().size()); } + @Test + void copilotClientOptionsOnListModelsCloned() { + CopilotClientOptions original = new CopilotClientOptions(); + List models = List.of(new ModelInfo()); + original.setOnListModels(() -> CompletableFuture.completedFuture(models)); + + CopilotClientOptions cloned = original.clone(); + + assertNotNull(cloned.getOnListModels()); + assertSame(original.getOnListModels(), cloned.getOnListModels()); + } + @Test void sessionConfigCloneBasic() { SessionConfig original = new SessionConfig(); @@ -102,6 +118,20 @@ void sessionConfigListIndependence() { assertEquals(3, original.getAvailableTools().size()); } + @Test + void sessionConfigAgentAndOnEventCloned() { + Consumer handler = event -> { + }; + SessionConfig original = new SessionConfig(); + original.setAgent("my-agent"); + original.setOnEvent(handler); + + SessionConfig cloned = original.clone(); + + assertEquals("my-agent", cloned.getAgent()); + assertSame(handler, cloned.getOnEvent()); + } + @Test void resumeSessionConfigCloneBasic() { ResumeSessionConfig original = new ResumeSessionConfig(); @@ -114,6 +144,20 @@ void resumeSessionConfigCloneBasic() { assertEquals(original.isStreaming(), cloned.isStreaming()); } + @Test + void resumeSessionConfigAgentAndOnEventCloned() { + Consumer handler = event -> { + }; + ResumeSessionConfig original = new ResumeSessionConfig(); + original.setAgent("my-agent"); + original.setOnEvent(handler); + + ResumeSessionConfig cloned = original.clone(); + + assertEquals("my-agent", cloned.getAgent()); + assertSame(handler, cloned.getOnEvent()); + } + @Test void messageOptionsCloneBasic() { MessageOptions original = new MessageOptions(); diff --git a/src/test/java/com/github/copilot/sdk/CopilotClientTest.java b/src/test/java/com/github/copilot/sdk/CopilotClientTest.java index 1f4f5193b..bc5869fda 100644 --- a/src/test/java/com/github/copilot/sdk/CopilotClientTest.java +++ b/src/test/java/com/github/copilot/sdk/CopilotClientTest.java @@ -494,4 +494,69 @@ void testNullOptionsDefaultsToEmpty() { assertEquals(ConnectionState.DISCONNECTED, client.getState()); } } + + // ===== OnListModels ===== + + @Test + void testListModels_WithCustomHandler_CallsHandler() throws Exception { + var customModels = new ArrayList(); + var model = new com.github.copilot.sdk.json.ModelInfo(); + model.setId("my-custom-model"); + customModels.add(model); + + var callCount = new int[]{0}; + var options = new CopilotClientOptions().setOnListModels(() -> { + callCount[0]++; + return CompletableFuture.completedFuture(new ArrayList<>(customModels)); + }); + + try (var client = new CopilotClient(options)) { + var models = client.listModels().get(); + assertEquals(1, callCount[0]); + assertEquals(1, models.size()); + assertEquals("my-custom-model", models.get(0).getId()); + } + } + + @Test + void testListModels_WithCustomHandler_CachesResults() throws Exception { + var customModels = new ArrayList(); + var model = new com.github.copilot.sdk.json.ModelInfo(); + model.setId("cached-model"); + customModels.add(model); + + var callCount = new int[]{0}; + var options = new CopilotClientOptions().setOnListModels(() -> { + callCount[0]++; + return CompletableFuture.completedFuture(new ArrayList<>(customModels)); + }); + + try (var client = new CopilotClient(options)) { + client.listModels().get(); + client.listModels().get(); + assertEquals(1, callCount[0], "Handler should be called only once due to caching"); + } + } + + @Test + void testListModels_WithCustomHandler_WorksWithoutStart() throws Exception { + var customModels = new ArrayList(); + var model = new com.github.copilot.sdk.json.ModelInfo(); + model.setId("no-start-model"); + customModels.add(model); + + var callCount = new int[]{0}; + var options = new CopilotClientOptions().setOnListModels(() -> { + callCount[0]++; + return CompletableFuture.completedFuture(new ArrayList<>(customModels)); + }); + + // No start() needed when onListModels is provided + try (var client = new CopilotClient(options)) { + var models = client.listModels().get(); + assertEquals(1, callCount[0]); + assertEquals(1, models.size()); + assertEquals("no-start-model", models.get(0).getId()); + } + } } diff --git a/src/test/java/com/github/copilot/sdk/MetadataApiTest.java b/src/test/java/com/github/copilot/sdk/MetadataApiTest.java index b3eb8fcb7..580c58153 100644 --- a/src/test/java/com/github/copilot/sdk/MetadataApiTest.java +++ b/src/test/java/com/github/copilot/sdk/MetadataApiTest.java @@ -327,7 +327,7 @@ void testListModels() throws Exception { // ===== Protocol Version Test ===== @Test - void testProtocolVersionIsTwo() { - assertEquals(2, SdkProtocolVersion.get()); + void testProtocolVersionIsThree() { + assertEquals(3, SdkProtocolVersion.get()); } } diff --git a/src/test/java/com/github/copilot/sdk/SessionEventParserTest.java b/src/test/java/com/github/copilot/sdk/SessionEventParserTest.java index 65129ce7e..d4770a721 100644 --- a/src/test/java/com/github/copilot/sdk/SessionEventParserTest.java +++ b/src/test/java/com/github/copilot/sdk/SessionEventParserTest.java @@ -2198,4 +2198,186 @@ void testParseJsonNodeUserMessageWithAttachment() throws Exception { assertEquals(0, att.selection().start().line()); assertEquals(14, att.selection().end().character()); } + + @Test + void testParseExternalToolRequestedEvent() throws Exception { + String json = """ + { + "type": "external_tool.requested", + "data": { + "requestId": "req-123", + "sessionId": "sess-456", + "toolCallId": "call-789", + "toolName": "get_weather", + "arguments": {"location": "Seattle"} + } + } + """; + + var event = (ExternalToolRequestedEvent) parseJson(json); + assertNotNull(event); + assertEquals("external_tool.requested", event.getType()); + assertNotNull(event.getData()); + assertEquals("req-123", event.getData().requestId()); + assertEquals("sess-456", event.getData().sessionId()); + assertEquals("call-789", event.getData().toolCallId()); + assertEquals("get_weather", event.getData().toolName()); + } + + @Test + void testParseExternalToolCompletedEvent() throws Exception { + String json = """ + { + "type": "external_tool.completed", + "data": { + "requestId": "req-123" + } + } + """; + + var event = (ExternalToolCompletedEvent) parseJson(json); + assertNotNull(event); + assertEquals("external_tool.completed", event.getType()); + assertEquals("req-123", event.getData().requestId()); + } + + @Test + void testParsePermissionRequestedEvent() throws Exception { + String json = """ + { + "type": "permission.requested", + "data": { + "requestId": "perm-req-456", + "permissionRequest": { + "kind": "shell", + "toolCallId": "call-001" + } + } + } + """; + + var event = (PermissionRequestedEvent) parseJson(json); + assertNotNull(event); + assertEquals("permission.requested", event.getType()); + assertEquals("perm-req-456", event.getData().requestId()); + assertNotNull(event.getData().permissionRequest()); + assertEquals("shell", event.getData().permissionRequest().getKind()); + } + + @Test + void testParsePermissionCompletedEvent() throws Exception { + String json = """ + { + "type": "permission.completed", + "data": { + "requestId": "perm-req-456", + "result": { + "kind": "approved" + } + } + } + """; + + var event = (PermissionCompletedEvent) parseJson(json); + assertNotNull(event); + assertEquals("permission.completed", event.getType()); + assertEquals("perm-req-456", event.getData().requestId()); + assertEquals("approved", event.getData().result().kind()); + } + + @Test + void testParseCommandQueuedEvent() throws Exception { + String json = """ + { + "type": "command.queued", + "data": { + "requestId": "cmd-req-789", + "command": "/help" + } + } + """; + + var event = (CommandQueuedEvent) parseJson(json); + assertNotNull(event); + assertEquals("command.queued", event.getType()); + assertEquals("cmd-req-789", event.getData().requestId()); + assertEquals("/help", event.getData().command()); + } + + @Test + void testParseCommandCompletedEvent() throws Exception { + String json = """ + { + "type": "command.completed", + "data": { + "requestId": "cmd-req-789" + } + } + """; + + var event = (CommandCompletedEvent) parseJson(json); + assertNotNull(event); + assertEquals("command.completed", event.getType()); + assertEquals("cmd-req-789", event.getData().requestId()); + } + + @Test + void testParseExitPlanModeRequestedEvent() throws Exception { + String json = """ + { + "type": "exit_plan_mode.requested", + "data": { + "requestId": "plan-req-001", + "summary": "Plan is ready", + "planContent": "## Plan\\n1. Do thing", + "actions": ["approve", "edit", "reject"], + "recommendedAction": "approve" + } + } + """; + + var event = (ExitPlanModeRequestedEvent) parseJson(json); + assertNotNull(event); + assertEquals("exit_plan_mode.requested", event.getType()); + assertEquals("plan-req-001", event.getData().requestId()); + assertEquals("Plan is ready", event.getData().summary()); + assertEquals(3, event.getData().actions().length); + assertEquals("approve", event.getData().recommendedAction()); + } + + @Test + void testParseExitPlanModeCompletedEvent() throws Exception { + String json = """ + { + "type": "exit_plan_mode.completed", + "data": { + "requestId": "plan-req-001" + } + } + """; + + var event = (ExitPlanModeCompletedEvent) parseJson(json); + assertNotNull(event); + assertEquals("exit_plan_mode.completed", event.getType()); + assertEquals("plan-req-001", event.getData().requestId()); + } + + @Test + void testParseSystemNotificationEvent() throws Exception { + String json = """ + { + "type": "system.notification", + "data": { + "content": "Agent completed", + "kind": {"type": "agent_completed", "agentId": "agent-1", "agentType": "task", "status": "completed"} + } + } + """; + + var event = (SystemNotificationEvent) parseJson(json); + assertNotNull(event); + assertEquals("system.notification", event.getType()); + assertNotNull(event.getData()); + assertTrue(event.getData().content().contains("Agent completed")); + } } diff --git a/src/test/java/com/github/copilot/sdk/SessionRequestBuilderTest.java b/src/test/java/com/github/copilot/sdk/SessionRequestBuilderTest.java index 7e9d5ee69..c00355211 100644 --- a/src/test/java/com/github/copilot/sdk/SessionRequestBuilderTest.java +++ b/src/test/java/com/github/copilot/sdk/SessionRequestBuilderTest.java @@ -237,4 +237,18 @@ private CopilotSession createTestSession() throws Exception { constructor.setAccessible(true); return constructor.newInstance("builder-test-session", null, null); } + + @Test + void testBuildCreateRequestWithAgent() { + var config = new SessionConfig().setAgent("my-agent"); + CreateSessionRequest request = SessionRequestBuilder.buildCreateRequest(config, "test-session-id"); + assertEquals("my-agent", request.getAgent()); + } + + @Test + void testBuildResumeRequestWithAgent() { + var config = new ResumeSessionConfig().setAgent("my-agent"); + ResumeSessionRequest request = SessionRequestBuilder.buildResumeRequest("session-id", config); + assertEquals("my-agent", request.getAgent()); + } } From c67265bfa532c2ce41e7c401f8c23042e1776c82 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Mar 2026 15:22:36 +0000 Subject: [PATCH 13/22] Refactor handleBroadcastEventAsync to use Java 21 pattern matching switch; bump minimum Java to 21 Co-authored-by: edburns <75821+edburns@users.noreply.github.com> --- .github/workflows/build-test.yml | 2 +- .github/workflows/publish-maven.yml | 2 +- README.md | 4 +- pom.xml | 2 +- .../github/copilot/sdk/CopilotSession.java | 42 +++++++++++-------- src/site/markdown/getting-started.md | 2 +- src/site/markdown/index.md | 2 +- 7 files changed, 31 insertions(+), 25 deletions(-) diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index 5749900f6..761b85917 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -38,7 +38,7 @@ jobs: node-version: 22 - uses: actions/setup-java@v5 with: - java-version: "17" + java-version: "21" distribution: "temurin" cache: "maven" diff --git a/.github/workflows/publish-maven.yml b/.github/workflows/publish-maven.yml index 5596cbd11..fa748c4a2 100644 --- a/.github/workflows/publish-maven.yml +++ b/.github/workflows/publish-maven.yml @@ -53,7 +53,7 @@ jobs: - name: Set up JDK 17 uses: actions/setup-java@v5 with: - java-version: "17" + java-version: "21" distribution: "temurin" cache: "maven" server-id: central diff --git a/README.md b/README.md index 5943afab4..98d7d59c1 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![Site](https://github.com/copilot-community-sdk/copilot-sdk-java/actions/workflows/deploy-site.yml/badge.svg)](https://github.com/copilot-community-sdk/copilot-sdk-java/actions/workflows/deploy-site.yml) [![Coverage](.github/badges/jacoco.svg)](https://copilot-community-sdk.github.io/copilot-sdk-java/snapshot/jacoco/index.html) [![Documentation](https://img.shields.io/badge/docs-online-brightgreen)](https://copilot-community-sdk.github.io/copilot-sdk-java/) -[![Java 17+](https://img.shields.io/badge/Java-17%2B-blue?logo=openjdk&logoColor=white)](https://openjdk.org/) +[![Java 21+](https://img.shields.io/badge/Java-21%2B-blue?logo=openjdk&logoColor=white)](https://openjdk.org/) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) #### Latest release @@ -24,7 +24,7 @@ Java SDK for programmatic control of GitHub Copilot CLI, enabling you to build A ### Requirements -- Java 17 or later +- Java 21 or later - GitHub Copilot CLI 0.0.411-1 or later installed and in PATH (or provide custom `cliPath`) ### Maven diff --git a/pom.xml b/pom.xml index 7ab031ce7..225b5b1da 100644 --- a/pom.xml +++ b/pom.xml @@ -37,7 +37,7 @@ - 17 + 21 UTF-8 ${project.build.directory}/copilot-sdk diff --git a/src/main/java/com/github/copilot/sdk/CopilotSession.java b/src/main/java/com/github/copilot/sdk/CopilotSession.java index c19fe72f3..c49deac5a 100644 --- a/src/main/java/com/github/copilot/sdk/CopilotSession.java +++ b/src/main/java/com/github/copilot/sdk/CopilotSession.java @@ -618,27 +618,33 @@ void dispatchEvent(AbstractSessionEvent 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 + switch (event) { + case 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); } - 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; + case 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); } - PermissionHandler handler = permissionHandler.get(); - if (handler == null) { - return; // This client doesn't handle permissions; another client will + default -> { + // Other event types are not handled here } - executePermissionAndRespondAsync(data.requestId(), data.permissionRequest(), handler); } } diff --git a/src/site/markdown/getting-started.md b/src/site/markdown/getting-started.md index 49281a4fc..8bb9c84b2 100644 --- a/src/site/markdown/getting-started.md +++ b/src/site/markdown/getting-started.md @@ -18,7 +18,7 @@ Copilot: In Tokyo it's 75°F and sunny. Great day to be outside! Before you begin, make sure you have: -- **Java 17+** installed +- **Java 21+** installed - **GitHub Copilot CLI** installed and authenticated ([Installation guide](https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli)) Verify the CLI is working: diff --git a/src/site/markdown/index.md b/src/site/markdown/index.md index 27abc27c0..4a0918ce1 100644 --- a/src/site/markdown/index.md +++ b/src/site/markdown/index.md @@ -8,7 +8,7 @@ Welcome to the documentation for the **Copilot SDK for Java** — a Java SDK for ### Requirements -- Java 17 or later +- Java 21 or later - GitHub Copilot CLI 0.0.411-1 or later installed and in PATH (or provide custom `cliPath`) ### Installation From 563e0c3343c764224760fdee02519cfb7d66b441 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Mar 2026 15:34:24 +0000 Subject: [PATCH 14/22] Revert Java 21 bump and pattern-matching switch; restore Java 17 and if/else instanceof Co-authored-by: edburns <75821+edburns@users.noreply.github.com> --- .github/workflows/build-test.yml | 2 +- .github/workflows/publish-maven.yml | 2 +- README.md | 4 +- pom.xml | 2 +- .../github/copilot/sdk/CopilotSession.java | 42 ++++++++----------- src/site/markdown/getting-started.md | 2 +- src/site/markdown/index.md | 2 +- 7 files changed, 25 insertions(+), 31 deletions(-) diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index 761b85917..5749900f6 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -38,7 +38,7 @@ jobs: node-version: 22 - uses: actions/setup-java@v5 with: - java-version: "21" + java-version: "17" distribution: "temurin" cache: "maven" diff --git a/.github/workflows/publish-maven.yml b/.github/workflows/publish-maven.yml index fa748c4a2..5596cbd11 100644 --- a/.github/workflows/publish-maven.yml +++ b/.github/workflows/publish-maven.yml @@ -53,7 +53,7 @@ jobs: - name: Set up JDK 17 uses: actions/setup-java@v5 with: - java-version: "21" + java-version: "17" distribution: "temurin" cache: "maven" server-id: central diff --git a/README.md b/README.md index 98d7d59c1..5943afab4 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![Site](https://github.com/copilot-community-sdk/copilot-sdk-java/actions/workflows/deploy-site.yml/badge.svg)](https://github.com/copilot-community-sdk/copilot-sdk-java/actions/workflows/deploy-site.yml) [![Coverage](.github/badges/jacoco.svg)](https://copilot-community-sdk.github.io/copilot-sdk-java/snapshot/jacoco/index.html) [![Documentation](https://img.shields.io/badge/docs-online-brightgreen)](https://copilot-community-sdk.github.io/copilot-sdk-java/) -[![Java 21+](https://img.shields.io/badge/Java-21%2B-blue?logo=openjdk&logoColor=white)](https://openjdk.org/) +[![Java 17+](https://img.shields.io/badge/Java-17%2B-blue?logo=openjdk&logoColor=white)](https://openjdk.org/) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) #### Latest release @@ -24,7 +24,7 @@ Java SDK for programmatic control of GitHub Copilot CLI, enabling you to build A ### Requirements -- Java 21 or later +- Java 17 or later - GitHub Copilot CLI 0.0.411-1 or later installed and in PATH (or provide custom `cliPath`) ### Maven diff --git a/pom.xml b/pom.xml index 225b5b1da..7ab031ce7 100644 --- a/pom.xml +++ b/pom.xml @@ -37,7 +37,7 @@ - 21 + 17 UTF-8 ${project.build.directory}/copilot-sdk diff --git a/src/main/java/com/github/copilot/sdk/CopilotSession.java b/src/main/java/com/github/copilot/sdk/CopilotSession.java index c49deac5a..c19fe72f3 100644 --- a/src/main/java/com/github/copilot/sdk/CopilotSession.java +++ b/src/main/java/com/github/copilot/sdk/CopilotSession.java @@ -618,33 +618,27 @@ void dispatchEvent(AbstractSessionEvent event) { * the event to handle */ private void handleBroadcastEventAsync(AbstractSessionEvent event) { - switch (event) { - case 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); + if (event instanceof ExternalToolRequestedEvent toolEvent) { + var data = toolEvent.getData(); + if (data == null || data.requestId() == null || data.toolName() == null) { + return; } - case 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); + 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; } - default -> { - // Other event types are not handled here + PermissionHandler handler = permissionHandler.get(); + if (handler == null) { + return; // This client doesn't handle permissions; another client will } + executePermissionAndRespondAsync(data.requestId(), data.permissionRequest(), handler); } } diff --git a/src/site/markdown/getting-started.md b/src/site/markdown/getting-started.md index 8bb9c84b2..49281a4fc 100644 --- a/src/site/markdown/getting-started.md +++ b/src/site/markdown/getting-started.md @@ -18,7 +18,7 @@ Copilot: In Tokyo it's 75°F and sunny. Great day to be outside! Before you begin, make sure you have: -- **Java 21+** installed +- **Java 17+** installed - **GitHub Copilot CLI** installed and authenticated ([Installation guide](https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli)) Verify the CLI is working: diff --git a/src/site/markdown/index.md b/src/site/markdown/index.md index 4a0918ce1..27abc27c0 100644 --- a/src/site/markdown/index.md +++ b/src/site/markdown/index.md @@ -8,7 +8,7 @@ Welcome to the documentation for the **Copilot SDK for Java** — a Java SDK for ### Requirements -- Java 21 or later +- Java 17 or later - GitHub Copilot CLI 0.0.411-1 or later installed and in PATH (or provide custom `cliPath`) ### Installation From e592e478bfc188f01bf2ab360a32719aae6e043d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Mar 2026 08:46:52 +0000 Subject: [PATCH 15/22] Fix: use correct permission denial kind and send errors back via RPC (ported from #185) --- .../github/copilot/sdk/CopilotSession.java | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/github/copilot/sdk/CopilotSession.java b/src/main/java/com/github/copilot/sdk/CopilotSession.java index c19fe72f3..452e82671 100644 --- a/src/main/java/com/github/copilot/sdk/CopilotSession.java +++ b/src/main/java/com/github/copilot/sdk/CopilotSession.java @@ -40,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; @@ -684,6 +685,14 @@ private void executeToolAndRespondAsync(String requestId, String toolName, Strin }); } 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); + } } }); } @@ -708,7 +717,7 @@ private void executePermissionAndRespondAsync(String requestId, PermissionReques }).exceptionally(ex -> { try { PermissionRequestResult denied = new PermissionRequestResult(); - denied.setKind("denied-could-not-request-from-user"); + 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) { @@ -718,6 +727,14 @@ private void executePermissionAndRespondAsync(String requestId, PermissionReques }); } 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); + } } }); } From 4e0f289338ef11d29cbece0e89e9b3c314e7b825 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 11 Mar 2026 14:00:40 +0000 Subject: [PATCH 16/22] Update JaCoCo coverage badge --- .github/badges/jacoco.svg | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/badges/jacoco.svg b/.github/badges/jacoco.svg index c80aa997f..8ef59f460 100644 --- a/.github/badges/jacoco.svg +++ b/.github/badges/jacoco.svg @@ -12,7 +12,7 @@ coverage coverage - 88.9% - 88.9% + 87.5% + 87.5% From 48e3960fc8a9d53d915fdbf3bb27ef4828d2ac99 Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Thu, 12 Mar 2026 02:39:16 +0100 Subject: [PATCH 17/22] Fix documentation coverage gaps Add missing SessionConfig options (clientName, agent, onEvent) to reference table. Add 13 undocumented event types with new sections for External Tool, Permission, Command, and Plan Mode events. Add session.log() and early event registration sections to advanced.md. Add onListModels to setup.md CopilotClientOptions table. Add disableResume and onEvent to resume options table. --- src/site/markdown/advanced.md | 50 ++++++++++++++++++++++++++++++ src/site/markdown/documentation.md | 38 +++++++++++++++++++++++ src/site/markdown/setup.md | 1 + 3 files changed, 89 insertions(+) diff --git a/src/site/markdown/advanced.md b/src/site/markdown/advanced.md index dc57f6258..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. diff --git a/src/site/markdown/documentation.md b/src/site/markdown/documentation.md index 26f17ce0f..ebfee18fc 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`, creates a new session instead of resuming | +| `onEvent` | Event handler registered before session resumption | **Example: Changing Model on Resume** @@ -637,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) | @@ -651,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 From df01e4a091703be5916ae6be1d21f1f031ff2f42 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Mar 2026 01:47:15 +0000 Subject: [PATCH 18/22] Initial plan From 2c4348d262c5a729ffeb8d663c888e3e7246fa34 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Mar 2026 01:50:42 +0000 Subject: [PATCH 19/22] Update .lastmerge to 062b61c8aa63b9b5d45fa1d7b01723e6660ffa83 --- .lastmerge | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.lastmerge b/.lastmerge index fd2e8b676..c5649a512 100644 --- a/.lastmerge +++ b/.lastmerge @@ -1 +1 @@ -9a0a1a5f21111f4ad02b5ce911750ecc75e054c3 +062b61c8aa63b9b5d45fa1d7b01723e6660ffa83 From 7215df0c9c70e52339a286b7f30635b92d7550fc Mon Sep 17 00:00:00 2001 From: Bruno Borges Date: Thu, 12 Mar 2026 03:08:37 +0100 Subject: [PATCH 20/22] Update src/site/markdown/documentation.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/site/markdown/documentation.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/site/markdown/documentation.md b/src/site/markdown/documentation.md index ebfee18fc..5e6913dbd 100644 --- a/src/site/markdown/documentation.md +++ b/src/site/markdown/documentation.md @@ -634,7 +634,7 @@ When resuming a session, you can optionally reconfigure many settings. This is u | `skillDirectories` | Directories to load skills from | | `disabledSkills` | Skills to disable | | `infiniteSessions` | Configure infinite session behavior | -| `disableResume` | When `true`, creates a new session instead of resuming | +| `disableResume` | When `true`, resumes without emitting a `session.resume` event | | `onEvent` | Event handler registered before session resumption | **Example: Changing Model on Resume** From e4e25119f6880f28327b5f512d34ade31c7ee81d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 12 Mar 2026 02:10:39 +0000 Subject: [PATCH 21/22] docs: update version references to 1.0.11 --- CHANGELOG.md | 13 ++++++++++--- README.md | 4 ++-- jbang-example.java | 2 +- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b95104175..29e6b6281 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,8 +8,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] -> **Upstream sync:** [`github/copilot-sdk@9a0a1a5`](https://github.com/github/copilot-sdk/commit/9a0a1a5f21111f4ad02b5ce911750ecc75e054c3) +> **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)) @@ -424,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 5943afab4..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 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.*; From a1169bc6bb3a66d566b60c2f5421d59c426a5a98 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 12 Mar 2026 02:11:00 +0000 Subject: [PATCH 22/22] [maven-release-plugin] prepare release v1.0.11 --- pom.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pom.xml b/pom.xml index 7ab031ce7..51c08825f 100644 --- a/pom.xml +++ b/pom.xml @@ -7,7 +7,7 @@ io.github.copilot-community-sdk copilot-sdk - 1.0.11-SNAPSHOT + 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 - HEAD + v1.0.11