Skip to content

Commit 543d155

Browse files
Copilotedburns
andauthored
Port reference implementation changes: remote sessions, provider model overrides, startup cleanup race fix
- Add Remote option to CopilotClientOptions with --remote CLI flag - Add modelId, wireModel, maxPromptTokens, maxOutputTokens to ProviderConfig - Fix client startup cleanup race condition (process cleanup on failure) - Update E2E test harness for CONNECT proxy metadata - Add unit tests for new provider config fields and clone - Add E2E tests for provider wire model feature - Document remote sessions and model overrides in advanced.md Co-authored-by: edburns <75821+edburns@users.noreply.github.com>
1 parent 51e0127 commit 543d155

10 files changed

Lines changed: 398 additions & 15 deletions

File tree

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,10 @@ ProcessInfo startCliServer() throws IOException, InterruptedException {
102102
args.add(String.valueOf(options.getSessionIdleTimeoutSeconds()));
103103
}
104104

105+
if (options.isRemote()) {
106+
args.add("--remote");
107+
}
108+
105109
List<String> command = resolveCliCommand(cliPath, args);
106110

107111
var pb = new ProcessBuilder(command);

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

Lines changed: 25 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -187,9 +187,9 @@ private CompletableFuture<Connection> startCore() {
187187
}
188188

