Skip to content

Commit e85055a

Browse files
committed
Port upstream SDK changes: auth options, reasoning effort, user input handlers, hooks
Features ported from upstream copilot-sdk (87ff5510..a552ae4): 1. Authentication options (PR #237, #2fe7352): - Add githubToken and useLoggedInUser to CopilotClientOptions - Pass auth flags to CLI process on startup - Validate auth options with external server (CliUrl) 2. Reasoning effort support (PR #302, #c39a129): - Add reasoningEffort field to SessionConfig/ResumeSessionConfig - Add reasoningEffort boolean to ModelSupports - Add supportedReasoningEfforts and defaultReasoningEffort to ModelInfo 3. User input handler (PR #269, #2fa6a92): - Add UserInputRequest, UserInputResponse, UserInputHandler, UserInputInvocation - Add onUserInputRequest to SessionConfig and ResumeSessionConfig - Handle userInput.request RPC method in CopilotClient - Add registerUserInputHandler and handleUserInputRequest to CopilotSession 4. Hooks system (PR #269, #2fa6a92): - Add PreToolUseHookInput/Output, PostToolUseHookInput/Output - Add PreToolUseHandler, PostToolUseHandler functional interfaces - Add SessionHooks and HookInvocation classes - Handle hooks.invoke RPC method in CopilotClient - Add registerHooks and handleHooksInvoke to CopilotSession 5. Session config enhancements: - Add workingDirectory to SessionConfig and ResumeSessionConfig - Add disableResume to ResumeSessionConfig 6. Models caching (PR #300, #a552ae4): - Cache listModels() results to prevent rate limiting - Clear cache on disconnect
1 parent 55316e2 commit e85055a

22 files changed

Lines changed: 1662 additions & 3 deletions

.lastmerge

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
87ff5510e0dacb030912501e4eb8deaac38f913f
1+
a552ae497313143e2426d6ff7e99b2632ab573dd

src/main/java/com/github/copilot/sdk/CopilotClient.java

Lines changed: 174 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,8 @@ public class CopilotClient implements AutoCloseable {
8989
private volatile boolean disposed = false;
9090
private final String optionsHost;
9191
private final Integer optionsPort;
92+
private volatile List<ModelInfo> modelsCache;
93+
private final Object modelsCacheLock = new Object();
9294

9395
/**
9496
* Creates a new CopilotClient with default options.
@@ -114,6 +116,13 @@ public CopilotClient(CopilotClientOptions options) {
114116
throw new IllegalArgumentException("CliUrl is mutually exclusive with UseStdio and CliPath");
115117
}
116118

119+
// Validate auth options with external server
120+
if (this.options.getCliUrl() != null && !this.options.getCliUrl().isEmpty()
121+
&& (this.options.getGithubToken() != null || this.options.getUseLoggedInUser() != null)) {
122+
throw new IllegalArgumentException(
123+
"GithubToken and UseLoggedInUser cannot be used with CliUrl (external server manages its own auth)");
124+
}
125+
117126
// Parse CliUrl if provided
118127
if (this.options.getCliUrl() != null && !this.options.getCliUrl().isEmpty()) {
119128
URI uri = parseCliUrl(this.options.getCliUrl());
@@ -217,6 +226,16 @@ private void registerRpcHandlers(JsonRpcClient rpc) {
217226
rpc.registerMethodHandler("permission.request", (requestId, params) -> {
218227
handlePermissionRequest(rpc, requestId, params);
219228
});
229+
230+
// Handle user input requests
231+
rpc.registerMethodHandler("userInput.request", (requestId, params) -> {
232+
handleUserInputRequest(rpc, requestId, params);
233+
});
234+
235+
// Handle hooks invocations
236+
rpc.registerMethodHandler("hooks.invoke", (requestId, params) -> {
237+
handleHooksInvoke(rpc, requestId, params);
238+
});
220239
}
221240

222241
private void handleToolCall(JsonRpcClient rpc, String requestId, JsonNode params) {
@@ -317,6 +336,93 @@ private void handlePermissionRequest(JsonRpcClient rpc, String requestId, JsonNo
317336
});
318337
}
319338

339+
private void handleUserInputRequest(JsonRpcClient rpc, String requestId, JsonNode params) {
340+
CompletableFuture.runAsync(() -> {
341+
try {
342+
String sessionId = params.get("sessionId").asText();
343+
String question = params.get("question").asText();
344+
JsonNode choicesNode = params.get("choices");
345+
JsonNode allowFreeformNode = params.get("allowFreeform");
346+
347+
CopilotSession session = sessions.get(sessionId);
348+
if (session == null) {
349+
rpc.sendErrorResponse(Long.parseLong(requestId), -32602, "Unknown session " + sessionId);
350+
return;
351+
}
352+
353+
com.github.copilot.sdk.json.UserInputRequest request = new com.github.copilot.sdk.json.UserInputRequest()
354+
.setQuestion(question);
355+
if (choicesNode != null && choicesNode.isArray()) {
356+
List<String> choices = new ArrayList<>();
357+
for (JsonNode choice : choicesNode) {
358+
choices.add(choice.asText());
359+
}
360+
request.setChoices(choices);
361+
}
362+
if (allowFreeformNode != null) {
363+
request.setAllowFreeform(allowFreeformNode.asBoolean());
364+
}
365+
366+
session.handleUserInputRequest(request).thenAccept(response -> {
367+
try {
368+
rpc.sendResponse(Long.parseLong(requestId),
369+
Map.of("answer", response.getAnswer(), "wasFreeform", response.isWasFreeform()));
370+
} catch (IOException e) {
371+
LOG.log(Level.SEVERE, "Error sending user input response", e);
372+
}
373+
}).exceptionally(ex -> {
374+
try {
375+
rpc.sendErrorResponse(Long.parseLong(requestId), -32603,
376+
"User input handler error: " + ex.getMessage());
377+
} catch (IOException e) {
378+
LOG.log(Level.SEVERE, "Error sending user input error", e);
379+
}
380+
return null;
381+
});
382+
} catch (Exception e) {
383+
LOG.log(Level.SEVERE, "Error handling user input request", e);
384+
}
385+
});
386+
}
387+
388+
private void handleHooksInvoke(JsonRpcClient rpc, String requestId, JsonNode params) {
389+
CompletableFuture.runAsync(() -> {
390+
try {
391+
String sessionId = params.get("sessionId").asText();
392+
String hookType = params.get("hookType").asText();
393+
JsonNode input = params.get("input");
394+
395+
CopilotSession session = sessions.get(sessionId);
396+
if (session == null) {
397+
rpc.sendErrorResponse(Long.parseLong(requestId), -32602, "Unknown session " + sessionId);
398+
return;
399+
}
400+
401+
session.handleHooksInvoke(hookType, input).thenAccept(output -> {
402+
try {
403+
if (output != null) {
404+
rpc.sendResponse(Long.parseLong(requestId), Map.of("output", output));
405+
} else {
406+
rpc.sendResponse(Long.parseLong(requestId), Map.of("output", (Object) null));
407+
}
408+
} catch (IOException e) {
409+
LOG.log(Level.SEVERE, "Error sending hooks response", e);
410+
}
411+
}).exceptionally(ex -> {
412+
try {
413+
rpc.sendErrorResponse(Long.parseLong(requestId), -32603,
414+
"Hooks handler error: " + ex.getMessage());
415+
} catch (IOException e) {
416+
LOG.log(Level.SEVERE, "Error sending hooks error", e);
417+
}
418+
return null;
419+
});
420+
} catch (Exception e) {
421+
LOG.log(Level.SEVERE, "Error handling hooks invoke", e);
422+
}
423+
});
424+
}
425+
320426
private void verifyProtocolVersion(Connection connection) throws Exception {
321427
int expectedVersion = SdkProtocolVersion.get();
322428
Map<String, Object> params = new HashMap<>();
@@ -373,6 +479,9 @@ private CompletableFuture<Void> cleanupConnection() {
373479
CompletableFuture<Connection> future = connectionFuture;
374480
connectionFuture = null;
375481

482+
// Clear models cache
483+
modelsCache = null;
484+
376485
if (future == null) {
377486
return CompletableFuture.completedFuture(null);
378487
}
@@ -414,6 +523,7 @@ public CompletableFuture<CopilotSession> createSession(SessionConfig config) {
414523
if (config != null) {
415524
request.setModel(config.getModel());
416525
request.setSessionId(config.getSessionId());
526+
request.setReasoningEffort(config.getReasoningEffort());
417527
request.setTools(config.getTools() != null
418528
? config.getTools().stream()
419529
.map(t -> new ToolDef(t.getName(), t.getDescription(), t.getParameters()))
@@ -424,6 +534,9 @@ public CompletableFuture<CopilotSession> createSession(SessionConfig config) {
424534
request.setExcludedTools(config.getExcludedTools());
425535
request.setProvider(config.getProvider());
426536
request.setRequestPermission(config.getOnPermissionRequest() != null ? true : null);
537+
request.setRequestUserInput(config.getOnUserInputRequest() != null ? true : null);
538+
request.setHooks(config.getHooks() != null && config.getHooks().hasHooks() ? true : null);
539+
request.setWorkingDirectory(config.getWorkingDirectory());
427540
request.setStreaming(config.isStreaming() ? true : null);
428541
request.setMcpServers(config.getMcpServers());
429542
request.setCustomAgents(config.getCustomAgents());
@@ -442,6 +555,12 @@ public CompletableFuture<CopilotSession> createSession(SessionConfig config) {
442555
if (config != null && config.getOnPermissionRequest() != null) {
443556
session.registerPermissionHandler(config.getOnPermissionRequest());
444557
}
558+
if (config != null && config.getOnUserInputRequest() != null) {
559+
session.registerUserInputHandler(config.getOnUserInputRequest());
560+
}
561+
if (config != null && config.getHooks() != null) {
562+
session.registerHooks(config.getHooks());
563+
}
445564
sessions.put(response.getSessionId(), session);
446565
return session;
447566
});
@@ -478,13 +597,18 @@ public CompletableFuture<CopilotSession> resumeSession(String sessionId, ResumeS
478597
ResumeSessionRequest request = new ResumeSessionRequest();
479598
request.setSessionId(sessionId);
480599
if (config != null) {
600+
request.setReasoningEffort(config.getReasoningEffort());
481601
request.setTools(config.getTools() != null
482602
? config.getTools().stream()
483603
.map(t -> new ToolDef(t.getName(), t.getDescription(), t.getParameters()))
484604
.collect(Collectors.toList())
485605
: null);
486606
request.setProvider(config.getProvider());
487607
request.setRequestPermission(config.getOnPermissionRequest() != null ? true : null);
608+
request.setRequestUserInput(config.getOnUserInputRequest() != null ? true : null);
609+
request.setHooks(config.getHooks() != null && config.getHooks().hasHooks() ? true : null);
610+
request.setWorkingDirectory(config.getWorkingDirectory());
611+
request.setDisableResume(config.isDisableResume() ? true : null);
488612
request.setStreaming(config.isStreaming() ? true : null);
489613
request.setMcpServers(config.getMcpServers());
490614
request.setCustomAgents(config.getCustomAgents());
@@ -501,6 +625,12 @@ public CompletableFuture<CopilotSession> resumeSession(String sessionId, ResumeS
501625
if (config != null && config.getOnPermissionRequest() != null) {
502626
session.registerPermissionHandler(config.getOnPermissionRequest());
503627
}
628+
if (config != null && config.getOnUserInputRequest() != null) {
629+
session.registerUserInputHandler(config.getOnUserInputRequest());
630+
}
631+
if (config != null && config.getHooks() != null) {
632+
session.registerHooks(config.getHooks());
633+
}
504634
sessions.put(response.getSessionId(), session);
505635
return session;
506636
});
@@ -576,13 +706,36 @@ public CompletableFuture<GetAuthStatusResponse> getAuthStatus() {
576706

577707
/**
578708
* Lists available models with their metadata.
709+
* <p>
710+
* Results are cached after the first successful call to avoid rate limiting.
711+
* The cache is cleared when the client disconnects.
579712
*
580713
* @return a future that resolves with a list of available models
581714
* @see ModelInfo
582715
*/
583716
public CompletableFuture<List<ModelInfo>> listModels() {
584-
return ensureConnected().thenCompose(connection -> connection.rpc
585-
.invoke("models.list", Map.of(), GetModelsResponse.class).thenApply(GetModelsResponse::getModels));
717+
// Check cache first
718+
List<ModelInfo> cached = modelsCache;
719+
if (cached != null) {
720+
return CompletableFuture.completedFuture(new ArrayList<>(cached));
721+
}
722+
723+
return ensureConnected().thenCompose(connection -> {
724+
// Double-check cache inside lock
725+
synchronized (modelsCacheLock) {
726+
if (modelsCache != null) {
727+
return CompletableFuture.completedFuture(new ArrayList<>(modelsCache));
728+
}
729+
}
730+
731+
return connection.rpc.invoke("models.list", Map.of(), GetModelsResponse.class).thenApply(response -> {
732+
List<ModelInfo> models = response.getModels();
733+
synchronized (modelsCacheLock) {
734+
modelsCache = models;
735+
}
736+
return new ArrayList<>(models); // Return a copy to prevent cache mutation
737+
});
738+
});
586739
}
587740

588741
/**
@@ -668,6 +821,20 @@ private ProcessInfo startCliServer() throws IOException, InterruptedException {
668821
args.add(String.valueOf(options.getPort()));
669822
}
670823

824+
// Add auth-related flags
825+
if (options.getGithubToken() != null && !options.getGithubToken().isEmpty()) {
826+
args.add("--auth-token-env");
827+
args.add("COPILOT_SDK_AUTH_TOKEN");
828+
}
829+
830+
// Default UseLoggedInUser to false when GithubToken is provided
831+
boolean useLoggedInUser = options.getUseLoggedInUser() != null
832+
? options.getUseLoggedInUser()
833+
: (options.getGithubToken() == null || options.getGithubToken().isEmpty());
834+
if (!useLoggedInUser) {
835+
args.add("--no-auto-login");
836+
}
837+
671838
List<String> command = resolveCliCommand(cliPath, args);
672839

673840
ProcessBuilder pb = new ProcessBuilder(command);
@@ -683,6 +850,11 @@ private ProcessInfo startCliServer() throws IOException, InterruptedException {
683850
}
684851
pb.environment().remove("NODE_DEBUG");
685852

853+
// Set auth token in environment if provided
854+
if (options.getGithubToken() != null && !options.getGithubToken().isEmpty()) {
855+
pb.environment().put("COPILOT_SDK_AUTH_TOKEN", options.getGithubToken());
856+
}
857+
686858
Process process = pb.start();
687859

688860
// Forward stderr to logger in background

0 commit comments

Comments
 (0)