Skip to content

Commit 3e1614b

Browse files
Copilotedburns
andauthored
Add unit/E2E tests and documentation for mode handler APIs
Co-authored-by: edburns <75821+edburns@users.noreply.github.com>
1 parent f55af2d commit 3e1614b

4 files changed

Lines changed: 360 additions & 0 deletions

File tree

src/site/markdown/advanced.md

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,9 @@ This guide covers advanced scenarios for extending and customizing your Copilot
5353
- [Incoming Elicitation Handler](#Incoming_Elicitation_Handler)
5454
- [Session Capabilities](#Session_Capabilities)
5555
- [Outgoing Elicitation via session.getUi()](#Outgoing_Elicitation_via_session.getUi)
56+
- [Mode Handlers](#Mode_Handlers)
57+
- [Exit Plan Mode](#Exit_Plan_Mode)
58+
- [Auto Mode Switch](#Auto_Mode_Switch)
5659
- [Getting Session Metadata by ID](#Getting_Session_Metadata_by_ID)
5760

5861
---
@@ -1267,6 +1270,64 @@ All `getUi()` methods throw `IllegalStateException` if the host does not support
12671270

12681271
---
12691272

1273+
## Mode Handlers
1274+
1275+
Mode handlers let your application respond to mode transitions requested by the Copilot CLI.
1276+
1277+
### Exit Plan Mode
1278+
1279+
When the model finishes creating a plan and wants to transition out of plan mode, it invokes the `exitPlanMode` handler. Register the handler via `SessionConfig.setOnExitPlanMode()`:
1280+
1281+
```java
1282+
var session = client.createSession(new SessionConfig()
1283+
.setOnExitPlanMode((request, invocation) -> {
1284+
System.out.println("Plan summary: " + request.getSummary());
1285+
System.out.println("Available actions: " + request.getActions());
1286+
System.out.println("Recommended: " + request.getRecommendedAction());
1287+
1288+
return CompletableFuture.completedFuture(
1289+
new ExitPlanModeResult()
1290+
.setApproved(true)
1291+
.setSelectedAction("interactive")
1292+
.setFeedback("Looks good, proceed!"));
1293+
})).get();
1294+
```
1295+
1296+
When no handler is registered, the SDK automatically approves the plan (`approved=true`). The handler receives an `ExitPlanModeRequest` with:
1297+
1298+
| Field | Description |
1299+
|---------------------|-------------------------------------------------|
1300+
| `summary` | Summary of the plan that was created |
1301+
| `planContent` | Full content of the plan file |
1302+
| `actions` | Available actions (e.g., interactive, autopilot) |
1303+
| `recommendedAction` | The recommended action for the user |
1304+
1305+
### Auto Mode Switch
1306+
1307+
When the model encounters a rate limit or similar constraint, it may request to switch modes automatically. Register the handler via `SessionConfig.setOnAutoModeSwitch()`:
1308+
1309+
```java
1310+
var session = client.createSession(new SessionConfig()
1311+
.setOnAutoModeSwitch((request, invocation) -> {
1312+
System.out.println("Error: " + request.getErrorCode());
1313+
System.out.println("Retry after: " + request.getRetryAfterSeconds() + "s");
1314+
1315+
return CompletableFuture.completedFuture(AutoModeSwitchResponse.YES);
1316+
})).get();
1317+
```
1318+
1319+
When no handler is registered, the SDK returns `NO` (declining the mode switch). The response options are:
1320+
1321+
| Response | Description |
1322+
|---------------------------------|------------------------------------------|
1323+
| `AutoModeSwitchResponse.YES` | Allow the mode switch this time |
1324+
| `AutoModeSwitchResponse.YES_ALWAYS`| Always allow automatic mode switches |
1325+
| `AutoModeSwitchResponse.NO` | Decline the mode switch |
1326+
1327+
Both handlers are also available on `ResumeSessionConfig` for resumed sessions.
1328+
1329+
---
1330+
12701331
## Getting Session Metadata by ID
12711332

12721333
Retrieve metadata for a specific session without listing all sessions:

src/test/java/com/github/copilot/sdk/ConfigCloneTest.java

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,10 @@
1616
import org.junit.jupiter.api.Test;
1717

1818
import com.github.copilot.sdk.generated.SessionEvent;
19+
import com.github.copilot.sdk.json.AutoModeSwitchResponse;
1920
import com.github.copilot.sdk.json.CopilotClientOptions;
2021
import com.github.copilot.sdk.json.DefaultAgentConfig;
22+
import com.github.copilot.sdk.json.ExitPlanModeResult;
2123
import com.github.copilot.sdk.json.InfiniteSessionConfig;
2224
import com.github.copilot.sdk.json.MessageOptions;
2325
import com.github.copilot.sdk.json.ModelInfo;
@@ -375,4 +377,32 @@ void copilotClientOptionsSessionIdleTimeoutCloned() {
375377

376378
assertEquals(600, cloned.getSessionIdleTimeoutSeconds());
377379
}
380+
381+
@Test
382+
void sessionConfigCloneCopiesModeSwitchHandlers() {
383+
SessionConfig original = new SessionConfig();
384+
original.setOnExitPlanMode(
385+
(request, invocation) -> CompletableFuture.completedFuture(new ExitPlanModeResult()));
386+
original.setOnAutoModeSwitch(
387+
(request, invocation) -> CompletableFuture.completedFuture(AutoModeSwitchResponse.NO));
388+
389+
SessionConfig cloned = original.clone();
390+
391+
assertSame(original.getOnExitPlanMode(), cloned.getOnExitPlanMode());
392+
assertSame(original.getOnAutoModeSwitch(), cloned.getOnAutoModeSwitch());
393+
}
394+
395+
@Test
396+
void resumeSessionConfigCloneCopiesModeSwitchHandlers() {
397+
ResumeSessionConfig original = new ResumeSessionConfig();
398+
original.setOnExitPlanMode(
399+
(request, invocation) -> CompletableFuture.completedFuture(new ExitPlanModeResult()));
400+
original.setOnAutoModeSwitch(
401+
(request, invocation) -> CompletableFuture.completedFuture(AutoModeSwitchResponse.NO));
402+
403+
ResumeSessionConfig cloned = original.clone();
404+
405+
assertSame(original.getOnExitPlanMode(), cloned.getOnExitPlanMode());
406+
assertSame(original.getOnAutoModeSwitch(), cloned.getOnAutoModeSwitch());
407+
}
378408
}
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
*--------------------------------------------------------------------------------------------*/
4+
5+
package com.github.copilot.sdk;
6+
7+
import static org.junit.jupiter.api.Assertions.*;
8+
9+
import java.util.HashMap;
10+
import java.util.Map;
11+
import java.util.concurrent.CompletableFuture;
12+
import java.util.concurrent.TimeUnit;
13+
14+
import org.junit.jupiter.api.AfterAll;
15+
import org.junit.jupiter.api.BeforeAll;
16+
import org.junit.jupiter.api.Test;
17+
18+
import com.github.copilot.sdk.generated.ExitPlanModeCompletedEvent;
19+
import com.github.copilot.sdk.generated.ExitPlanModeRequestedEvent;
20+
import com.github.copilot.sdk.json.AutoModeSwitchRequest;
21+
import com.github.copilot.sdk.json.AutoModeSwitchResponse;
22+
import com.github.copilot.sdk.json.CopilotClientOptions;
23+
import com.github.copilot.sdk.json.ExitPlanModeRequest;
24+
import com.github.copilot.sdk.json.ExitPlanModeResult;
25+
import com.github.copilot.sdk.json.MessageOptions;
26+
import com.github.copilot.sdk.json.PermissionHandler;
27+
import com.github.copilot.sdk.json.SessionConfig;
28+
29+
/**
30+
* E2E tests for exit-plan-mode and auto-mode-switch handler APIs.
31+
*
32+
* <p>
33+
* Ported from {@code ModeHandlersE2ETests.cs} in the reference implementation
34+
* dotnet SDK.
35+
* </p>
36+
*/
37+
public class ModeHandlersTest {
38+
39+
private static final String TOKEN = "mode-handler-token";
40+
41+
private static E2ETestContext ctx;
42+
43+
@BeforeAll
44+
static void setup() throws Exception {
45+
ctx = E2ETestContext.create();
46+
}
47+
48+
@AfterAll
49+
static void teardown() throws Exception {
50+
if (ctx != null) {
51+
ctx.close();
52+
}
53+
}
54+
55+
private CopilotClient createAuthenticatedClient() {
56+
Map<String, String> env = new HashMap<>(ctx.getEnvironment());
57+
env.put("COPILOT_DEBUG_GITHUB_API_URL", ctx.getProxyUrl());
58+
59+
return ctx.createClient(new CopilotClientOptions().setEnvironment(env));
60+
}
61+
62+
private void configureAuthenticatedUser() throws Exception {
63+
ctx.setCopilotUserByToken(TOKEN, "mode-handler-user", "individual_pro", ctx.getProxyUrl(),
64+
"https://localhost:1/telemetry", "mode-handler-tracking-id");
65+
}
66+
67+
@Test
68+
void shouldInvokeExitPlanModeHandlerWhenModelUsesTool() throws Exception {
69+
final String summary = "Greeting file implementation plan";
70+
configureAuthenticatedUser();
71+
72+
var handlerCalled = new CompletableFuture<ExitPlanModeRequest>();
73+
74+
try (var client = createAuthenticatedClient()) {
75+
var session = client.createSession(new SessionConfig().setGitHubToken(TOKEN)
76+
.setOnPermissionRequest(PermissionHandler.APPROVE_ALL).setOnExitPlanMode((request, invocation) -> {
77+
handlerCalled.complete(request);
78+
return CompletableFuture.completedFuture(new ExitPlanModeResult().setApproved(true)
79+
.setSelectedAction("interactive").setFeedback("Approved by the Java E2E test"));
80+
})).get(30, TimeUnit.SECONDS);
81+
82+
var requestedEvent = new CompletableFuture<ExitPlanModeRequestedEvent>();
83+
var completedEvent = new CompletableFuture<ExitPlanModeCompletedEvent>();
84+
85+
session.on(event -> {
86+
if (event instanceof ExitPlanModeRequestedEvent requested
87+
&& summary.equals(requested.getData().summary())) {
88+
requestedEvent.complete(requested);
89+
} else if (event instanceof ExitPlanModeCompletedEvent completed
90+
&& Boolean.TRUE.equals(completed.getData().approved())
91+
&& "interactive".equals(completed.getData().selectedAction())) {
92+
completedEvent.complete(completed);
93+
}
94+
});
95+
96+
var response = session.sendAndWait(new MessageOptions().setPrompt(
97+
"Create a brief implementation plan for adding a greeting.txt file, then request approval with exit_plan_mode.")
98+
.setMode("plan")).get(120, TimeUnit.SECONDS);
99+
100+
var request = handlerCalled.get(10, TimeUnit.SECONDS);
101+
assertEquals(summary, request.getSummary());
102+
assertNotNull(request.getActions());
103+
assertTrue(request.getActions().contains("interactive"));
104+
assertNotNull(request.getPlanContent());
105+
106+
var reqEvent = requestedEvent.get(10, TimeUnit.SECONDS);
107+
assertEquals(request.getSummary(), reqEvent.getData().summary());
108+
109+
var compEvent = completedEvent.get(10, TimeUnit.SECONDS);
110+
assertTrue(compEvent.getData().approved());
111+
assertEquals("interactive", compEvent.getData().selectedAction());
112+
113+
assertNotNull(response);
114+
115+
session.close();
116+
}
117+
}
118+
119+
@Test
120+
void shouldInvokeAutoModeSwitchHandlerWhenRateLimited() throws Exception {
121+
configureAuthenticatedUser();
122+
123+
var handlerCalled = new CompletableFuture<AutoModeSwitchRequest>();
124+
125+
try (var client = createAuthenticatedClient()) {
126+
var session = client.createSession(
127+
new SessionConfig().setGitHubToken(TOKEN).setOnPermissionRequest(PermissionHandler.APPROVE_ALL)
128+
.setOnAutoModeSwitch((request, invocation) -> {
129+
handlerCalled.complete(request);
130+
return CompletableFuture.completedFuture(AutoModeSwitchResponse.YES);
131+
}))
132+
.get(30, TimeUnit.SECONDS);
133+
134+
session.sendAndWait(new MessageOptions()
135+
.setPrompt("Explain that auto mode recovered from a rate limit in one short sentence."))
136+
.get(30, TimeUnit.SECONDS);
137+
138+
var request = handlerCalled.get(30, TimeUnit.SECONDS);
139+
assertEquals("user_weekly_rate_limited", request.getErrorCode());
140+
assertEquals(1.0, request.getRetryAfterSeconds());
141+
142+
session.close();
143+
}
144+
}
145+
}

0 commit comments

Comments
 (0)