189189
private Connection startCoreBody() {
190+
Process process = null;
190191
try {
191192
JsonRpcClient rpc;
192-
Process process = null;
193193

194194
if (optionsHost != null && optionsPort != null) {
195195
// External server (TCP)
@@ -215,6 +215,10 @@ private Connection startCoreBody() {
215215
LOG.info("Copilot client connected");
216216
return connection;
217217
} catch (Exception e) {
218+
// Clean up the spawned process if connection setup failed
219+
if (process != null) {
220+
cleanupCliProcess(process);
221+
}
218222
String stderr = serverManager.getStderrOutput();
219223
if (!stderr.isEmpty()) {
220224
throw new CompletionException(new IOException(
@@ -352,21 +356,28 @@ private CompletableFuture<Void> cleanupConnection() {
352356
}
353357

354358
if (connection.process != null) {
355-
try {
356-
if (connection.process.isAlive()) {
357-
Process destroyedProcess = connection.process.destroyForcibly();
358-
if (!destroyedProcess.waitFor(FORCE_KILL_TIMEOUT_SECONDS, TimeUnit.SECONDS)) {
359-
LOG.fine("Process did not terminate within force kill timeout");
360-
}
361-
}
362-
} catch (InterruptedException e) {
363-
Thread.currentThread().interrupt();
364-
LOG.log(Level.FINE, "Interrupted while killing process", e);
365-
} catch (Exception e) {
366-
LOG.log(Level.FINE, "Error killing process", e);
359+
cleanupCliProcess(connection.process);
360+
}
361+
}).exceptionally(ex -> {
362+
LOG.log(Level.FINE, "Ignoring failed Copilot client startup during cleanup", ex);
363+
return null;
364+
});
365+
}
366+
367+
private static void cleanupCliProcess(Process process) {
368+
try {
369+
if (process.isAlive()) {
370+
Process destroyedProcess = process.destroyForcibly();
371+
if (!destroyedProcess.waitFor(FORCE_KILL_TIMEOUT_SECONDS, TimeUnit.SECONDS)) {
372+
LOG.fine("Process did not terminate within force kill timeout");
367373
}
368374
}
369-
}).exceptionally(ex -> null);
375+
} catch (InterruptedException e) {
376+
Thread.currentThread().interrupt();
377+
LOG.log(Level.FINE, "Interrupted while killing process", e);
378+
} catch (Exception e) {
379+
LOG.log(Level.FINE, "Error killing process", e);
380+
}
370381
}
371382

372383
/**

src/main/java/com/github/copilot/sdk/json/CopilotClientOptions.java

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ public class CopilotClientOptions {
5454
private int port;
5555
private TelemetryConfig telemetry;
5656
private Integer sessionIdleTimeoutSeconds;
57+
private boolean remote;
5758
private String tcpConnectionToken;
5859
private Boolean useLoggedInUser;
5960
private boolean useStdio = true;
@@ -438,6 +439,37 @@ public CopilotClientOptions setPort(int port) {
438439
return this;
439440
}
440441

442+
/**
443+
* Returns whether remote session support (Mission Control integration) is
444+
* enabled.
445+
* <p>
446+
* When {@code true}, sessions in a GitHub repository working directory are
447+
* accessible from GitHub web and mobile.
448+
*
449+
* @return {@code true} if remote sessions are enabled
450+
*/
451+
public boolean isRemote() {
452+
return remote;
453+
}
454+
455+
/**
456+
* Enables remote session support (Mission Control integration).
457+
* <p>
458+
* When {@code true}, sessions in a GitHub repository working directory are
459+
* accessible from GitHub web and mobile.
460+
* <p>
461+
* This option is only used when the SDK spawns the CLI process; it is ignored
462+
* when connecting to an external server via {@link #setCliUrl(String)}.
463+
*
464+
* @param remote
465+
* {@code true} to enable remote sessions
466+
* @return this options instance for method chaining
467+
*/
468+
public CopilotClientOptions setRemote(boolean remote) {
469+
this.remote = remote;
470+
return this;
471+
}
472+
441473
/**
442474
* Gets the OpenTelemetry configuration for the CLI server.
443475
*
@@ -599,6 +631,7 @@ public CopilotClientOptions clone() {
599631
copy.logLevel = this.logLevel;
600632
copy.onListModels = this.onListModels;
601633
copy.port = this.port;
634+
copy.remote = this.remote;
602635
copy.sessionIdleTimeoutSeconds = this.sessionIdleTimeoutSeconds;
603636
copy.tcpConnectionToken = this.tcpConnectionToken;
604637
copy.telemetry = this.telemetry;

src/main/java/com/github/copilot/sdk/json/ProviderConfig.java

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,18 @@ public class ProviderConfig {
5757
@JsonProperty("headers")
5858
private Map<String, String> headers;
5959

60+
@JsonProperty("modelId")
61+
private String modelId;
62+
63+
@JsonProperty("wireModel")
64+
private String wireModel;
65+
66+
@JsonProperty("maxPromptTokens")
67+
private Integer maxPromptTokens;
68+
69+
@JsonProperty("maxOutputTokens")
70+
private Integer maxOutputTokens;
71+
6072
/**
6173
* Gets the provider type.
6274
*
@@ -225,4 +237,109 @@ public ProviderConfig setHeaders(Map<String, String> headers) {
225237
this.headers = headers;
226238
return this;
227239
}
240+
241+
/**
242+
* Gets the well-known model name used by the runtime.
243+
* <p>
244+
* Used to look up agent configuration (tools, prompts, reasoning behavior) and
245+
* default token limits. Also used as the wire model when
246+
* {@link #getWireModel()} is not set.
247+
*
248+
* @return the model ID, or {@code null} if not set
249+
*/
250+
public String getModelId() {
251+
return modelId;
252+
}
253+
254+
/**
255+
* Sets the well-known model name used by the runtime.
256+
* <p>
257+
* Used to look up agent configuration (tools, prompts, reasoning behavior) and
258+
* default token limits. Also used as the wire model when
259+
* {@link #getWireModel()} is not set. Falls back to
260+
* {@link SessionConfig#getModel()}.
261+
*
262+
* @param modelId
263+
* the model ID
264+
* @return this config for method chaining
265+
*/
266+
public ProviderConfig setModelId(String modelId) {
267+
this.modelId = modelId;
268+
return this;
269+
}
270+
271+
/**
272+
* Gets the model name sent to the provider API for inference.
273+
*
274+
* @return the wire model name, or {@code null} if not set
275+
*/
276+
public String getWireModel() {
277+
return wireModel;
278+
}
279+
280+
/**
281+
* Sets the model name sent to the provider API for inference.
282+
* <p>
283+
* Use this when the provider's model name (e.g. an Azure deployment name or a
284+
* custom fine-tune name) differs from {@link #getModelId()}. Falls back to
285+
* {@link #getModelId()}, then {@link SessionConfig#getModel()}.
286+
*
287+
* @param wireModel
288+
* the wire model name
289+
* @return this config for method chaining
290+
*/
291+
public ProviderConfig setWireModel(String wireModel) {
292+
this.wireModel = wireModel;
293+
return this;
294+
}
295+
296+
/**
297+
* Gets the maximum prompt token override.
298+
*
299+
* @return the max prompt tokens, or {@code null} if not set
300+
*/
301+
public Integer getMaxPromptTokens() {
302+
return maxPromptTokens;
303+
}
304+
305+
/**
306+
* Sets the maximum prompt tokens override.
307+
* <p>
308+
* Overrides the resolved model's default max prompt tokens. The runtime
309+
* triggers conversation compaction before sending a request when the prompt
310+
* (system message, history, tool definitions, user message) would exceed this
311+
* limit.
312+
*
313+
* @param maxPromptTokens
314+
* the max prompt tokens
315+
* @return this config for method chaining
316+
*/
317+
public ProviderConfig setMaxPromptTokens(Integer maxPromptTokens) {
318+
this.maxPromptTokens = maxPromptTokens;
319+
return this;
320+
}
321+
322+
/**
323+
* Gets the maximum output token override.
324+
*
325+
* @return the max output tokens, or {@code null} if not set
326+
*/
327+
public Integer getMaxOutputTokens() {
328+
return maxOutputTokens;
329+
}
330+
331+
/**
332+
* Sets the maximum output tokens override.
333+
* <p>
334+
* Overrides the resolved model's default max output tokens. When hit, the model
335+
* stops generating and returns a truncated response.
336+
*
337+
* @param maxOutputTokens
338+
* the max output tokens
339+
* @return this config for method chaining
340+
*/
341+
public ProviderConfig setMaxOutputTokens(Integer maxOutputTokens) {
342+
this.maxOutputTokens = maxOutputTokens;
343+
return this;
344+
}
228345
}

src/site/markdown/advanced.md

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -383,6 +383,29 @@ var session = client.createSession(
383383

384384
> **Note:** The `bearerToken` option accepts a **static token string** only. The SDK does not refresh this token automatically. If your token expires, requests will fail and you'll need to create a new session with a fresh token.
385385
386+
### Model Overrides
387+
388+
Use `modelId` and `wireModel` to control model resolution and the model name on the wire:
389+
390+
```java
391+
var session = client.createSession(
392+
new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL)
393+
.setProvider(new ProviderConfig()
394+
.setType("openai")
395+
.setBaseUrl("https://api.openai.com/v1")
396+
.setApiKey("sk-...")
397+
.setModelId("gpt-4o") // Runtime config lookup
398+
.setWireModel("my-finetune-v3") // Sent to the provider API
399+
.setMaxPromptTokens(100_000) // Override max prompt tokens
400+
.setMaxOutputTokens(4096)) // Override max output tokens
401+
).get();
402+
```
403+
404+
- **`modelId`** — Well-known model name used by the runtime to look up agent configuration (tools, prompts, reasoning behavior) and default token limits. Also used as the wire model when `wireModel` is not set.
405+
- **`wireModel`** — Model name sent to the provider API for inference. Use when the provider's model name (e.g., an Azure deployment name or a custom fine-tune name) differs from `modelId`.
406+
- **`maxPromptTokens`** — Overrides the resolved model's default max prompt tokens. The runtime triggers conversation compaction when the prompt would exceed this limit.
407+
- **`maxOutputTokens`** — Overrides the resolved model's default max output tokens.
408+
386409
### Microsoft Foundry Local
387410

388411
[Microsoft Foundry Local](https://foundrylocal.ai) lets you run AI models locally on your own device with an OpenAI-compatible API. Install it via the Foundry Local CLI, then point the SDK at your local endpoint:
@@ -1262,6 +1285,39 @@ This is more efficient than `listSessions()` when you already know the session I
12621285

12631286
---
12641287

1288+
## Remote Sessions
1289+
1290+
Remote sessions enable Mission Control integration, making sessions accessible from GitHub web and mobile. When enabled, sessions in a GitHub repository working directory receive a remote URL.
1291+
1292+
### Enabling Remote Sessions
1293+
1294+
Set `remote(true)` on the client options to enable remote session support for all sessions:
1295+
1296+
```java
1297+
var options = new CopilotClientOptions()
1298+
.setRemote(true)
1299+
.setCwd("/path/to/github-repo");
1300+
1301+
try (var client = new CopilotClient(options)) {
1302+
var session = client.createSession(
1303+
new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL)
1304+
).get();
1305+
1306+
// Listen for the remote URL info event
1307+
session.on(SessionInfoEvent.class, event -> {
1308+
System.out.println("Remote URL: " + event.getData());
1309+
});
1310+
}
1311+
```
1312+
1313+
### Prerequisites
1314+
1315+
- The user must be authenticated (GitHub token or logged-in user)
1316+
- The session's working directory must be a GitHub repository
1317+
- This option is only used when the SDK spawns the CLI process; it is ignored when connecting to an external server via `setCliUrl()`
1318+
1319+
---
1320+
12651321
## Next Steps
12661322

12671323
- 📖 **[Documentation](documentation.html)** - Core concepts, events, streaming, models, tool filtering, reasoning effort

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

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,10 +56,12 @@
5656
public class CapiProxy implements AutoCloseable {
5757

5858
private static final ObjectMapper MAPPER = new ObjectMapper();
59-
private static final Pattern LISTENING_PATTERN = Pattern.compile("Listening: (http://[^\\s]+)");
59+
private static final Pattern LISTENING_PATTERN = Pattern.compile("Listening: (http://[^\\s]+)(?:\\s+(\\{.*\\}))?$");
6060

6161
private Process process;
6262
private String proxyUrl;
63+
private String connectProxyUrl;
64+
private String caFilePath;
6365
private final HttpClient httpClient;
6466
private BufferedReader stdoutReader;
6567

@@ -138,6 +140,21 @@ public String start() throws IOException, InterruptedException {
138140
}
139141

140142
proxyUrl = matcher.group(1);
143+
144+
// Parse optional metadata (CONNECT proxy details)
145+
String metadata = matcher.group(2);
146+
if (metadata != null && !metadata.isEmpty()) {
147+
try {
148+
Map<String, String> meta = MAPPER.readValue(metadata, new TypeReference<Map<String, String>>() {
149+
});
150+
connectProxyUrl = meta.get("connectProxyUrl");
151+
caFilePath = meta.get("caFilePath");
152+
} catch (Exception e) {
153+
process.destroyForcibly();
154+
throw new IOException("Failed to parse proxy startup metadata: " + metadata, e);
155+
}
156+
}
157+
141158
return proxyUrl;
142159
}
143160

@@ -329,6 +346,8 @@ public void stop(boolean skipWritingCache) throws IOException, InterruptedExcept
329346

330347
process = null;
331348
proxyUrl = null;
349+
connectProxyUrl = null;
350+
caFilePath = null;
332351
}
333352

334353
/**
@@ -340,6 +359,24 @@ public String getProxyUrl() {
340359
return proxyUrl;
341360
}
342361

362+
/**
363+
* Gets the CONNECT proxy URL for HTTPS interception.
364+
*
365+
* @return the CONNECT proxy URL, or null if not available
366+
*/
367+
public String getConnectProxyUrl() {
368+
return connectProxyUrl;
369+
}
370+
371+
/**
372+
* Gets the CA file path for trusting the CONNECT proxy's certificate.
373+
*
374+
* @return the CA file path, or null if not available
375+
*/
376+
public String getCaFilePath() {
377+
return caFilePath;
378+
}
379+
343380
/**
344381
* Checks if the proxy process is still alive and responsive. This does both a
345382
* process alive check AND an HTTP health check.

0 commit comments

Comments
 (0)