Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .lastmerge
Original file line number Diff line number Diff line change
@@ -1 +1 @@
dcd86c189501ce1b46b787ca60d90f3f315f3079
4246289e484d42155c75267660d448d9ac4f9158
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,17 @@
*
* <h2>Common Result Kinds</h2>
* <ul>
* <li>"user-approved" - User approved the permission request</li>
* <li>"user-denied" - User denied the permission request</li>
* <li>"denied-no-approval-rule-and-could-not-request-from-user" - No handler
* and couldn't ask user</li>
* <li>{@link PermissionRequestResultKind#APPROVED} — approved</li>
* <li>{@link PermissionRequestResultKind#DENIED_BY_RULES} — denied by
* rules</li>
* <li>{@link PermissionRequestResultKind#DENIED_COULD_NOT_REQUEST_FROM_USER} —
* no handler and couldn't ask user</li>
* <li>{@link PermissionRequestResultKind#DENIED_INTERACTIVELY_BY_USER} — denied
* by the user interactively</li>
* </ul>
*
* @see PermissionHandler
* @see PermissionRequestResultKind
* @since 1.0.0
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
Expand All @@ -36,7 +40,7 @@ public final class PermissionRequestResult {
private List<Object> rules;

/**
* Gets the result kind.
* Gets the result kind as a string.
*
* @return the result kind indicating approval or denial
*/
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>
* 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.
*
* <h2>Well-known kinds</h2>
* <ul>
* <li>{@link #APPROVED} — the permission was approved.</li>
* <li>{@link #DENIED_BY_RULES} — the permission was denied by policy
* rules.</li>
* <li>{@link #DENIED_COULD_NOT_REQUEST_FROM_USER} — the permission was denied
* because no approval rule was found and the user could not be prompted.</li>
* <li>{@link #DENIED_INTERACTIVELY_BY_USER} — the permission was denied
* interactively by the user.</li>
* </ul>
*
* @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));
}
}
26 changes: 19 additions & 7 deletions src/site/markdown/advanced.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
82 changes: 27 additions & 55 deletions src/test/java/com/github/copilot/sdk/CopilotSessionTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
}
}
Expand Down Expand Up @@ -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();
}
}
Expand Down Expand Up @@ -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<AbstractSessionEvent>();
var idleReceived = new CompletableFuture<Void>();

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.
*
Expand Down Expand Up @@ -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();
}
Expand Down
4 changes: 2 additions & 2 deletions src/test/java/com/github/copilot/sdk/E2ETestContext.java
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
Expand Down
Loading
Loading