Skip to content

Commit ae3896f

Browse files
committed
Add session.options.update call for feature flag propagation
Implement updateSessionOptionsForMode() in CopilotClient to send session.options.update RPC call after session creation and resumption, matching the behavior of the Node.js, .NET, and Go reference implementations. In EMPTY mode, safe defaults are applied. In COPILOT_CLI mode, only explicitly-set fields are forwarded. Includes 8 unit tests covering both modes and partial overrides.
1 parent 7c5c970 commit ae3896f

2 files changed

Lines changed: 448 additions & 47 deletions

File tree

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

Lines changed: 188 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@
2222
import com.github.copilot.rpc.CopilotClientMode;
2323
import com.github.copilot.rpc.CopilotClientOptions;
2424
import com.github.copilot.rpc.CreateSessionResponse;
25+
import com.github.copilot.generated.rpc.SessionOptionsUpdateParams;
26+
import com.github.copilot.generated.rpc.SessionInstalledPlugin;
2527
import com.github.copilot.generated.rpc.ConnectParams;
2628
import com.github.copilot.generated.rpc.ServerRpc;
2729
import com.github.copilot.rpc.DeleteSessionResponse;
@@ -484,30 +486,40 @@ public CompletableFuture<CopilotSession> createSession(SessionConfig config) {
484486
}
485487

486488
long rpcNanos = System.nanoTime();
487-
return connection.rpc.invoke("session.create", request, CreateSessionResponse.class).thenApply(response -> {
488-
LoggingHelpers.logTiming(LOG, Level.FINE,
489-
"CopilotClient.createSession session creation request completed. Elapsed={Elapsed}, SessionId="
490-
+ sessionId,
491-
rpcNanos);
492-
session.setWorkspacePath(response.workspacePath());
493-
session.setCapabilities(response.capabilities());
494-
// If the server returned a different sessionId (e.g. a v2 CLI that ignores
495-
// the client-supplied ID), re-key the sessions map.
496-
String returnedId = response.sessionId();
497-
if (returnedId != null && !returnedId.equals(sessionId)) {
498-
sessions.remove(sessionId);
499-
session.setActiveSessionId(returnedId);
500-
sessions.put(returnedId, session);
501-
}
502-
LoggingHelpers.logTiming(LOG, Level.FINE,
503-
"CopilotClient.createSession complete. Elapsed={Elapsed}, SessionId=" + sessionId, totalNanos);
504-
return session;
505-
}).exceptionally(ex -> {
506-
sessions.remove(sessionId);
507-
LoggingHelpers.logTiming(LOG, Level.WARNING, ex,
508-
"CopilotClient.createSession failed. Elapsed={Elapsed}, SessionId=" + sessionId, totalNanos);
509-
throw ex instanceof RuntimeException re ? re : new RuntimeException(ex);
510-
});
489+
return connection.rpc.invoke("session.create", request, CreateSessionResponse.class)
490+
.thenCompose(response -> {
491+
LoggingHelpers.logTiming(LOG, Level.FINE,
492+
"CopilotClient.createSession session creation request completed. Elapsed={Elapsed}, SessionId="
493+
+ sessionId,
494+
rpcNanos);
495+
session.setWorkspacePath(response.workspacePath());
496+
session.setCapabilities(response.capabilities());
497+
// If the server returned a different sessionId (e.g. a v2 CLI that ignores
498+
// the client-supplied ID), re-key the sessions map.
499+
String returnedId = response.sessionId();
500+
if (returnedId != null && !returnedId.equals(sessionId)) {
501+
sessions.remove(sessionId);
502+
session.setActiveSessionId(returnedId);
503+
sessions.put(returnedId, session);
504+
}
505+
506+
return updateSessionOptionsForMode(session, config.getSkipCustomInstructions().orElse(null),
507+
config.getCustomAgentsLocalOnly().orElse(null),
508+
config.getCoauthorEnabled().orElse(null),
509+
config.getManageScheduleEnabled().orElse(null)).thenApply(v -> {
510+
LoggingHelpers.logTiming(LOG, Level.FINE,
511+
"CopilotClient.createSession complete. Elapsed={Elapsed}, SessionId="
512+
+ sessionId,
513+
totalNanos);
514+
return session;
515+
});
516+
}).exceptionally(ex -> {
517+
sessions.remove(sessionId);
518+
LoggingHelpers.logTiming(LOG, Level.WARNING, ex,
519+
"CopilotClient.createSession failed. Elapsed={Elapsed}, SessionId=" + sessionId,
520+
totalNanos);
521+
throw ex instanceof RuntimeException re ? re : new RuntimeException(ex);
522+
});
511523
});
512524
}
513525

