diff --git a/.github/scripts/build/format-and-test.sh b/.github/scripts/build/format-and-test.sh index 6de3972f3..c827813bb 100755 --- a/.github/scripts/build/format-and-test.sh +++ b/.github/scripts/build/format-and-test.sh @@ -13,7 +13,7 @@ # ────────────────────────────────────────────────────────────── set -euo pipefail -ROOT_DIR="$(cd "$(dirname "$0")/../.." && pwd)" +ROOT_DIR="$(cd "$(dirname "$0")/../../.." && pwd)" cd "$ROOT_DIR" FORMAT=true diff --git a/README.md b/README.md index 5a597fcb7..ae93d99b8 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ Java SDK for programmatic control of GitHub Copilot CLI, enabling you to build A ### Requirements - Java 17 or later -- GitHub Copilot CLI 0.0.409 or later installed and in PATH (or provide custom `cliPath`) +- GitHub Copilot CLI 0.0.411-1 or later installed and in PATH (or provide custom `cliPath`) ### Maven diff --git a/src/main/java/com/github/copilot/sdk/CliServerManager.java b/src/main/java/com/github/copilot/sdk/CliServerManager.java index f4f570d49..42e41245e 100644 --- a/src/main/java/com/github/copilot/sdk/CliServerManager.java +++ b/src/main/java/com/github/copilot/sdk/CliServerManager.java @@ -32,6 +32,7 @@ final class CliServerManager { private static final Logger LOG = Logger.getLogger(CliServerManager.class.getName()); private final CopilotClientOptions options; + private final StringBuilder stderrBuffer = new StringBuilder(); CliServerManager(CopilotClientOptions options) { this.options = options; @@ -47,6 +48,8 @@ final class CliServerManager { * if interrupted while waiting for port detection */ ProcessInfo startCliServer() throws IOException, InterruptedException { + clearStderrBuffer(); + String cliPath = options.getCliPath() != null ? options.getCliPath() : "copilot"; var args = new ArrayList(); @@ -152,6 +155,9 @@ private void startStderrReader(Process process) { new InputStreamReader(process.getErrorStream(), StandardCharsets.UTF_8))) { String line; while ((line = reader.readLine()) != null) { + synchronized (stderrBuffer) { + stderrBuffer.append(line).append('\n'); + } LOG.fine("[CLI] " + line); } } catch (IOException e) { @@ -171,6 +177,10 @@ private Integer waitForPortAnnouncement(Process process) throws IOException { while (System.currentTimeMillis() < deadline) { String line = reader.readLine(); if (line == null) { + String stderr = getStderrOutput(); + if (!stderr.isEmpty()) { + throw new IOException("CLI process exited unexpectedly. stderr: " + stderr); + } throw new IOException("CLI process exited unexpectedly"); } @@ -185,6 +195,18 @@ private Integer waitForPortAnnouncement(Process process) throws IOException { } } + String getStderrOutput() { + synchronized (stderrBuffer) { + return stderrBuffer.toString().trim(); + } + } + + private void clearStderrBuffer() { + synchronized (stderrBuffer) { + stderrBuffer.setLength(0); + } + } + private List resolveCliCommand(String cliPath, List args) { boolean isJsFile = cliPath.toLowerCase().endsWith(".js"); diff --git a/src/main/java/com/github/copilot/sdk/CopilotClient.java b/src/main/java/com/github/copilot/sdk/CopilotClient.java index 46190c469..8d4806874 100644 --- a/src/main/java/com/github/copilot/sdk/CopilotClient.java +++ b/src/main/java/com/github/copilot/sdk/CopilotClient.java @@ -4,6 +4,7 @@ package com.github.copilot.sdk; +import java.io.IOException; import java.net.URI; import java.util.ArrayList; import java.util.HashMap; @@ -174,6 +175,11 @@ private CompletableFuture startCore() { LOG.info("Copilot client connected"); return connection; } catch (Exception e) { + String stderr = serverManager.getStderrOutput(); + if (!stderr.isEmpty()) { + throw new CompletionException( + new IOException("CLI process exited unexpectedly. stderr: " + stderr, e)); + } throw new CompletionException(e); } }); diff --git a/src/main/java/com/github/copilot/sdk/SessionRequestBuilder.java b/src/main/java/com/github/copilot/sdk/SessionRequestBuilder.java index a59940bcf..61c06e7f0 100644 --- a/src/main/java/com/github/copilot/sdk/SessionRequestBuilder.java +++ b/src/main/java/com/github/copilot/sdk/SessionRequestBuilder.java @@ -49,6 +49,7 @@ static CreateSessionRequest buildCreateRequest(SessionConfig config) { request.setWorkingDirectory(config.getWorkingDirectory()); request.setStreaming(config.isStreaming() ? true : null); request.setMcpServers(config.getMcpServers()); + request.setEnvValueMode("direct"); request.setCustomAgents(config.getCustomAgents()); request.setInfiniteSessions(config.getInfiniteSessions()); request.setSkillDirectories(config.getSkillDirectories()); @@ -90,6 +91,7 @@ static ResumeSessionRequest buildResumeRequest(String sessionId, ResumeSessionCo request.setDisableResume(config.isDisableResume() ? true : null); request.setStreaming(config.isStreaming() ? true : null); request.setMcpServers(config.getMcpServers()); + request.setEnvValueMode("direct"); request.setCustomAgents(config.getCustomAgents()); request.setSkillDirectories(config.getSkillDirectories()); request.setDisabledSkills(config.getDisabledSkills()); diff --git a/src/main/java/com/github/copilot/sdk/events/AbstractSessionEvent.java b/src/main/java/com/github/copilot/sdk/events/AbstractSessionEvent.java index 00c95ed4b..00295ee06 100644 --- a/src/main/java/com/github/copilot/sdk/events/AbstractSessionEvent.java +++ b/src/main/java/com/github/copilot/sdk/events/AbstractSessionEvent.java @@ -52,9 +52,9 @@ public abstract sealed class AbstractSessionEvent permits // Session events SessionStartEvent, SessionResumeEvent, SessionErrorEvent, SessionIdleEvent, SessionInfoEvent, - SessionModelChangeEvent, SessionHandoffEvent, SessionTruncationEvent, SessionSnapshotRewindEvent, - SessionUsageInfoEvent, SessionCompactionStartEvent, SessionCompactionCompleteEvent, SessionShutdownEvent, - SessionContextChangedEvent, + SessionModelChangeEvent, SessionModeChangedEvent, SessionPlanChangedEvent, SessionWorkspaceFileChangedEvent, + SessionHandoffEvent, SessionTruncationEvent, SessionSnapshotRewindEvent, SessionUsageInfoEvent, + SessionCompactionStartEvent, SessionCompactionCompleteEvent, SessionShutdownEvent, SessionContextChangedEvent, // Assistant events AssistantTurnStartEvent, AssistantIntentEvent, AssistantReasoningEvent, AssistantReasoningDeltaEvent, AssistantMessageEvent, AssistantMessageDeltaEvent, AssistantTurnEndEvent, AssistantUsageEvent, AbortEvent, diff --git a/src/main/java/com/github/copilot/sdk/events/SessionEventParser.java b/src/main/java/com/github/copilot/sdk/events/SessionEventParser.java index 4cc1763cf..146e3ee08 100644 --- a/src/main/java/com/github/copilot/sdk/events/SessionEventParser.java +++ b/src/main/java/com/github/copilot/sdk/events/SessionEventParser.java @@ -55,6 +55,9 @@ public class SessionEventParser { TYPE_MAP.put("session.idle", SessionIdleEvent.class); TYPE_MAP.put("session.info", SessionInfoEvent.class); TYPE_MAP.put("session.model_change", SessionModelChangeEvent.class); + TYPE_MAP.put("session.mode_changed", SessionModeChangedEvent.class); + TYPE_MAP.put("session.plan_changed", SessionPlanChangedEvent.class); + TYPE_MAP.put("session.workspace_file_changed", SessionWorkspaceFileChangedEvent.class); TYPE_MAP.put("session.handoff", SessionHandoffEvent.class); TYPE_MAP.put("session.truncation", SessionTruncationEvent.class); TYPE_MAP.put("session.snapshot_rewind", SessionSnapshotRewindEvent.class); diff --git a/src/main/java/com/github/copilot/sdk/events/SessionModeChangedEvent.java b/src/main/java/com/github/copilot/sdk/events/SessionModeChangedEvent.java new file mode 100644 index 000000000..3c5b5d661 --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/events/SessionModeChangedEvent.java @@ -0,0 +1,38 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk.events; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Event: session.mode_changed + * + * @since 1.0.10 + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class SessionModeChangedEvent extends AbstractSessionEvent { + + @JsonProperty("data") + private SessionModeChangedData data; + + @Override + public String getType() { + return "session.mode_changed"; + } + + public SessionModeChangedData getData() { + return data; + } + + public void setData(SessionModeChangedData data) { + this.data = data; + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record SessionModeChangedData(@JsonProperty("previousMode") String previousMode, + @JsonProperty("newMode") String newMode) { + } +} diff --git a/src/main/java/com/github/copilot/sdk/events/SessionPlanChangedEvent.java b/src/main/java/com/github/copilot/sdk/events/SessionPlanChangedEvent.java new file mode 100644 index 000000000..2010cf146 --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/events/SessionPlanChangedEvent.java @@ -0,0 +1,37 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk.events; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Event: session.plan_changed + * + * @since 1.0.10 + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class SessionPlanChangedEvent extends AbstractSessionEvent { + + @JsonProperty("data") + private SessionPlanChangedData data; + + @Override + public String getType() { + return "session.plan_changed"; + } + + public SessionPlanChangedData getData() { + return data; + } + + public void setData(SessionPlanChangedData data) { + this.data = data; + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record SessionPlanChangedData(@JsonProperty("operation") String operation) { + } +} diff --git a/src/main/java/com/github/copilot/sdk/events/SessionWorkspaceFileChangedEvent.java b/src/main/java/com/github/copilot/sdk/events/SessionWorkspaceFileChangedEvent.java new file mode 100644 index 000000000..0ed30aeb8 --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/events/SessionWorkspaceFileChangedEvent.java @@ -0,0 +1,38 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk.events; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Event: session.workspace_file_changed + * + * @since 1.0.10 + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class SessionWorkspaceFileChangedEvent extends AbstractSessionEvent { + + @JsonProperty("data") + private SessionWorkspaceFileChangedData data; + + @Override + public String getType() { + return "session.workspace_file_changed"; + } + + public SessionWorkspaceFileChangedData getData() { + return data; + } + + public void setData(SessionWorkspaceFileChangedData data) { + this.data = data; + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record SessionWorkspaceFileChangedData(@JsonProperty("path") String path, + @JsonProperty("operation") String operation) { + } +} diff --git a/src/main/java/com/github/copilot/sdk/json/CreateSessionRequest.java b/src/main/java/com/github/copilot/sdk/json/CreateSessionRequest.java index 1e1ee621a..522aabd32 100644 --- a/src/main/java/com/github/copilot/sdk/json/CreateSessionRequest.java +++ b/src/main/java/com/github/copilot/sdk/json/CreateSessionRequest.java @@ -67,6 +67,9 @@ public final class CreateSessionRequest { @JsonProperty("mcpServers") private Map mcpServers; + @JsonProperty("envValueMode") + private String envValueMode; + @JsonProperty("customAgents") private List customAgents; @@ -224,6 +227,16 @@ public void setMcpServers(Map mcpServers) { this.mcpServers = mcpServers; } + /** Gets MCP environment variable value mode. @return the mode */ + public String getEnvValueMode() { + return envValueMode; + } + + /** Sets MCP environment variable value mode. @param envValueMode the mode */ + public void setEnvValueMode(String envValueMode) { + this.envValueMode = envValueMode; + } + /** Gets custom agents. @return the agents */ public List getCustomAgents() { return customAgents == null ? null : Collections.unmodifiableList(customAgents); diff --git a/src/main/java/com/github/copilot/sdk/json/ResumeSessionRequest.java b/src/main/java/com/github/copilot/sdk/json/ResumeSessionRequest.java index 19c51aef0..5d5228a16 100644 --- a/src/main/java/com/github/copilot/sdk/json/ResumeSessionRequest.java +++ b/src/main/java/com/github/copilot/sdk/json/ResumeSessionRequest.java @@ -74,6 +74,9 @@ public final class ResumeSessionRequest { @JsonProperty("mcpServers") private Map mcpServers; + @JsonProperty("envValueMode") + private String envValueMode; + @JsonProperty("customAgents") private List customAgents; @@ -251,6 +254,16 @@ public void setMcpServers(Map mcpServers) { this.mcpServers = mcpServers; } + /** Gets MCP environment variable value mode. @return the mode */ + public String getEnvValueMode() { + return envValueMode; + } + + /** Sets MCP environment variable value mode. @param envValueMode the mode */ + public void setEnvValueMode(String envValueMode) { + this.envValueMode = envValueMode; + } + /** Gets custom agents. @return the agents */ public List getCustomAgents() { return customAgents == null ? null : Collections.unmodifiableList(customAgents); diff --git a/src/site/markdown/index.md b/src/site/markdown/index.md index 736405ee4..9ea166715 100644 --- a/src/site/markdown/index.md +++ b/src/site/markdown/index.md @@ -9,7 +9,7 @@ Welcome to the documentation for the **Copilot SDK for Java** — a Java SDK for ### Requirements - Java 17 or later -- GitHub Copilot CLI 0.0.409 or later installed and in PATH (or provide custom `cliPath`) +- GitHub Copilot CLI 0.0.411-1 or later installed and in PATH (or provide custom `cliPath`) ### Installation diff --git a/src/test/java/com/github/copilot/sdk/CopilotClientTest.java b/src/test/java/com/github/copilot/sdk/CopilotClientTest.java index 1dde9fd01..514e68998 100644 --- a/src/test/java/com/github/copilot/sdk/CopilotClientTest.java +++ b/src/test/java/com/github/copilot/sdk/CopilotClientTest.java @@ -149,6 +149,26 @@ void testStartAndConnectUsingStdio() throws Exception { } } + @Test + void testShouldReportErrorWithStderrWhenCliFailsToStart() throws Exception { + if (cliPath == null) { + System.out.println("Skipping test: CLI not found"); + return; + } + + var options = new CopilotClientOptions().setCliPath(cliPath) + .setCliArgs(new String[]{"--nonexistent-flag-for-testing"}).setUseStdio(true); + + try (var client = new CopilotClient(options)) { + Exception ex = assertThrows(Exception.class, () -> client.start().get()); + Throwable root = ex instanceof ExecutionException && ex.getCause() != null ? ex.getCause() : ex; + String message = root.getMessage(); + assertNotNull(message); + assertTrue(message.toLowerCase().contains("stderr") || message.toLowerCase().contains("unexpectedly"), + "Error should include stderr or unexpected exit details: " + message); + } + } + @Test void testStartAndConnectUsingTcp() throws Exception { if (cliPath == null) { diff --git a/src/test/java/com/github/copilot/sdk/SessionEventParserTest.java b/src/test/java/com/github/copilot/sdk/SessionEventParserTest.java index a102dfe27..2186929fb 100644 --- a/src/test/java/com/github/copilot/sdk/SessionEventParserTest.java +++ b/src/test/java/com/github/copilot/sdk/SessionEventParserTest.java @@ -158,6 +158,59 @@ void testParseSessionModelChangeEvent() throws Exception { assertEquals("session.model_change", event.getType()); } + @Test + void testParseSessionModeChangedEvent() throws Exception { + String json = """ + { + "type": "session.mode_changed", + "data": { + "previousMode": "interactive", + "newMode": "plan" + } + } + """; + + AbstractSessionEvent event = parseJson(json); + assertNotNull(event); + assertInstanceOf(SessionModeChangedEvent.class, event); + assertEquals("session.mode_changed", event.getType()); + } + + @Test + void testParseSessionPlanChangedEvent() throws Exception { + String json = """ + { + "type": "session.plan_changed", + "data": { + "operation": "update" + } + } + """; + + AbstractSessionEvent event = parseJson(json); + assertNotNull(event); + assertInstanceOf(SessionPlanChangedEvent.class, event); + assertEquals("session.plan_changed", event.getType()); + } + + @Test + void testParseSessionWorkspaceFileChangedEvent() throws Exception { + String json = """ + { + "type": "session.workspace_file_changed", + "data": { + "path": "plan.md", + "operation": "create" + } + } + """; + + AbstractSessionEvent event = parseJson(json); + assertNotNull(event); + assertInstanceOf(SessionWorkspaceFileChangedEvent.class, event); + assertEquals("session.workspace_file_changed", event.getType()); + } + @Test void testParseSessionHandoffEvent() throws Exception { String json = """ @@ -861,7 +914,8 @@ void testParseEmptyJson() throws Exception { @Test void testParseAllEventTypes() throws Exception { String[] types = {"session.start", "session.resume", "session.error", "session.idle", "session.info", - "session.model_change", "session.handoff", "session.truncation", "session.snapshot_rewind", + "session.model_change", "session.mode_changed", "session.plan_changed", + "session.workspace_file_changed", "session.handoff", "session.truncation", "session.snapshot_rewind", "session.usage_info", "session.compaction_start", "session.compaction_complete", "user.message", "pending_messages.modified", "assistant.turn_start", "assistant.intent", "assistant.reasoning", "assistant.reasoning_delta", "assistant.message", "assistant.message_delta", "assistant.turn_end", diff --git a/src/test/java/com/github/copilot/sdk/SessionRequestBuilderTest.java b/src/test/java/com/github/copilot/sdk/SessionRequestBuilderTest.java index f6de79a0f..b1737de55 100644 --- a/src/test/java/com/github/copilot/sdk/SessionRequestBuilderTest.java +++ b/src/test/java/com/github/copilot/sdk/SessionRequestBuilderTest.java @@ -59,6 +59,12 @@ void testBuildCreateRequestHooksWithHandler() { assertTrue(request.getHooks(), "Should be true when hooks have handlers"); } + @Test + void testBuildCreateRequestSetsEnvValueModeToDirect() { + CreateSessionRequest request = SessionRequestBuilder.buildCreateRequest(new SessionConfig()); + assertEquals("direct", request.getEnvValueMode()); + } + // ========================================================================= // buildResumeRequest // ========================================================================= @@ -130,6 +136,12 @@ void testBuildResumeRequestStreaming() { assertTrue(request.getStreaming()); } + @Test + void testBuildResumeRequestSetsEnvValueModeToDirect() { + ResumeSessionRequest request = SessionRequestBuilder.buildResumeRequest("sid-8", new ResumeSessionConfig()); + assertEquals("direct", request.getEnvValueMode()); + } + // ========================================================================= // configureSession (ResumeSessionConfig overload) // =========================================================================