@@ -581,29 +593,158 @@ public CompletableFuture<CopilotSession> resumeSession(String sessionId, ResumeS
581593
}
582594

583595
long rpcNanos = System.nanoTime();
584-
return connection.rpc.invoke("session.resume", request, ResumeSessionResponse.class).thenApply(response -> {
585-
LoggingHelpers.logTiming(LOG, Level.FINE,
586-
"CopilotClient.resumeSession session resume request completed. Elapsed={Elapsed}, SessionId="
587-
+ sessionId,
588-
rpcNanos);
589-
session.setWorkspacePath(response.workspacePath());
590-
session.setCapabilities(response.capabilities());
591-
// If the server returned a different sessionId than what was requested, re-key.
592-
String returnedId = response.sessionId();
593-
if (returnedId != null && !returnedId.equals(sessionId)) {
594-
sessions.remove(sessionId);
595-
session.setActiveSessionId(returnedId);
596-
sessions.put(returnedId, session);
597-
}
598-
LoggingHelpers.logTiming(LOG, Level.FINE,
599-
"CopilotClient.resumeSession complete. Elapsed={Elapsed}, SessionId=" + sessionId, totalNanos);
600-
return session;
601-
}).exceptionally(ex -> {
602-
sessions.remove(sessionId);
603-
LoggingHelpers.logTiming(LOG, Level.WARNING, ex,
604-
"CopilotClient.resumeSession failed. Elapsed={Elapsed}, SessionId=" + sessionId, totalNanos);
605-
throw ex instanceof RuntimeException re ? re : new RuntimeException(ex);
606-
});
596+
return connection.rpc.invoke("session.resume", request, ResumeSessionResponse.class)
597+
.thenCompose(response -> {
598+
LoggingHelpers.logTiming(LOG, Level.FINE,
599+
"CopilotClient.resumeSession session resume request completed. Elapsed={Elapsed}, SessionId="
600+
+ sessionId,
601+
rpcNanos);
602+
session.setWorkspacePath(response.workspacePath());
603+
session.setCapabilities(response.capabilities());
604+
// If the server returned a different sessionId than what was requested,
605+
// re-key.
606+
String returnedId = response.sessionId();
607+
if (returnedId != null && !returnedId.equals(sessionId)) {
608+
sessions.remove(sessionId);
609+
session.setActiveSessionId(returnedId);
610+
sessions.put(returnedId, session);
611+
}
612+
613+
return updateSessionOptionsForMode(session, config.getSkipCustomInstructions().orElse(null),
614+
config.getCustomAgentsLocalOnly().orElse(null),
615+
config.getCoauthorEnabled().orElse(null),
616+
config.getManageScheduleEnabled().orElse(null)).thenApply(v -> {
617+
LoggingHelpers.logTiming(LOG, Level.FINE,
618+
"CopilotClient.resumeSession complete. Elapsed={Elapsed}, SessionId="
619+
+ sessionId,
620+
totalNanos);
621+
return session;
622+
});
623+
}).exceptionally(ex -> {
624+
sessions.remove(sessionId);
625+
LoggingHelpers.logTiming(LOG, Level.WARNING, ex,
626+
"CopilotClient.resumeSession failed. Elapsed={Elapsed}, SessionId=" + sessionId,
627+
totalNanos);
628+
throw ex instanceof RuntimeException re ? re : new RuntimeException(ex);
629+
});
630+
});
631+
}
632+
633+
/**
634+
* Applies the post-create / post-resume {@code session.options.update} patch.
635+
* <p>
636+
* In {@link CopilotClientMode#EMPTY EMPTY} mode this defaults the four
637+
* overridable feature flags to safe values (caller values from the config win);
638+
* {@code installedPlugins=[]} is unconditional under empty mode so apps that
639+
* need plugins must switch modes. In {@link CopilotClientMode#COPILOT_CLI
640+
* COPILOT_CLI} mode only explicitly-set fields are forwarded.
641+
*
642+
* @param session
643+
* the session to patch
644+
* @param skipCustomInstructions
645+
* caller-supplied value, or {@code null} if not set
646+
* @param customAgentsLocalOnly
647+
* caller-supplied value, or {@code null} if not set
648+
* @param coauthorEnabled
649+
* caller-supplied value, or {@code null} if not set
650+
* @param manageScheduleEnabled
651+
* caller-supplied value, or {@code null} if not set
652+
* @return a future that completes when the patch has been applied
653+
*/
654+
CompletableFuture<Void> updateSessionOptionsForMode(CopilotSession session, Boolean skipCustomInstructions,
655+
Boolean customAgentsLocalOnly, Boolean coauthorEnabled, Boolean manageScheduleEnabled) {
656+
657+
Boolean patchSkip = null;
658+
Boolean patchAgents = null;
659+
Boolean patchCoauthor = null;
660+
Boolean patchSchedule = null;
661+
List<SessionInstalledPlugin> patchPlugins = null;
662+
boolean hasAnyPatch = false;
663+
664+
if (options.getMode() == CopilotClientMode.EMPTY) {
665+
patchSkip = skipCustomInstructions != null ? skipCustomInstructions : true;
666+
patchAgents = customAgentsLocalOnly != null ? customAgentsLocalOnly : true;
667+
patchCoauthor = coauthorEnabled != null ? coauthorEnabled : false;
668+
patchSchedule = manageScheduleEnabled != null ? manageScheduleEnabled : false;
669+
patchPlugins = List.of();
670+
hasAnyPatch = true;
671+
} else {
672+
if (skipCustomInstructions != null) {
673+
patchSkip = skipCustomInstructions;
674+
hasAnyPatch = true;
675+
}
676+
if (customAgentsLocalOnly != null) {
677+
patchAgents = customAgentsLocalOnly;
678+
hasAnyPatch = true;
679+
}
680+
if (coauthorEnabled != null) {
681+
patchCoauthor = coauthorEnabled;
682+
hasAnyPatch = true;
683+
}
684+
if (manageScheduleEnabled != null) {
685+
patchSchedule = manageScheduleEnabled;
686+
hasAnyPatch = true;
687+
}
688+
}
689+
690+
if (!hasAnyPatch) {
691+
return CompletableFuture.completedFuture(null);
692+
}
693+
694+
var params = new SessionOptionsUpdateParams(null, // sessionId — set by SessionOptionsApi
695+
null, // model
696+
null, // reasoningEffort
697+
null, // clientName
698+
null, // lspClientName
699+
null, // integrationId
700+
null, // featureFlags
701+
null, // isExperimentalMode
702+
null, // provider
703+
null, // workingDirectory
704+
null, // availableTools
705+
null, // excludedTools
706+
null, // toolFilterPrecedence
707+
null, // enableScriptSafety
708+
null, // shellInitProfile
709+
null, // shellProcessFlags
710+
null, // sandboxConfig
711+
null, // logInteractiveShells
712+
null, // envValueMode
713+
null, // skillDirectories
714+
null, // disabledSkills
715+
null, // enableOnDemandInstructionDiscovery
716+
patchPlugins, // installedPlugins
717+
patchAgents, // customAgentsLocalOnly
718+
patchSkip, // skipCustomInstructions
719+
null, // disabledInstructionSources
720+
patchCoauthor, // coauthorEnabled
721+
null, // trajectoryFile
722+
null, // enableStreaming
723+
null, // copilotUrl
724+
null, // askUserDisabled
725+
null, // continueOnAutoMode
726+
null, // runningInInteractiveMode
727+
null, // enableReasoningSummaries
728+
null, // agentContext
729+
null, // eventsLogDirectory
730+
null, // additionalContentExclusionPolicies
731+
patchSchedule // manageScheduleEnabled
732+
);
733+
734+
return session.getRpc().options.update(params).<Void>thenCompose(result -> {
735+
LOG.fine("session.options.update applied for session " + session.getSessionId());
736+
return CompletableFuture.completedFuture(null);
737+
}).exceptionally(ex -> {
738+
// The runtime session exists but the post-create options patch failed.
739+
// Best-effort disconnect so we don't leak it (in empty mode it would
740+
// otherwise stay alive with permissive defaults).
741+
LOG.log(Level.WARNING, "session.options.update failed for session " + session.getSessionId(), ex);
742+
try {
743+
session.close();
744+
} catch (Exception closeEx) {
745+
// Swallow: original error is the one the caller needs.
746+
}
747+
throw ex instanceof RuntimeException re ? re : new RuntimeException(ex);
607748
});
608749
}
609750

0 commit comments

Comments
 (0)