diff --git a/.gitignore b/.gitignore index 8740b8b6c..8d7f42429 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,5 @@ blog-copilotsdk/ .claude/worktrees smoke-test *job-logs.txt +temporary-prompts/ +changebundle.txt* diff --git a/.lastmerge b/.lastmerge index c5649a512..a0cf76b72 100644 --- a/.lastmerge +++ b/.lastmerge @@ -1 +1 @@ -062b61c8aa63b9b5d45fa1d7b01723e6660ffa83 +40887393a9e687dacc141a645799441b0313ff15 diff --git a/CHANGELOG.md b/CHANGELOG.md index 3db88df0f..7170f8e73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,36 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] -> **Upstream sync:** [`github/copilot-sdk@062b61c`](https://github.com/github/copilot-sdk/commit/062b61c8aa63b9b5d45fa1d7b01723e6660ffa83) +> **Upstream sync:** [`github/copilot-sdk@4088739`](https://github.com/github/copilot-sdk/commit/40887393a9e687dacc141a645799441b0313ff15) + +### Added + +- `UnknownSessionEvent` — forward-compatible placeholder for event types not yet known to the SDK; unknown events are now dispatched to handlers instead of being silently dropped (upstream: [`d82fd62`](https://github.com/github/copilot-sdk/commit/d82fd62)) +- `PermissionRequestResultKind.NO_RESULT` — new constant that signals the handler intentionally abstains from answering a permission request, leaving it unanswered for another client (upstream: [`df59a0e`](https://github.com/github/copilot-sdk/commit/df59a0e)) +- `ToolDefinition.skipPermission` field and `ToolDefinition.createSkipPermission()` factory — marks a tool to skip the permission prompt (upstream: [`10c4d02`](https://github.com/github/copilot-sdk/commit/10c4d02)) +- `SystemMessageMode.CUSTOMIZE` — new enum value for fine-grained system prompt customization (upstream: [`005b780`](https://github.com/github/copilot-sdk/commit/005b780)) +- `SectionOverrideAction` enum — specifies the operation on a system prompt section (replace, remove, append, prepend, transform) (upstream: [`005b780`](https://github.com/github/copilot-sdk/commit/005b780)) +- `SectionOverride` class — describes how one section of the system prompt should be modified, with optional transform callback (upstream: [`005b780`](https://github.com/github/copilot-sdk/commit/005b780)) +- `SystemPromptSections` constants — well-known section identifier strings for use with CUSTOMIZE mode (upstream: [`005b780`](https://github.com/github/copilot-sdk/commit/005b780)) +- `SystemMessageConfig.setSections(Map)` — section-level overrides for CUSTOMIZE mode (upstream: [`005b780`](https://github.com/github/copilot-sdk/commit/005b780)) +- `systemMessage.transform` RPC handler — the SDK now registers a handler that invokes transform callbacks registered in the session config (upstream: [`005b780`](https://github.com/github/copilot-sdk/commit/005b780)) +- `CopilotSession.setModel(String, String)` — new overload that accepts an optional reasoning effort level (upstream: [`ea90f07`](https://github.com/github/copilot-sdk/commit/ea90f07)) +- `CopilotSession.log(String, String, Boolean, String)` — new overload with an optional `url` parameter (minor addition) +- `BlobAttachment` class — inline base64-encoded binary attachment for messages (e.g., images) (upstream: [`698b259`](https://github.com/github/copilot-sdk/commit/698b259)) +- `MessageAttachment` sealed interface — type-safe base for all attachment types (`Attachment`, `BlobAttachment`), with Jackson polymorphic serialization support +- `TelemetryConfig` class — OpenTelemetry configuration for the CLI server; set on `CopilotClientOptions.setTelemetry()` (upstream: [`f2d21a0`](https://github.com/github/copilot-sdk/commit/f2d21a0)) +- `CopilotClientOptions.setTelemetry(TelemetryConfig)` — enables OpenTelemetry instrumentation in the CLI server (upstream: [`f2d21a0`](https://github.com/github/copilot-sdk/commit/f2d21a0)) + +### Changed + +- `Attachment` record now implements `MessageAttachment` sealed interface +- `BlobAttachment` class now implements `MessageAttachment` sealed interface and is `final` +- `MessageOptions.setAttachments(List)` — parameter type changed from `List` to `List` to support both `Attachment` and `BlobAttachment` in the same list with full compile-time safety +- `SendMessageRequest.setAttachments(List)` — matching change for the internal request type + +### Deprecated + +- `CopilotClientOptions.setAutoRestart(boolean)` — this option has no effect and will be removed in a future release ## [0.1.32-java.0] - 2026-03-17 diff --git a/src/main/java/com/github/copilot/sdk/CliServerManager.java b/src/main/java/com/github/copilot/sdk/CliServerManager.java index b2a798ada..217699986 100644 --- a/src/main/java/com/github/copilot/sdk/CliServerManager.java +++ b/src/main/java/com/github/copilot/sdk/CliServerManager.java @@ -110,6 +110,28 @@ ProcessInfo startCliServer() throws IOException, InterruptedException { pb.environment().put("COPILOT_SDK_AUTH_TOKEN", options.getGitHubToken()); } + // Set telemetry environment variables if configured + if (options.getTelemetry() != null) { + var telemetry = options.getTelemetry(); + pb.environment().put("COPILOT_OTEL_ENABLED", "true"); + if (telemetry.getOtlpEndpoint() != null) { + pb.environment().put("OTEL_EXPORTER_OTLP_ENDPOINT", telemetry.getOtlpEndpoint()); + } + if (telemetry.getFilePath() != null) { + pb.environment().put("COPILOT_OTEL_FILE_EXPORTER_PATH", telemetry.getFilePath()); + } + if (telemetry.getExporterType() != null) { + pb.environment().put("COPILOT_OTEL_EXPORTER_TYPE", telemetry.getExporterType()); + } + if (telemetry.getSourceName() != null) { + pb.environment().put("COPILOT_OTEL_SOURCE_NAME", telemetry.getSourceName()); + } + if (telemetry.getCaptureContent() != null) { + pb.environment().put("OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT", + telemetry.getCaptureContent() ? "true" : "false"); + } + } + Process process = pb.start(); // Forward stderr to logger in background diff --git a/src/main/java/com/github/copilot/sdk/CopilotClient.java b/src/main/java/com/github/copilot/sdk/CopilotClient.java index 39034c910..707469428 100644 --- a/src/main/java/com/github/copilot/sdk/CopilotClient.java +++ b/src/main/java/com/github/copilot/sdk/CopilotClient.java @@ -332,7 +332,19 @@ public CompletableFuture createSession(SessionConfig config) { SessionRequestBuilder.configureSession(session, config); sessions.put(sessionId, session); + // Extract transform callbacks from the system message config. + // Callbacks are registered with the session; a wire-safe copy of the + // system message (with transform sections replaced by action="transform") + // is used in the RPC request. + var extracted = SessionRequestBuilder.extractTransformCallbacks(config.getSystemMessage()); + if (extracted.transformCallbacks() != null) { + session.registerTransformCallbacks(extracted.transformCallbacks()); + } + var request = SessionRequestBuilder.buildCreateRequest(config, sessionId); + if (extracted.wireSystemMessage() != config.getSystemMessage()) { + request.setSystemMessage(extracted.wireSystemMessage()); + } return connection.rpc.invoke("session.create", request, CreateSessionResponse.class).thenApply(response -> { session.setWorkspacePath(response.workspacePath()); @@ -390,7 +402,16 @@ public CompletableFuture resumeSession(String sessionId, ResumeS SessionRequestBuilder.configureSession(session, config); sessions.put(sessionId, session); + // Extract transform callbacks from the system message config. + var extracted = SessionRequestBuilder.extractTransformCallbacks(config.getSystemMessage()); + if (extracted.transformCallbacks() != null) { + session.registerTransformCallbacks(extracted.transformCallbacks()); + } + var request = SessionRequestBuilder.buildResumeRequest(sessionId, config); + if (extracted.wireSystemMessage() != config.getSystemMessage()) { + request.setSystemMessage(extracted.wireSystemMessage()); + } return connection.rpc.invoke("session.resume", request, ResumeSessionResponse.class).thenApply(response -> { session.setWorkspacePath(response.workspacePath()); diff --git a/src/main/java/com/github/copilot/sdk/CopilotSession.java b/src/main/java/com/github/copilot/sdk/CopilotSession.java index 452e82671..8c68e1e3e 100644 --- a/src/main/java/com/github/copilot/sdk/CopilotSession.java +++ b/src/main/java/com/github/copilot/sdk/CopilotSession.java @@ -120,6 +120,7 @@ public final class CopilotSession implements AutoCloseable { private final AtomicReference hooksHandler = new AtomicReference<>(); private volatile EventErrorHandler eventErrorHandler; private volatile EventErrorPolicy eventErrorPolicy = EventErrorPolicy.PROPAGATE_AND_LOG_ERRORS; + private volatile Map>> transformCallbacks; /** Tracks whether this session instance has been terminated via close(). */ private volatile boolean isTerminated = false; @@ -709,6 +710,12 @@ private void executePermissionAndRespondAsync(String requestId, PermissionReques invocation.setSessionId(sessionId); handler.handle(permissionRequest, invocation).thenAccept(result -> { try { + PermissionRequestResultKind kind = new PermissionRequestResultKind(result.getKind()); + if (PermissionRequestResultKind.NO_RESULT.equals(kind)) { + // Handler explicitly abstains — leave the request unanswered + // so another client can handle it. + return; + } rpc.invoke("session.permissions.handlePendingPermissionRequest", Map.of("sessionId", sessionId, "requestId", requestId, "result", result), Object.class); } catch (Exception e) { @@ -867,6 +874,67 @@ void registerHooks(SessionHooks hooks) { hooksHandler.set(hooks); } + /** + * Registers transform callbacks for system message sections. + *

+ * Called internally when creating or resuming a session with + * {@link com.github.copilot.sdk.SystemMessageMode#CUSTOMIZE} and transform + * callbacks. + * + * @param callbacks + * the transform callbacks keyed by section identifier; {@code null} + * clears any previously registered callbacks + */ + void registerTransformCallbacks( + Map>> callbacks) { + this.transformCallbacks = callbacks; + } + + /** + * Handles a {@code systemMessage.transform} RPC call from the Copilot CLI. + *

+ * The CLI sends section content; the SDK invokes the registered transform + * callbacks and returns the transformed sections. + * + * @param sections + * JSON node containing sections keyed by section identifier + * @return a future resolving with a map of transformed sections + */ + CompletableFuture> handleSystemMessageTransform(JsonNode sections) { + var callbacks = this.transformCallbacks; + var result = new java.util.LinkedHashMap(); + var futures = new ArrayList>(); + + if (sections != null && sections.isObject()) { + sections.fields().forEachRemaining(entry -> { + String sectionId = entry.getKey(); + String content = entry.getValue().has("content") ? entry.getValue().get("content").asText("") : ""; + + java.util.function.Function> cb = callbacks != null + ? callbacks.get(sectionId) + : null; + + if (cb != null) { + CompletableFuture f = cb.apply(content).exceptionally(ex -> content) + .thenAccept(transformed -> { + synchronized (result) { + result.put(sectionId, Map.of("content", transformed != null ? transformed : "")); + } + }); + futures.add(f); + } else { + result.put(sectionId, Map.of("content", content)); + } + }); + } + + return CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).thenApply(v -> { + Map response = new java.util.LinkedHashMap<>(); + response.put("sections", result); + return response; + }); + } + /** * Handles a hook invocation from the Copilot CLI. *

@@ -982,6 +1050,38 @@ public CompletableFuture abort() { return rpc.invoke("session.abort", Map.of("sessionId", sessionId), Void.class); } + /** + * Changes the model for this session with an optional reasoning effort level. + *

+ * The new model takes effect for the next message. Conversation history is + * preserved. + * + *

{@code
+     * session.setModel("gpt-4.1").get();
+     * session.setModel("claude-sonnet-4.6", "high").get();
+     * }
+ * + * @param model + * the model ID to switch to (e.g., {@code "gpt-4.1"}) + * @param reasoningEffort + * reasoning effort level (e.g., {@code "low"}, {@code "medium"}, + * {@code "high"}, {@code "xhigh"}); {@code null} to use default + * @return a future that completes when the model switch is acknowledged + * @throws IllegalStateException + * if this session has been terminated + * @since 1.2.0 + */ + public CompletableFuture setModel(String model, String reasoningEffort) { + ensureNotTerminated(); + var params = new java.util.HashMap(); + params.put("sessionId", sessionId); + params.put("modelId", model); + if (reasoningEffort != null) { + params.put("reasoningEffort", reasoningEffort); + } + return rpc.invoke("session.model.switchTo", params, Void.class); + } + /** * Changes the model for this session. *

@@ -1000,8 +1100,7 @@ public CompletableFuture abort() { * @since 1.0.11 */ public CompletableFuture setModel(String model) { - ensureNotTerminated(); - return rpc.invoke("session.model.switchTo", Map.of("sessionId", sessionId, "modelId", model), Void.class); + return setModel(model, null); } /** @@ -1017,6 +1116,7 @@ public CompletableFuture setModel(String model) { * session.log("Build completed successfully").get(); * session.log("Disk space low", "warning", null).get(); * session.log("Temporary status", null, true).get(); + * session.log("Details at link", "info", null, "https://example.com").get(); * } * * @param message @@ -1028,11 +1128,14 @@ public CompletableFuture setModel(String model) { * @param ephemeral * when {@code true}, the message is transient and not persisted to * disk; {@code null} uses default behavior + * @param url + * optional URL to associate with the log entry; {@code null} to omit * @return a future that completes when the message is logged * @throws IllegalStateException * if this session has been terminated + * @since 1.2.0 */ - public CompletableFuture log(String message, String level, Boolean ephemeral) { + public CompletableFuture log(String message, String level, Boolean ephemeral, String url) { ensureNotTerminated(); var params = new java.util.HashMap(); params.put("sessionId", sessionId); @@ -1043,9 +1146,44 @@ public CompletableFuture log(String message, String level, Boolean ephemer if (ephemeral != null) { params.put("ephemeral", ephemeral); } + if (url != null) { + params.put("url", url); + } return rpc.invoke("session.log", params, Void.class); } + /** + * Logs a message to the session timeline. + *

+ * The message appears in the session event stream and is visible to SDK + * consumers. Non-ephemeral messages are also persisted to the session event log + * on disk. + * + *

Example Usage

+ * + *
{@code
+     * session.log("Build completed successfully").get();
+     * session.log("Disk space low", "warning", null).get();
+     * session.log("Temporary status", null, true).get();
+     * }
+ * + * @param message + * the message to log + * @param level + * the log severity level ({@code "info"}, {@code "warning"}, + * {@code "error"}), or {@code null} to use the default + * ({@code "info"}) + * @param ephemeral + * when {@code true}, the message is transient and not persisted to + * disk; {@code null} uses default behavior + * @return a future that completes when the message is logged + * @throws IllegalStateException + * if this session has been terminated + */ + public CompletableFuture log(String message, String level, Boolean ephemeral) { + return log(message, level, ephemeral, null); + } + /** * Logs an informational message to the session timeline. * diff --git a/src/main/java/com/github/copilot/sdk/ExtractedTransforms.java b/src/main/java/com/github/copilot/sdk/ExtractedTransforms.java new file mode 100644 index 000000000..717d873d3 --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/ExtractedTransforms.java @@ -0,0 +1,30 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk; + +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.function.Function; + +import com.github.copilot.sdk.json.SystemMessageConfig; + +/** + * Result of extracting transform callbacks from a {@link SystemMessageConfig}. + *

+ * Holds a wire-safe copy of the system message config (with transform callbacks + * replaced by {@code action="transform"}) alongside the extracted callbacks + * that must be registered with the session. + * + * @param wireSystemMessage + * the system message config safe for JSON serialization; may be + * {@code null} when the input config was {@code null} + * @param transformCallbacks + * transform callbacks keyed by section identifier; {@code null} when + * no transforms were present + * @see SessionRequestBuilder#extractTransformCallbacks(SystemMessageConfig) + */ +record ExtractedTransforms(SystemMessageConfig wireSystemMessage, + Map>> transformCallbacks) { +} diff --git a/src/main/java/com/github/copilot/sdk/RpcHandlerDispatcher.java b/src/main/java/com/github/copilot/sdk/RpcHandlerDispatcher.java index 2f041c7e4..101f68528 100644 --- a/src/main/java/com/github/copilot/sdk/RpcHandlerDispatcher.java +++ b/src/main/java/com/github/copilot/sdk/RpcHandlerDispatcher.java @@ -74,6 +74,8 @@ void registerHandlers(JsonRpcClient rpc) { rpc.registerMethodHandler("userInput.request", (requestId, params) -> handleUserInputRequest(rpc, requestId, params)); rpc.registerMethodHandler("hooks.invoke", (requestId, params) -> handleHooksInvoke(rpc, requestId, params)); + rpc.registerMethodHandler("systemMessage.transform", + (requestId, params) -> handleSystemMessageTransform(rpc, requestId, params)); } private void handleSessionEvent(JsonNode params) { @@ -191,6 +193,13 @@ private void handlePermissionRequest(JsonRpcClient rpc, String requestId, JsonNo session.handlePermissionRequest(permissionRequest).thenAccept(result -> { try { + if (PermissionRequestResultKind.NO_RESULT.getValue().equalsIgnoreCase(result.getKind())) { + // Protocol v2 does not support NO_RESULT — the server + // expects exactly one response per request, so abstaining + // would leave it hanging. + throw new IllegalStateException( + "Permission handlers cannot return 'no-result' when connected to a protocol v2 server."); + } rpc.sendResponse(Long.parseLong(requestId), Map.of("result", result)); } catch (IOException e) { LOG.log(Level.SEVERE, "Error sending permission result", e); @@ -310,4 +319,44 @@ interface LifecycleEventDispatcher { void dispatch(SessionLifecycleEvent event); } + + private void handleSystemMessageTransform(JsonRpcClient rpc, String requestId, JsonNode params) { + CompletableFuture.runAsync(() -> { + try { + final long requestIdLong; + try { + requestIdLong = Long.parseLong(requestId); + } catch (NumberFormatException nfe) { + LOG.log(Level.SEVERE, "Invalid requestId for systemMessage.transform: " + requestId, nfe); + return; + } + + String sessionId = params.has("sessionId") ? params.get("sessionId").asText() : null; + JsonNode sections = params.get("sections"); + + CopilotSession session = sessionId != null ? sessions.get(sessionId) : null; + if (session == null) { + rpc.sendErrorResponse(requestIdLong, -32602, "Unknown session " + sessionId); + return; + } + + session.handleSystemMessageTransform(sections).thenAccept(result -> { + try { + rpc.sendResponse(requestIdLong, result); + } catch (IOException e) { + LOG.log(Level.SEVERE, "Error sending systemMessage.transform response", e); + } + }).exceptionally(ex -> { + try { + rpc.sendErrorResponse(requestIdLong, -32603, "Transform error: " + ex.getMessage()); + } catch (IOException e) { + LOG.log(Level.SEVERE, "Error sending transform error response", e); + } + return null; + }); + } catch (Exception e) { + LOG.log(Level.SEVERE, "Error handling systemMessage.transform", e); + } + }); + } } diff --git a/src/main/java/com/github/copilot/sdk/SessionRequestBuilder.java b/src/main/java/com/github/copilot/sdk/SessionRequestBuilder.java index 7ea0be880..6f1cd573c 100644 --- a/src/main/java/com/github/copilot/sdk/SessionRequestBuilder.java +++ b/src/main/java/com/github/copilot/sdk/SessionRequestBuilder.java @@ -4,10 +4,18 @@ package com.github.copilot.sdk; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.function.Function; + import com.github.copilot.sdk.json.CreateSessionRequest; import com.github.copilot.sdk.json.ResumeSessionConfig; import com.github.copilot.sdk.json.ResumeSessionRequest; +import com.github.copilot.sdk.json.SectionOverride; +import com.github.copilot.sdk.json.SectionOverrideAction; import com.github.copilot.sdk.json.SessionConfig; +import com.github.copilot.sdk.json.SystemMessageConfig; /** * Builds JSON-RPC request objects from session configuration. @@ -22,6 +30,58 @@ private SessionRequestBuilder() { // Utility class } + /** + * Extracts transform callbacks from a {@link SystemMessageConfig} and returns a + * wire-safe copy of the config alongside the extracted callbacks. + *

+ * When the system message mode is {@link SystemMessageMode#CUSTOMIZE} and some + * sections have {@link SectionOverride#getTransform() transform} callbacks set, + * this method: + *

    + *
  1. Removes the callbacks from the wire config (they must not be + * serialized).
  2. + *
  3. Replaces each transform section with + * {@link SectionOverrideAction#TRANSFORM} in the wire config.
  4. + *
  5. Returns the callbacks so they can be registered with the session.
  6. + *
+ * + * @param systemMessage + * the system message config, may be {@code null} + * @return an {@link ExtractedTransforms} containing the wire-safe config and + * any extracted callbacks + */ + static ExtractedTransforms extractTransformCallbacks(SystemMessageConfig systemMessage) { + if (systemMessage == null || systemMessage.getMode() != SystemMessageMode.CUSTOMIZE + || systemMessage.getSections() == null) { + return new ExtractedTransforms(systemMessage, null); + } + + Map>> callbacks = new HashMap<>(); + Map wireSections = new HashMap<>(); + + for (Map.Entry entry : systemMessage.getSections().entrySet()) { + String sectionId = entry.getKey(); + SectionOverride override = entry.getValue(); + + if (override.getTransform() != null) { + callbacks.put(sectionId, override.getTransform()); + wireSections.put(sectionId, new SectionOverride().setAction(SectionOverrideAction.TRANSFORM)); + } else { + wireSections.put(sectionId, override); + } + } + + if (callbacks.isEmpty()) { + return new ExtractedTransforms(systemMessage, null); + } + + // Build a wire-safe copy of the system message with callbacks removed + var wireConfig = new SystemMessageConfig().setMode(systemMessage.getMode()) + .setContent(systemMessage.getContent()).setSections(wireSections); + + return new ExtractedTransforms(wireConfig, callbacks); + } + /** * Builds a CreateSessionRequest from the given configuration. * diff --git a/src/main/java/com/github/copilot/sdk/SystemMessageMode.java b/src/main/java/com/github/copilot/sdk/SystemMessageMode.java index 29533b488..67fb5ea5e 100644 --- a/src/main/java/com/github/copilot/sdk/SystemMessageMode.java +++ b/src/main/java/com/github/copilot/sdk/SystemMessageMode.java @@ -31,7 +31,20 @@ public enum SystemMessageMode { * Warning: This mode removes all default guardrails and * behaviors. Use with caution. */ - REPLACE("replace"); + REPLACE("replace"), + + /** + * Override individual sections of the system prompt. + *

+ * Use this mode with + * {@link com.github.copilot.sdk.json.SystemMessageConfig#setSections} to + * selectively replace, remove, append, prepend, or transform individual + * sections of the default system prompt. An optional {@code content} string is + * appended after all sections when provided. + * + * @since 1.2.0 + */ + CUSTOMIZE("customize"); private final String value; 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 5127f6eee..4626bb4f8 100644 --- a/src/main/java/com/github/copilot/sdk/events/AbstractSessionEvent.java +++ b/src/main/java/com/github/copilot/sdk/events/AbstractSessionEvent.java @@ -73,7 +73,9 @@ public abstract sealed class AbstractSessionEvent permits SkillInvokedEvent, // Other events SubagentStartedEvent, SubagentCompletedEvent, SubagentFailedEvent, SubagentSelectedEvent, - SubagentDeselectedEvent, HookStartEvent, HookEndEvent, SystemMessageEvent { + SubagentDeselectedEvent, HookStartEvent, HookEndEvent, SystemMessageEvent, + // Forward-compatibility placeholder for event types not yet known to this SDK + UnknownSessionEvent { @JsonProperty("id") private UUID id; 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 75971b29e..308317e6b 100644 --- a/src/main/java/com/github/copilot/sdk/events/SessionEventParser.java +++ b/src/main/java/com/github/copilot/sdk/events/SessionEventParser.java @@ -122,8 +122,14 @@ public static AbstractSessionEvent parse(JsonNode node) { Class eventClass = TYPE_MAP.get(type); if (eventClass == null) { - LOG.fine("Unknown event type: " + type); - return null; + LOG.fine("Unknown event type: " + type + " — returning UnknownSessionEvent for forward compatibility"); + UnknownSessionEvent base = MAPPER.treeToValue(node, UnknownSessionEvent.class); + UnknownSessionEvent result = new UnknownSessionEvent(type); + result.setId(base.getId()); + result.setTimestamp(base.getTimestamp()); + result.setParentId(base.getParentId()); + result.setEphemeral(base.getEphemeral()); + return result; } return MAPPER.treeToValue(node, eventClass); diff --git a/src/main/java/com/github/copilot/sdk/events/UnknownSessionEvent.java b/src/main/java/com/github/copilot/sdk/events/UnknownSessionEvent.java new file mode 100644 index 000000000..cbc19b317 --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/events/UnknownSessionEvent.java @@ -0,0 +1,74 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk.events; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +/** + * Represents an unrecognized session event type received from the CLI. + *

+ * When the CLI sends an event with a type that this SDK does not recognize (for + * example, an event type introduced in a newer CLI version), the SDK wraps it + * in an {@code UnknownSessionEvent} rather than dropping it. This ensures + * forward compatibility: event handlers can simply ignore unknown event types + * without the SDK crashing. + * + *

Example

+ * + *
{@code
+ * session.on(event -> {
+ * 	if (event instanceof UnknownSessionEvent unknown) {
+ * 		// Ignore events from newer CLI versions
+ * 	}
+ * });
+ * }
+ * + * @see AbstractSessionEvent + * @since 1.2.0 + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class UnknownSessionEvent extends AbstractSessionEvent { + + private final String originalType; + + /** + * Creates an unknown session event with the given original type string. + * + * @param originalType + * the event type string received from the CLI; may be {@code null} + */ + public UnknownSessionEvent(String originalType) { + this.originalType = originalType != null ? originalType : "unknown"; + } + + /** + * No-arg constructor for internal use by the parser. + *

+ * Creates an unknown event with {@code "unknown"} as the original type. Callers + * that need the original type should use {@link #UnknownSessionEvent(String)}. + */ + UnknownSessionEvent() { + this("unknown"); + } + + /** + * Returns {@code "unknown"} as the canonical type for all unrecognized events. + * + * @return always {@code "unknown"} + */ + @Override + public String getType() { + return "unknown"; + } + + /** + * Returns the original event type string as received from the CLI. + * + * @return the original type, never {@code null} + */ + public String getOriginalType() { + return originalType; + } +} diff --git a/src/main/java/com/github/copilot/sdk/json/Attachment.java b/src/main/java/com/github/copilot/sdk/json/Attachment.java index bf6610804..04011d6f1 100644 --- a/src/main/java/com/github/copilot/sdk/json/Attachment.java +++ b/src/main/java/com/github/copilot/sdk/json/Attachment.java @@ -30,5 +30,10 @@ */ @JsonInclude(JsonInclude.Include.NON_NULL) public record Attachment(@JsonProperty("type") String type, @JsonProperty("path") String path, - @JsonProperty("displayName") String displayName) { + @JsonProperty("displayName") String displayName) implements MessageAttachment { + + @Override + public String getType() { + return type; + } } diff --git a/src/main/java/com/github/copilot/sdk/json/BlobAttachment.java b/src/main/java/com/github/copilot/sdk/json/BlobAttachment.java new file mode 100644 index 000000000..d58a1e15e --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/json/BlobAttachment.java @@ -0,0 +1,115 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk.json; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Represents an inline base64-encoded binary attachment (blob) for messages. + *

+ * Use this attachment type to pass image data or other binary content directly + * to the assistant, without requiring a file on disk. + * + *

Example Usage

+ * + *
{@code
+ * var attachment = new BlobAttachment().setData("iVBORw0KGgoAAAANSUhEUg...") // base64-encoded content
+ * 		.setMimeType("image/png").setDisplayName("screenshot.png");
+ *
+ * var options = new MessageOptions().setPrompt("Describe this image").setAttachments(List.of(attachment));
+ * }
+ * + * @see MessageOptions#setAttachments(java.util.List) + * @since 1.2.0 + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public final class BlobAttachment implements MessageAttachment { + + @JsonProperty("type") + private final String type = "blob"; + + @JsonProperty("data") + private String data; + + @JsonProperty("mimeType") + private String mimeType; + + @JsonProperty("displayName") + private String displayName; + + /** + * Returns the attachment type, always {@code "blob"}. + * + * @return {@code "blob"} + */ + @Override + public String getType() { + return type; + } + + /** + * Gets the base64-encoded binary content. + * + * @return the base64 data string + */ + public String getData() { + return data; + } + + /** + * Sets the base64-encoded binary content. + * + * @param data + * the base64-encoded content + * @return this attachment for method chaining + */ + public BlobAttachment setData(String data) { + this.data = data; + return this; + } + + /** + * Gets the MIME type of the binary content. + * + * @return the MIME type (e.g., {@code "image/png"}) + */ + public String getMimeType() { + return mimeType; + } + + /** + * Sets the MIME type of the binary content. + * + * @param mimeType + * the MIME type (e.g., {@code "image/png"}, {@code "image/jpeg"}) + * @return this attachment for method chaining + */ + public BlobAttachment setMimeType(String mimeType) { + this.mimeType = mimeType; + return this; + } + + /** + * Gets the human-readable display name for the attachment. + * + * @return the display name, or {@code null} + */ + public String getDisplayName() { + return displayName; + } + + /** + * Sets the human-readable display name for the attachment. + * + * @param displayName + * a user-visible name (e.g., {@code "screenshot.png"}) + * @return this attachment for method chaining + */ + public BlobAttachment setDisplayName(String displayName) { + this.displayName = displayName; + return this; + } +} diff --git a/src/main/java/com/github/copilot/sdk/json/CopilotClientOptions.java b/src/main/java/com/github/copilot/sdk/json/CopilotClientOptions.java index 4fd55d3ba..4cdee912c 100644 --- a/src/main/java/com/github/copilot/sdk/json/CopilotClientOptions.java +++ b/src/main/java/com/github/copilot/sdk/json/CopilotClientOptions.java @@ -42,11 +42,13 @@ public class CopilotClientOptions { private String cliUrl; private String logLevel = "info"; private boolean autoStart = true; - private boolean autoRestart = true; + @Deprecated + private boolean autoRestart; private Map environment; private String gitHubToken; private Boolean useLoggedInUser; private Supplier>> onListModels; + private TelemetryConfig telemetry; /** * Gets the path to the Copilot CLI executable. @@ -236,8 +238,11 @@ public CopilotClientOptions setAutoStart(boolean autoStart) { /** * Returns whether the client should automatically restart the server on crash. * - * @return {@code true} to auto-restart (default), {@code false} otherwise + * @return the auto-restart flag value (no longer has any effect) + * @deprecated This option has no effect and will be removed in a future + * release. */ + @Deprecated public boolean isAutoRestart() { return autoRestart; } @@ -247,9 +252,12 @@ public boolean isAutoRestart() { * crashes unexpectedly. * * @param autoRestart - * {@code true} to auto-restart, {@code false} otherwise + * ignored — this option no longer has any effect * @return this options instance for method chaining + * @deprecated This option has no effect and will be removed in a future + * release. */ + @Deprecated public CopilotClientOptions setAutoRestart(boolean autoRestart) { this.autoRestart = autoRestart; return this; @@ -378,6 +386,32 @@ public CopilotClientOptions setOnListModels(Supplier + * When set to a non-{@code null} value, the CLI server is started with + * OpenTelemetry instrumentation enabled using the provided settings. + * + * @param telemetry + * the telemetry configuration + * @return this options instance for method chaining + * @since 1.2.0 + */ + public CopilotClientOptions setTelemetry(TelemetryConfig telemetry) { + this.telemetry = telemetry; + return this; + } + /** * Creates a shallow clone of this {@code CopilotClientOptions} instance. *

@@ -404,6 +438,7 @@ public CopilotClientOptions clone() { copy.gitHubToken = this.gitHubToken; copy.useLoggedInUser = this.useLoggedInUser; copy.onListModels = this.onListModels; + copy.telemetry = this.telemetry; return copy; } } diff --git a/src/main/java/com/github/copilot/sdk/json/MessageAttachment.java b/src/main/java/com/github/copilot/sdk/json/MessageAttachment.java new file mode 100644 index 000000000..3371a56ba --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/json/MessageAttachment.java @@ -0,0 +1,32 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk.json; + +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; + +/** + * Marker interface for all attachment types that can be included in a message. + *

+ * This is the Java equivalent of the .NET SDK's + * {@code UserMessageDataAttachmentsItem} polymorphic base class. + * + * @see Attachment + * @see BlobAttachment + * @see MessageOptions#setAttachments(java.util.List) + * @since 1.2.0 + */ +@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type") +@JsonSubTypes({@JsonSubTypes.Type(value = Attachment.class, name = "file"), + @JsonSubTypes.Type(value = BlobAttachment.class, name = "blob")}) +public sealed interface MessageAttachment permits Attachment, BlobAttachment { + + /** + * Returns the attachment type discriminator (e.g., "file", "blob"). + * + * @return the type string + */ + String getType(); +} diff --git a/src/main/java/com/github/copilot/sdk/json/MessageOptions.java b/src/main/java/com/github/copilot/sdk/json/MessageOptions.java index 6ba629b7b..c2a92014e 100644 --- a/src/main/java/com/github/copilot/sdk/json/MessageOptions.java +++ b/src/main/java/com/github/copilot/sdk/json/MessageOptions.java @@ -25,6 +25,15 @@ * session.send(options).get(); * } * + *

Blob Attachment Example

+ * + *
{@code
+ * var options = new MessageOptions().setPrompt("Describe this image").setAttachments(List.of(new BlobAttachment()
+ * 		.setData("iVBORw0KGgoAAAANSUhEUg...").setMimeType("image/png").setDisplayName("screenshot.png")));
+ *
+ * session.send(options).get();
+ * }
+ * * @see com.github.copilot.sdk.CopilotSession#send(MessageOptions) * @since 1.0.0 */ @@ -32,7 +41,7 @@ public class MessageOptions { private String prompt; - private List attachments; + private List attachments; private String mode; /** @@ -57,27 +66,33 @@ public MessageOptions setPrompt(String prompt) { } /** - * Gets the file attachments. + * Gets the attachments. * * @return the list of attachments */ - public List getAttachments() { + public List getAttachments() { return attachments == null ? null : Collections.unmodifiableList(attachments); } /** - * Sets file attachments to include with the message. + * Sets attachments to include with the message. *

- * Attachments provide additional context to the assistant, such as source code - * files, documents, or other relevant files. + * Attachments provide additional context to the assistant. Supported types: + *

    + *
  • {@link Attachment} — file, directory, code selection, or GitHub + * reference
  • + *
  • {@link BlobAttachment} — inline base64-encoded binary data (e.g. images) + *
  • + *
* * @param attachments - * the list of file attachments + * the list of attachments * @return this options instance for method chaining * @see Attachment + * @see BlobAttachment */ - public MessageOptions setAttachments(List attachments) { - this.attachments = attachments; + public MessageOptions setAttachments(List attachments) { + this.attachments = attachments != null ? new ArrayList<>(attachments) : null; return this; } diff --git a/src/main/java/com/github/copilot/sdk/json/PermissionRequestResultKind.java b/src/main/java/com/github/copilot/sdk/json/PermissionRequestResultKind.java index f85a0df1c..8f30be092 100644 --- a/src/main/java/com/github/copilot/sdk/json/PermissionRequestResultKind.java +++ b/src/main/java/com/github/copilot/sdk/json/PermissionRequestResultKind.java @@ -51,6 +51,21 @@ public final class PermissionRequestResultKind { public static final PermissionRequestResultKind DENIED_INTERACTIVELY_BY_USER = new PermissionRequestResultKind( "denied-interactively-by-user"); + /** + * Leaves the pending permission request unanswered. + *

+ * When the SDK is used as an extension and the extension's permission handler + * cannot or chooses not to handle a given permission request, it can return + * {@code NO_RESULT} to leave the request unanswered, allowing another client to + * handle it. + *

+ * Warning: This kind is only valid with protocol v3 servers + * (broadcast permission model). When connected to a protocol v2 server, the SDK + * will throw {@link IllegalStateException} because v2 expects exactly one + * response per permission request. + */ + public static final PermissionRequestResultKind NO_RESULT = new PermissionRequestResultKind("no-result"); + private final String value; /** diff --git a/src/main/java/com/github/copilot/sdk/json/SectionOverride.java b/src/main/java/com/github/copilot/sdk/json/SectionOverride.java new file mode 100644 index 000000000..40a58449d --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/json/SectionOverride.java @@ -0,0 +1,138 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk.json; + +import java.util.concurrent.CompletableFuture; +import java.util.function.Function; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Override operation for a single system prompt section in + * {@link SystemMessageMode#CUSTOMIZE} mode. + *

+ * Each {@code SectionOverride} describes how one named section of the default + * system prompt should be modified. The section name keys come from + * {@link SystemPromptSections}. + * + *

Static override example

+ * + *
{@code
+ * var config = new SystemMessageConfig().setMode(SystemMessageMode.CUSTOMIZE).setSections(Map.of(
+ * 		SystemPromptSections.TONE,
+ * 		new SectionOverride().setAction(SectionOverrideAction.REPLACE).setContent("Be concise and formal."),
+ * 		SystemPromptSections.CODE_CHANGE_RULES, new SectionOverride().setAction(SectionOverrideAction.REMOVE)));
+ * }
+ * + *

Transform callback example

+ * + *
{@code
+ * var config = new SystemMessageConfig().setMode(SystemMessageMode.CUSTOMIZE)
+ * 		.setSections(Map.of(SystemPromptSections.IDENTITY, new SectionOverride().setTransform(
+ * 				content -> CompletableFuture.completedFuture(content + "\nAlways end replies with DONE."))));
+ * }
+ * + * @see SystemMessageConfig + * @see SectionOverrideAction + * @see SystemPromptSections + * @since 1.2.0 + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class SectionOverride { + + @JsonProperty("action") + private SectionOverrideAction action; + + @JsonProperty("content") + private String content; + + /** + * Transform callback invoked by the SDK when the CLI requests a + * {@code systemMessage.transform} RPC call. + *

+ * The function receives the current section content and returns the transformed + * content wrapped in a {@link CompletableFuture}. When a transform is set, it + * takes precedence over {@link #action}; the wire representation uses + * {@link SectionOverrideAction#TRANSFORM} automatically. + *

+ * This field is not serialized — it is handled entirely by the SDK. + */ + @JsonIgnore + private Function> transform; + + /** + * Gets the override action. + * + * @return the action, or {@code null} if a transform callback is set + */ + public SectionOverrideAction getAction() { + return action; + } + + /** + * Sets the override action. + * + * @param action + * the action to perform on this section + * @return this override for method chaining + */ + public SectionOverride setAction(SectionOverrideAction action) { + this.action = action; + return this; + } + + /** + * Gets the content for the override. + * + * @return the content, or {@code null} + */ + public String getContent() { + return content; + } + + /** + * Sets the content for the override. + *

+ * Used for {@link SectionOverrideAction#REPLACE}, + * {@link SectionOverrideAction#APPEND}, and + * {@link SectionOverrideAction#PREPEND}. Ignored for + * {@link SectionOverrideAction#REMOVE}. + * + * @param content + * the content string + * @return this override for method chaining + */ + public SectionOverride setContent(String content) { + this.content = content; + return this; + } + + /** + * Gets the transform callback. + * + * @return the transform function, or {@code null} if not set + */ + public Function> getTransform() { + return transform; + } + + /** + * Sets the transform callback for this section. + *

+ * The function receives the current section content as a {@code String} and + * returns the transformed content via a {@link CompletableFuture}. When set, + * this takes precedence over {@link #action}. + * + * @param transform + * a function that transforms the section content + * @return this override for method chaining + */ + public SectionOverride setTransform(Function> transform) { + this.transform = transform; + return this; + } +} diff --git a/src/main/java/com/github/copilot/sdk/json/SectionOverrideAction.java b/src/main/java/com/github/copilot/sdk/json/SectionOverrideAction.java new file mode 100644 index 000000000..2d179f753 --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/json/SectionOverrideAction.java @@ -0,0 +1,55 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk.json; + +import com.fasterxml.jackson.annotation.JsonValue; + +/** + * Specifies the operation to perform on a system prompt section in + * {@link SystemMessageMode#CUSTOMIZE} mode. + * + * @see SectionOverride + * @see SystemMessageConfig + * @since 1.2.0 + */ +public enum SectionOverrideAction { + + /** Replace the section content entirely. */ + REPLACE("replace"), + + /** Remove the section from the prompt. */ + REMOVE("remove"), + + /** Append content after the existing section. */ + APPEND("append"), + + /** Prepend content before the existing section. */ + PREPEND("prepend"), + + /** + * Transform the section content via a callback. + *

+ * When this action is used, the {@link SectionOverride#getTransform()} callback + * must be set. The SDK will not serialize this action over the wire directly; + * instead it registers a {@code systemMessage.transform} RPC handler. + */ + TRANSFORM("transform"); + + private final String value; + + SectionOverrideAction(String value) { + this.value = value; + } + + /** + * Returns the JSON value for this action. + * + * @return the string value used in JSON serialization + */ + @JsonValue + public String getValue() { + return value; + } +} diff --git a/src/main/java/com/github/copilot/sdk/json/SendMessageRequest.java b/src/main/java/com/github/copilot/sdk/json/SendMessageRequest.java index 477af98dc..32f43a7ef 100644 --- a/src/main/java/com/github/copilot/sdk/json/SendMessageRequest.java +++ b/src/main/java/com/github/copilot/sdk/json/SendMessageRequest.java @@ -31,7 +31,7 @@ public final class SendMessageRequest { private String prompt; @JsonProperty("attachments") - private List attachments; + private List attachments; @JsonProperty("mode") private String mode; @@ -57,12 +57,12 @@ public void setPrompt(String prompt) { } /** Gets the attachments. @return the list of attachments */ - public List getAttachments() { + public List getAttachments() { return attachments == null ? null : Collections.unmodifiableList(attachments); } /** Sets the attachments. @param attachments the list of attachments */ - public void setAttachments(List attachments) { + public void setAttachments(List attachments) { this.attachments = attachments; } diff --git a/src/main/java/com/github/copilot/sdk/json/SystemMessageConfig.java b/src/main/java/com/github/copilot/sdk/json/SystemMessageConfig.java index c45e46486..94af117ea 100644 --- a/src/main/java/com/github/copilot/sdk/json/SystemMessageConfig.java +++ b/src/main/java/com/github/copilot/sdk/json/SystemMessageConfig.java @@ -4,6 +4,8 @@ package com.github.copilot.sdk.json; +import java.util.Map; + import com.fasterxml.jackson.annotation.JsonInclude; import com.github.copilot.sdk.SystemMessageMode; @@ -11,8 +13,8 @@ * Configuration for customizing the system message. *

* The system message controls the behavior and personality of the AI assistant. - * This configuration allows you to either append to or replace the default - * system message. + * This configuration allows you to either append to, replace, or fine-tune the + * default system message. * *

Example - Append Mode

* @@ -28,8 +30,23 @@ * .setContent("You are a helpful coding assistant."); * } * + *

Example - Customize Mode

+ * + *
{@code
+ * var config = new SystemMessageConfig().setMode(SystemMessageMode.CUSTOMIZE)
+ * 		.setSections(
+ * 				Map.of(SystemPromptSections.TONE,
+ * 						new SectionOverride().setAction(SectionOverrideAction.REPLACE)
+ * 								.setContent("Be concise and formal."),
+ * 						SystemPromptSections.CODE_CHANGE_RULES,
+ * 						new SectionOverride().setAction(SectionOverrideAction.REMOVE)))
+ * 		.setContent("Additional instructions appended after all sections.");
+ * }
+ * * @see SessionConfig#setSystemMessage(SystemMessageConfig) * @see SystemMessageMode + * @see SectionOverride + * @see SystemPromptSections * @since 1.0.0 */ @JsonInclude(JsonInclude.Include.NON_NULL) @@ -37,11 +54,14 @@ public class SystemMessageConfig { private SystemMessageMode mode; private String content; + @JsonInclude(JsonInclude.Include.NON_NULL) + @com.fasterxml.jackson.annotation.JsonProperty("sections") + private Map sections; /** * Gets the system message mode. * - * @return the mode (APPEND or REPLACE) + * @return the mode (APPEND, REPLACE, or CUSTOMIZE) */ public SystemMessageMode getMode() { return mode; @@ -51,11 +71,12 @@ public SystemMessageMode getMode() { * Sets the system message mode. *

* Use {@link SystemMessageMode#APPEND} to add to the default system message - * while preserving guardrails, or {@link SystemMessageMode#REPLACE} to fully - * customize the system message. + * while preserving guardrails, {@link SystemMessageMode#REPLACE} to fully + * customize the system message, or {@link SystemMessageMode#CUSTOMIZE} to + * override individual sections. * * @param mode - * the mode (APPEND or REPLACE) + * the mode (APPEND, REPLACE, or CUSTOMIZE) * @return this config for method chaining */ public SystemMessageConfig setMode(SystemMessageMode mode) { @@ -75,8 +96,9 @@ public String getContent() { /** * Sets the system message content. *

- * This is the text that will be appended to or replace the default system - * message, depending on the configured mode. + * For {@link SystemMessageMode#APPEND} and {@link SystemMessageMode#REPLACE} + * modes, this is the primary content. For {@link SystemMessageMode#CUSTOMIZE} + * mode, this is appended after all section overrides. * * @param content * the system message content @@ -86,4 +108,33 @@ public SystemMessageConfig setContent(String content) { this.content = content; return this; } + + /** + * Gets the section-level overrides for {@link SystemMessageMode#CUSTOMIZE} + * mode. + * + * @return the sections map, or {@code null} + */ + public Map getSections() { + return sections; + } + + /** + * Sets section-level overrides for {@link SystemMessageMode#CUSTOMIZE} mode. + *

+ * Keys are section identifiers from {@link SystemPromptSections}. Each value + * describes how that section should be modified. Sections with a + * {@link SectionOverride#getTransform() transform} callback are handled locally + * by the SDK via a {@code systemMessage.transform} RPC call; the rest are sent + * to the CLI as-is. + * + * @param sections + * a map of section identifier to override operation + * @return this config for method chaining + * @since 1.2.0 + */ + public SystemMessageConfig setSections(Map sections) { + this.sections = sections; + return this; + } } diff --git a/src/main/java/com/github/copilot/sdk/json/SystemPromptSections.java b/src/main/java/com/github/copilot/sdk/json/SystemPromptSections.java new file mode 100644 index 000000000..fa512d032 --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/json/SystemPromptSections.java @@ -0,0 +1,66 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk.json; + +/** + * Well-known system prompt section identifiers for use with + * {@link SystemMessageMode#CUSTOMIZE} mode. + *

+ * Each constant names a section of the default Copilot system prompt. Pass + * these as keys in the {@code sections} map of {@link SystemMessageConfig} to + * override individual sections. + * + *

Example

+ * + *
{@code
+ * var config = new SystemMessageConfig().setMode(SystemMessageMode.CUSTOMIZE).setSections(Map.of(
+ * 		SystemPromptSections.TONE,
+ * 		new SectionOverride().setAction(SectionOverrideAction.REPLACE).setContent("Always be concise."),
+ * 		SystemPromptSections.CODE_CHANGE_RULES, new SectionOverride().setAction(SectionOverrideAction.REMOVE)));
+ * }
+ * + * @see SystemMessageConfig + * @see SectionOverride + * @since 1.2.0 + */ +public final class SystemPromptSections { + + /** Agent identity preamble and mode statement. */ + public static final String IDENTITY = "identity"; + + /** Response style, conciseness rules, output formatting preferences. */ + public static final String TONE = "tone"; + + /** Tool usage patterns, parallel calling, batching guidelines. */ + public static final String TOOL_EFFICIENCY = "tool_efficiency"; + + /** CWD, OS, git root, directory listing, available tools. */ + public static final String ENVIRONMENT_CONTEXT = "environment_context"; + + /** Coding rules, linting/testing, ecosystem tools, style. */ + public static final String CODE_CHANGE_RULES = "code_change_rules"; + + /** Tips, behavioral best practices, behavioral guidelines. */ + public static final String GUIDELINES = "guidelines"; + + /** Environment limitations, prohibited actions, security policies. */ + public static final String SAFETY = "safety"; + + /** Per-tool usage instructions. */ + public static final String TOOL_INSTRUCTIONS = "tool_instructions"; + + /** Repository and organization custom instructions. */ + public static final String CUSTOM_INSTRUCTIONS = "custom_instructions"; + + /** + * End-of-prompt instructions: parallel tool calling, persistence, task + * completion. + */ + public static final String LAST_INSTRUCTIONS = "last_instructions"; + + private SystemPromptSections() { + // utility class + } +} diff --git a/src/main/java/com/github/copilot/sdk/json/TelemetryConfig.java b/src/main/java/com/github/copilot/sdk/json/TelemetryConfig.java new file mode 100644 index 000000000..8407ab609 --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/json/TelemetryConfig.java @@ -0,0 +1,149 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk.json; + +/** + * OpenTelemetry configuration for the Copilot CLI server. + *

+ * When set on {@link CopilotClientOptions#setTelemetry(TelemetryConfig)}, the + * CLI server is started with OpenTelemetry instrumentation enabled using the + * provided settings. + * + *

Example Usage

+ * + *
{@code
+ * var options = new CopilotClientOptions()
+ * 		.setTelemetry(new TelemetryConfig().setOtlpEndpoint("http://localhost:4318").setSourceName("my-app"));
+ * }
+ * + * @see CopilotClientOptions#setTelemetry(TelemetryConfig) + * @since 1.2.0 + */ +public class TelemetryConfig { + + private String otlpEndpoint; + private String filePath; + private String exporterType; + private String sourceName; + private Boolean captureContent; + + /** + * Gets the OTLP exporter endpoint URL. + *

+ * Maps to the {@code OTEL_EXPORTER_OTLP_ENDPOINT} environment variable. + * + * @return the OTLP endpoint URL, or {@code null} + */ + public String getOtlpEndpoint() { + return otlpEndpoint; + } + + /** + * Sets the OTLP exporter endpoint URL. + * + * @param otlpEndpoint + * the endpoint URL (e.g., {@code "http://localhost:4318"}) + * @return this config for method chaining + */ + public TelemetryConfig setOtlpEndpoint(String otlpEndpoint) { + this.otlpEndpoint = otlpEndpoint; + return this; + } + + /** + * Gets the file path for the file exporter. + *

+ * Maps to the {@code COPILOT_OTEL_FILE_EXPORTER_PATH} environment variable. + * + * @return the file path, or {@code null} + */ + public String getFilePath() { + return filePath; + } + + /** + * Sets the file path for the file exporter. + * + * @param filePath + * the path where telemetry spans are written + * @return this config for method chaining + */ + public TelemetryConfig setFilePath(String filePath) { + this.filePath = filePath; + return this; + } + + /** + * Gets the exporter type. + *

+ * Maps to the {@code COPILOT_OTEL_EXPORTER_TYPE} environment variable. + * + * @return the exporter type (e.g., {@code "otlp-http"} or {@code "file"}), or + * {@code null} + */ + public String getExporterType() { + return exporterType; + } + + /** + * Sets the exporter type. + * + * @param exporterType + * the exporter type ({@code "otlp-http"} or {@code "file"}) + * @return this config for method chaining + */ + public TelemetryConfig setExporterType(String exporterType) { + this.exporterType = exporterType; + return this; + } + + /** + * Gets the source name for telemetry spans. + *

+ * Maps to the {@code COPILOT_OTEL_SOURCE_NAME} environment variable. + * + * @return the source name, or {@code null} + */ + public String getSourceName() { + return sourceName; + } + + /** + * Sets the source name for telemetry spans. + * + * @param sourceName + * a name identifying the application producing the spans + * @return this config for method chaining + */ + public TelemetryConfig setSourceName(String sourceName) { + this.sourceName = sourceName; + return this; + } + + /** + * Gets whether to capture message content as part of telemetry. + *

+ * Maps to the {@code OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT} + * environment variable. + * + * @return {@code true} to capture content, {@code false} to suppress it, or + * {@code null} to use the default + */ + public Boolean getCaptureContent() { + return captureContent; + } + + /** + * Sets whether to capture message content as part of telemetry. + * + * @param captureContent + * {@code true} to capture content, {@code false} to suppress it + * @return this config for method chaining + */ + public TelemetryConfig setCaptureContent(Boolean captureContent) { + this.captureContent = captureContent; + return this; + } +} diff --git a/src/main/java/com/github/copilot/sdk/json/ToolDefinition.java b/src/main/java/com/github/copilot/sdk/json/ToolDefinition.java index 9b3087a42..ba33ce1e3 100644 --- a/src/main/java/com/github/copilot/sdk/json/ToolDefinition.java +++ b/src/main/java/com/github/copilot/sdk/json/ToolDefinition.java @@ -52,6 +52,10 @@ * when {@code true}, indicates that this tool intentionally * overrides a built-in CLI tool with the same name; {@code null} or * {@code false} means the tool is purely custom + * @param skipPermission + * when {@code true}, the CLI skips the permission request for this + * tool invocation; {@code null} or {@code false} uses normal + * permission handling * @see SessionConfig#setTools(java.util.List) * @see ToolHandler * @since 1.0.0 @@ -59,7 +63,8 @@ @JsonInclude(JsonInclude.Include.NON_NULL) public record ToolDefinition(@JsonProperty("name") String name, @JsonProperty("description") String description, @JsonProperty("parameters") Object parameters, @JsonIgnore ToolHandler handler, - @JsonProperty("overridesBuiltInTool") Boolean overridesBuiltInTool) { + @JsonProperty("overridesBuiltInTool") Boolean overridesBuiltInTool, + @JsonProperty("skipPermission") Boolean skipPermission) { /** * Creates a tool definition with a JSON schema for parameters. @@ -79,7 +84,7 @@ public record ToolDefinition(@JsonProperty("name") String name, @JsonProperty("d */ public static ToolDefinition create(String name, String description, Map schema, ToolHandler handler) { - return new ToolDefinition(name, description, schema, handler, null); + return new ToolDefinition(name, description, schema, handler, null, null); } /** @@ -103,6 +108,29 @@ public static ToolDefinition create(String name, String description, Map schema, ToolHandler handler) { - return new ToolDefinition(name, description, schema, handler, true); + return new ToolDefinition(name, description, schema, handler, true, null); + } + + /** + * Creates a tool definition that skips the permission request. + *

+ * Use this factory method when the tool is safe to invoke without user + * permission confirmation. Setting {@code skipPermission} to {@code true} + * signals to the CLI that no permission check is needed. + * + * @param name + * the unique name of the tool + * @param description + * a description of what the tool does + * @param schema + * the JSON Schema as a {@code Map} + * @param handler + * the handler function to execute when invoked + * @return a new tool definition with permission skipping enabled + * @since 1.2.0 + */ + public static ToolDefinition createSkipPermission(String name, String description, Map schema, + ToolHandler handler) { + return new ToolDefinition(name, description, schema, handler, null, true); } } diff --git a/src/site/markdown/advanced.md b/src/site/markdown/advanced.md index f9ab53f55..bc9302840 100644 --- a/src/site/markdown/advanced.md +++ b/src/site/markdown/advanced.md @@ -6,11 +6,15 @@ This guide covers advanced scenarios for extending and customizing your Copilot - [Custom Tools](#Custom_Tools) - [Overriding Built-in Tools](#Overriding_Built-in_Tools) + - [Skipping Permission for Safe Tools](#Skipping_Permission_for_Safe_Tools) - [Switching Models Mid-Session](#Switching_Models_Mid-Session) - [System Messages](#System_Messages) - [Adding Rules](#Adding_Rules) - [Full Control](#Full_Control) + - [Fine-grained Customization](#Fine-grained_Customization) - [File Attachments](#File_Attachments) + - [Inline Blob Attachments](#Inline_Blob_Attachments) +- [OpenTelemetry](#OpenTelemetry) - [Bring Your Own Key (BYOK)](#Bring_Your_Own_Key_BYOK) - [Infinite Sessions](#Infinite_Sessions) - [Manual Compaction](#Manual_Compaction) @@ -42,6 +46,7 @@ This guide covers advanced scenarios for extending and customizing your Copilot - [Event Handler Exceptions](#Event_Handler_Exceptions) - [Custom Event Error Handler](#Custom_Event_Error_Handler) - [Event Error Policy](#Event_Error_Policy) +- [OpenTelemetry](#OpenTelemetry) --- @@ -108,6 +113,34 @@ var session = client.createSession( ).get(); ``` +### Skipping Permission for Safe Tools + +When a tool performs only read-only or non-destructive operations, you can mark it to skip the +permission prompt entirely using `ToolDefinition.createSkipPermission()`: + +```java +var safeLookup = ToolDefinition.createSkipPermission( + "safe_lookup", + "Look up a record by ID (read-only, no side effects)", + Map.of( + "type", "object", + "properties", Map.of( + "id", Map.of("type", "string") + ), + "required", List.of("id") + ), + invocation -> { + String id = (String) invocation.getArguments().get("id"); + return CompletableFuture.completedFuture("Record: " + lookupRecord(id)); + } +); +``` + +The CLI bypasses the permission request for this tool invocation, so no `PermissionRequestedEvent` +is emitted and the `onPermissionRequest` handler is not called. + +See [ToolDefinition](apidocs/com/github/copilot/sdk/json/ToolDefinition.html) Javadoc for details. + --- ## Switching Models Mid-Session @@ -124,10 +157,16 @@ var session = client.createSession( // Switch to a different model mid-conversation session.setModel("gpt-4.1").get(); +// Switch with a specific reasoning effort level +session.setModel("claude-sonnet-4.6", "high").get(); + // Next message will use the new model session.sendAndWait(new MessageOptions().setPrompt("Continue with the new model")).get(); ``` +The `reasoningEffort` parameter accepts `"low"`, `"medium"`, `"high"`, or `"xhigh"` for models +that support reasoning. Pass `null` (or use the single-argument overload) to use the default. + The session emits a [`SessionModelChangeEvent`](apidocs/com/github/copilot/sdk/events/SessionModelChangeEvent.html) when the switch completes, which you can observe with `session.on(SessionModelChangeEvent.class, event -> ...)`. @@ -170,6 +209,56 @@ var session = client.createSession( ).get(); ``` +### Fine-grained Customization + +Use `CUSTOMIZE` mode to override individual sections of the default system prompt without +replacing it entirely. You can replace, remove, append, prepend, or transform specific sections +using the section identifiers from `SystemPromptSections`. + +**Static overrides:** + +```java +var session = client.createSession( + new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL) + .setSystemMessage(new SystemMessageConfig() + .setMode(SystemMessageMode.CUSTOMIZE) + .setSections(Map.of( + // Replace the tone section + SystemPromptSections.TONE, + new SectionOverride() + .setAction(SectionOverrideAction.REPLACE) + .setContent("Be concise and formal in all responses."), + // Remove the code-change-rules section entirely + SystemPromptSections.CODE_CHANGE_RULES, + new SectionOverride() + .setAction(SectionOverrideAction.REMOVE) + )) + // Optional: extra content appended after all sections + .setContent("Always mention quarterly earnings.")) +).get(); +``` + +**Transform callbacks** let you inspect and modify section content at runtime: + +```java +var session = client.createSession( + new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL) + .setSystemMessage(new SystemMessageConfig() + .setMode(SystemMessageMode.CUSTOMIZE) + .setSections(Map.of( + SystemPromptSections.IDENTITY, + new SectionOverride() + .setTransform(content -> + CompletableFuture.completedFuture( + content + "\nAlways end your reply with DONE.")) + ))) +).get(); +``` + +See [SystemMessageConfig](apidocs/com/github/copilot/sdk/json/SystemMessageConfig.html), +[SectionOverride](apidocs/com/github/copilot/sdk/json/SectionOverride.html), and +[SystemPromptSections](apidocs/com/github/copilot/sdk/json/SystemPromptSections.html) Javadoc for details. + --- ## File Attachments @@ -203,6 +292,45 @@ session.send(new MessageOptions() ).get(); ``` +### Inline Blob Attachments + +Use `BlobAttachment` to pass inline base64-encoded binary data — for example, an image captured +at runtime — without writing it to disk first: + +```java +// Load image bytes and base64-encode them +byte[] imageBytes = Files.readAllBytes(Path.of("/path/to/screenshot.png")); +String base64Data = Base64.getEncoder().encodeToString(imageBytes); + +session.send(new MessageOptions() + .setPrompt("Describe this screenshot") + .setAttachments(List.of( + new BlobAttachment() + .setData(base64Data) + .setMimeType("image/png") + .setDisplayName("screenshot.png") + )) +).get(); +``` + +See [BlobAttachment](apidocs/com/github/copilot/sdk/json/BlobAttachment.html) Javadoc for details. + +Both `Attachment` and `BlobAttachment` implement the sealed `MessageAttachment` interface. +For a mixed list with both types, use an explicit type hint: + +```java +session.send(new MessageOptions() + .setPrompt("Analyze these") + .setAttachments(List.of( + new Attachment("file", "/path/to/file.java", "Source"), + new BlobAttachment() + .setData(base64Data) + .setMimeType("image/png") + .setDisplayName("screenshot.png") + )) +).get(); +``` + --- ## Bring Your Own Key (BYOK) @@ -928,6 +1056,43 @@ See [EventErrorPolicy](apidocs/com/github/copilot/sdk/EventErrorPolicy.html) and --- +## OpenTelemetry + +Enable OpenTelemetry tracing in the Copilot CLI server by configuring a `TelemetryConfig` +on the `CopilotClientOptions`. This is useful for observability, performance monitoring, +and debugging. + +```java +var options = new CopilotClientOptions() + .setTelemetry(new TelemetryConfig() + .setOtlpEndpoint("http://localhost:4318") // OTLP/HTTP exporter + .setSourceName("my-app")); + +var client = new CopilotClient(options); +``` + +To export to a local file instead: + +```java +var options = new CopilotClientOptions() + .setTelemetry(new TelemetryConfig() + .setExporterType("file") + .setFilePath("/tmp/copilot-traces.json") + .setCaptureContent(true)); // include message content in spans +``` + +| Property | Environment Variable | Description | +|----------|---------------------|-------------| +| `otlpEndpoint` | `OTEL_EXPORTER_OTLP_ENDPOINT` | OTLP exporter endpoint URL | +| `filePath` | `COPILOT_OTEL_FILE_EXPORTER_PATH` | File path for the file exporter | +| `exporterType` | `COPILOT_OTEL_EXPORTER_TYPE` | `"otlp-http"` or `"file"` | +| `sourceName` | `COPILOT_OTEL_SOURCE_NAME` | Source name for telemetry spans | +| `captureContent` | `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` | Whether to capture message content | + +See [TelemetryConfig](apidocs/com/github/copilot/sdk/json/TelemetryConfig.html) Javadoc for details. + +--- + ## Next Steps - 📖 **[Documentation](documentation.html)** - Core concepts, events, streaming, models, tool filtering, reasoning effort diff --git a/src/test/java/com/github/copilot/sdk/ForwardCompatibilityTest.java b/src/test/java/com/github/copilot/sdk/ForwardCompatibilityTest.java new file mode 100644 index 000000000..e64615141 --- /dev/null +++ b/src/test/java/com/github/copilot/sdk/ForwardCompatibilityTest.java @@ -0,0 +1,123 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.UUID; + +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.copilot.sdk.events.AbstractSessionEvent; +import com.github.copilot.sdk.events.SessionEventParser; +import com.github.copilot.sdk.events.UnknownSessionEvent; +import com.github.copilot.sdk.events.UserMessageEvent; + +/** + * Unit tests for forward-compatible handling of unknown session event types. + *

+ * Verifies that the SDK gracefully handles event types introduced by newer CLI + * versions without crashing. + */ +public class ForwardCompatibilityTest { + + @Test + void parse_knownEventType_returnsTypedEvent() { + String json = """ + { + "id": "00000000-0000-0000-0000-000000000001", + "timestamp": "2026-01-01T00:00:00Z", + "type": "user.message", + "data": { "content": "Hello" } + } + """; + var node = parse(json); + AbstractSessionEvent result = SessionEventParser.parse(node); + + assertInstanceOf(UserMessageEvent.class, result); + assertEquals("user.message", result.getType()); + } + + @Test + void parse_unknownEventType_returnsUnknownSessionEvent() { + String json = """ + { + "id": "12345678-1234-1234-1234-123456789abc", + "timestamp": "2026-06-15T10:30:00Z", + "type": "future.feature_from_server", + "data": { "key": "value" } + } + """; + var node = parse(json); + AbstractSessionEvent result = SessionEventParser.parse(node); + + assertInstanceOf(UnknownSessionEvent.class, result); + assertEquals("unknown", result.getType()); + } + + @Test + void parse_unknownEventType_preservesOriginalType() { + String json = """ + { + "id": "12345678-1234-1234-1234-123456789abc", + "timestamp": "2026-06-15T10:30:00Z", + "type": "future.feature_from_server", + "data": {} + } + """; + var node = parse(json); + AbstractSessionEvent result = SessionEventParser.parse(node); + + assertInstanceOf(UnknownSessionEvent.class, result); + assertEquals("future.feature_from_server", ((UnknownSessionEvent) result).getOriginalType()); + } + + @Test + void parse_unknownEventType_preservesBaseMetadata() { + String json = """ + { + "id": "12345678-1234-1234-1234-123456789abc", + "timestamp": "2026-06-15T10:30:00Z", + "parentId": "abcdefab-abcd-abcd-abcd-abcdefabcdef", + "type": "future.feature_from_server", + "data": {} + } + """; + var node = parse(json); + AbstractSessionEvent result = SessionEventParser.parse(node); + + assertNotNull(result); + assertEquals(UUID.fromString("12345678-1234-1234-1234-123456789abc"), result.getId()); + assertEquals(UUID.fromString("abcdefab-abcd-abcd-abcd-abcdefabcdef"), result.getParentId()); + } + + @Test + void unknownSessionEvent_getType_returnsUnknown() { + var evt = new UnknownSessionEvent("some.future.type"); + assertEquals("unknown", evt.getType()); + } + + @Test + void unknownSessionEvent_getOriginalType_returnsOriginal() { + var evt = new UnknownSessionEvent("some.future.type"); + assertEquals("some.future.type", evt.getOriginalType()); + } + + @Test + void unknownSessionEvent_nullType_usesUnknown() { + var evt = new UnknownSessionEvent(null); + assertEquals("unknown", evt.getType()); + assertEquals("unknown", evt.getOriginalType()); + } + + private com.fasterxml.jackson.databind.JsonNode parse(String json) { + try { + return new ObjectMapper().readTree(json); + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} diff --git a/src/test/java/com/github/copilot/sdk/MessageAttachmentTest.java b/src/test/java/com/github/copilot/sdk/MessageAttachmentTest.java new file mode 100644 index 000000000..3150cecc2 --- /dev/null +++ b/src/test/java/com/github/copilot/sdk/MessageAttachmentTest.java @@ -0,0 +1,158 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import com.github.copilot.sdk.json.Attachment; +import com.github.copilot.sdk.json.BlobAttachment; +import com.github.copilot.sdk.json.MessageAttachment; +import com.github.copilot.sdk.json.MessageOptions; +import com.github.copilot.sdk.json.SendMessageRequest; + +/** + * Tests for the {@link MessageAttachment} sealed interface and type-safe + * attachment handling. + */ +class MessageAttachmentTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + // ========================================================================= + // Sealed interface hierarchy + // ========================================================================= + + @Test + void attachmentImplementsMessageAttachment() { + Attachment attachment = new Attachment("file", "/path/to/file.java", "Source"); + assertInstanceOf(MessageAttachment.class, attachment); + assertEquals("file", attachment.getType()); + } + + @Test + void blobAttachmentImplementsMessageAttachment() { + BlobAttachment blob = new BlobAttachment().setData("aGVsbG8=").setMimeType("image/png") + .setDisplayName("test.png"); + assertInstanceOf(MessageAttachment.class, blob); + assertEquals("blob", blob.getType()); + } + + // ========================================================================= + // MessageOptions type safety + // ========================================================================= + + @Test + void setAttachmentsAcceptsListOfAttachment() { + MessageOptions options = new MessageOptions(); + List list = List.of(new Attachment("file", "/a.java", "A")); + options.setAttachments(list); + + assertEquals(1, options.getAttachments().size()); + assertInstanceOf(Attachment.class, options.getAttachments().get(0)); + } + + @Test + void setAttachmentsAcceptsListOfBlobAttachment() { + MessageOptions options = new MessageOptions(); + List list = List.of(new BlobAttachment().setData("ZGF0YQ==").setMimeType("image/jpeg")); + options.setAttachments(list); + + assertEquals(1, options.getAttachments().size()); + assertInstanceOf(BlobAttachment.class, options.getAttachments().get(0)); + } + + @Test + void setAttachmentsAcceptsMixedList() { + MessageOptions options = new MessageOptions(); + List mixed = List.of(new Attachment("file", "/a.java", "A"), + new BlobAttachment().setData("ZGF0YQ==").setMimeType("image/png")); + options.setAttachments(mixed); + + assertEquals(2, options.getAttachments().size()); + assertInstanceOf(Attachment.class, options.getAttachments().get(0)); + assertInstanceOf(BlobAttachment.class, options.getAttachments().get(1)); + } + + @Test + void setAttachmentsHandlesNull() { + MessageOptions options = new MessageOptions(); + options.setAttachments(null); + assertNull(options.getAttachments()); + } + + @Test + void getAttachmentsReturnsUnmodifiableList() { + MessageOptions options = new MessageOptions(); + options.setAttachments(List.of(new Attachment("file", "/a.java", "A"))); + assertThrows(UnsupportedOperationException.class, + () -> options.getAttachments().add(new Attachment("file", "/b.java", "B"))); + } + + // ========================================================================= + // SendMessageRequest type safety + // ========================================================================= + + @Test + void sendMessageRequestAcceptsMessageAttachmentList() { + SendMessageRequest request = new SendMessageRequest(); + List list = List.of(new Attachment("file", "/a.java", "A"), + new BlobAttachment().setData("ZGF0YQ==").setMimeType("image/png")); + request.setAttachments(list); + + assertEquals(2, request.getAttachments().size()); + } + + // ========================================================================= + // Jackson serialization + // ========================================================================= + + @Test + void serializeAttachmentIncludesType() throws Exception { + Attachment attachment = new Attachment("file", "/path/to/file.java", "Source"); + String json = MAPPER.writeValueAsString(attachment); + assertTrue(json.contains("\"type\":\"file\"")); + assertTrue(json.contains("\"path\":\"/path/to/file.java\"")); + } + + @Test + void serializeBlobAttachmentIncludesType() throws Exception { + BlobAttachment blob = new BlobAttachment().setData("aGVsbG8=").setMimeType("image/png") + .setDisplayName("test.png"); + String json = MAPPER.writeValueAsString(blob); + assertTrue(json.contains("\"type\":\"blob\"")); + assertTrue(json.contains("\"data\":\"aGVsbG8=\"")); + assertTrue(json.contains("\"mimeType\":\"image/png\"")); + } + + @Test + void serializeMessageOptionsWithMixedAttachments() throws Exception { + MessageOptions options = new MessageOptions().setPrompt("Describe") + .setAttachments(List.of(new Attachment("file", "/a.java", "A"), + new BlobAttachment().setData("ZGF0YQ==").setMimeType("image/png").setDisplayName("img.png"))); + + String json = MAPPER.writeValueAsString(options); + assertTrue(json.contains("\"type\":\"file\"")); + assertTrue(json.contains("\"type\":\"blob\"")); + } + + @Test + void cloneMessageOptionsPreservesAttachments() { + MessageOptions original = new MessageOptions().setPrompt("test") + .setAttachments(List.of(new Attachment("file", "/a.java", "A"))); + + MessageOptions cloned = original.clone(); + + assertEquals(1, cloned.getAttachments().size()); + assertInstanceOf(Attachment.class, cloned.getAttachments().get(0)); + // Verify clone is independent + assertNotSame(original.getAttachments(), cloned.getAttachments()); + } +} diff --git a/src/test/java/com/github/copilot/sdk/PermissionRequestResultKindTest.java b/src/test/java/com/github/copilot/sdk/PermissionRequestResultKindTest.java index b21f96e83..5d68a560e 100644 --- a/src/test/java/com/github/copilot/sdk/PermissionRequestResultKindTest.java +++ b/src/test/java/com/github/copilot/sdk/PermissionRequestResultKindTest.java @@ -28,6 +28,7 @@ void wellKnownKinds_haveExpectedValues() { PermissionRequestResultKind.DENIED_COULD_NOT_REQUEST_FROM_USER.getValue()); assertEquals("denied-interactively-by-user", PermissionRequestResultKind.DENIED_INTERACTIVELY_BY_USER.getValue()); + assertEquals("no-result", PermissionRequestResultKind.NO_RESULT.getValue()); } @Test @@ -113,7 +114,7 @@ void jsonRoundTrip_allWellKnownKinds() throws Exception { PermissionRequestResultKind[] kinds = {PermissionRequestResultKind.APPROVED, PermissionRequestResultKind.DENIED_BY_RULES, PermissionRequestResultKind.DENIED_COULD_NOT_REQUEST_FROM_USER, - PermissionRequestResultKind.DENIED_INTERACTIVELY_BY_USER,}; + PermissionRequestResultKind.DENIED_INTERACTIVELY_BY_USER, PermissionRequestResultKind.NO_RESULT,}; for (PermissionRequestResultKind kind : kinds) { var result = new PermissionRequestResult().setKind(kind); String json = mapper.writeValueAsString(result); diff --git a/src/test/java/com/github/copilot/sdk/RpcHandlerDispatcherTest.java b/src/test/java/com/github/copilot/sdk/RpcHandlerDispatcherTest.java index 465e66a8a..61ad4dadd 100644 --- a/src/test/java/com/github/copilot/sdk/RpcHandlerDispatcherTest.java +++ b/src/test/java/com/github/copilot/sdk/RpcHandlerDispatcherTest.java @@ -25,6 +25,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; import com.github.copilot.sdk.json.PermissionRequestResult; +import com.github.copilot.sdk.json.PermissionRequestResultKind; import com.github.copilot.sdk.json.PreToolUseHookOutput; import com.github.copilot.sdk.json.SessionHooks; import com.github.copilot.sdk.json.SessionLifecycleEvent; @@ -341,6 +342,25 @@ void permissionRequestHandlerFails() throws Exception { assertEquals("denied-no-approval-rule-and-could-not-request-from-user", result.get("kind").asText()); } + @Test + void permissionRequestV2RejectsNoResult() throws Exception { + CopilotSession session = createSession("s1"); + session.registerPermissionHandler((request, invocation) -> CompletableFuture + .completedFuture(new PermissionRequestResult().setKind(PermissionRequestResultKind.NO_RESULT))); + + ObjectNode params = MAPPER.createObjectNode(); + params.put("sessionId", "s1"); + params.putObject("permissionRequest"); + + invokeHandler("permission.request", "13", params); + + // V2 protocol does not support NO_RESULT — the handler should fall through + // to the exception path and respond with denied. + JsonNode response = readResponse(); + JsonNode result = response.get("result").get("result"); + assertEquals("denied-no-approval-rule-and-could-not-request-from-user", result.get("kind").asText()); + } + // ===== userInput.request tests ===== @Test diff --git a/src/test/java/com/github/copilot/sdk/SessionEventParserTest.java b/src/test/java/com/github/copilot/sdk/SessionEventParserTest.java index d4770a721..5898d5301 100644 --- a/src/test/java/com/github/copilot/sdk/SessionEventParserTest.java +++ b/src/test/java/com/github/copilot/sdk/SessionEventParserTest.java @@ -845,7 +845,12 @@ void testParseUnknownEventType() throws Exception { """; AbstractSessionEvent event = parseJson(json); - assertNull(event, "Unknown event types should return null"); + assertNotNull(event, "Unknown event types should return an UnknownSessionEvent"); + assertInstanceOf(com.github.copilot.sdk.events.UnknownSessionEvent.class, event, + "Unknown event types should return UnknownSessionEvent for forward compatibility"); + assertEquals("unknown", event.getType()); + assertEquals("unknown.event.type", + ((com.github.copilot.sdk.events.UnknownSessionEvent) event).getOriginalType()); } @Test diff --git a/src/test/java/com/github/copilot/sdk/SessionHandlerTest.java b/src/test/java/com/github/copilot/sdk/SessionHandlerTest.java index 53b478eab..068ae630a 100644 --- a/src/test/java/com/github/copilot/sdk/SessionHandlerTest.java +++ b/src/test/java/com/github/copilot/sdk/SessionHandlerTest.java @@ -17,6 +17,7 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.github.copilot.sdk.json.PermissionRequestResult; +import com.github.copilot.sdk.json.PermissionRequestResultKind; import com.github.copilot.sdk.json.SessionEndHookOutput; import com.github.copilot.sdk.json.SessionHooks; import com.github.copilot.sdk.json.SessionStartHookOutput; @@ -115,6 +116,26 @@ void testHandlePermissionRequestHandlerSucceeds() throws Exception { assertEquals("allow", result.getKind()); } + // ===== handlePermissionRequest: handler returns NO_RESULT (v3 path) ===== + + @Test + void testHandlePermissionRequestNoResultPassesThrough() throws Exception { + session.registerPermissionHandler((request, invocation) -> { + var res = new PermissionRequestResult(); + res.setKind(PermissionRequestResultKind.NO_RESULT); + return CompletableFuture.completedFuture(res); + }); + + JsonNode data = MAPPER.valueToTree(Map.of("tool", "read_file")); + + PermissionRequestResult result = session.handlePermissionRequest(data).get(); + + // In v3, NO_RESULT is a valid response — the session just returns it + // and the caller (CopilotSession.executePermissionAndRespondAsync) decides + // to skip sending the RPC response. + assertEquals("no-result", result.getKind()); + } + // ===== handleUserInputRequest: no handler registered ===== @Test diff --git a/src/test/java/com/github/copilot/sdk/SessionRequestBuilderTest.java b/src/test/java/com/github/copilot/sdk/SessionRequestBuilderTest.java index c00355211..1a64b7534 100644 --- a/src/test/java/com/github/copilot/sdk/SessionRequestBuilderTest.java +++ b/src/test/java/com/github/copilot/sdk/SessionRequestBuilderTest.java @@ -251,4 +251,58 @@ void testBuildResumeRequestWithAgent() { ResumeSessionRequest request = SessionRequestBuilder.buildResumeRequest("session-id", config); assertEquals("my-agent", request.getAgent()); } + + // ========================================================================= + // extractTransformCallbacks + // ========================================================================= + + @Test + void extractTransformCallbacks_nullSystemMessage_returnsNull() { + ExtractedTransforms result = SessionRequestBuilder.extractTransformCallbacks(null); + assertNull(result.wireSystemMessage()); + assertNull(result.transformCallbacks()); + } + + @Test + void extractTransformCallbacks_appendMode_returnsOriginalConfig() { + var config = new com.github.copilot.sdk.json.SystemMessageConfig() + .setMode(com.github.copilot.sdk.SystemMessageMode.APPEND).setContent("extra content"); + ExtractedTransforms result = SessionRequestBuilder.extractTransformCallbacks(config); + assertSame(config, result.wireSystemMessage()); + assertNull(result.transformCallbacks()); + } + + @Test + void extractTransformCallbacks_customizeModeNoTransforms_returnsOriginalConfig() { + var sections = Map.of("tone", new com.github.copilot.sdk.json.SectionOverride() + .setAction(com.github.copilot.sdk.json.SectionOverrideAction.REMOVE)); + var config = new com.github.copilot.sdk.json.SystemMessageConfig() + .setMode(com.github.copilot.sdk.SystemMessageMode.CUSTOMIZE).setSections(sections); + ExtractedTransforms result = SessionRequestBuilder.extractTransformCallbacks(config); + assertSame(config, result.wireSystemMessage()); + assertNull(result.transformCallbacks()); + } + + @Test + void extractTransformCallbacks_customizeModeWithTransform_extractsCallbacks() { + var transformFn = (java.util.function.Function>) content -> CompletableFuture + .completedFuture(content + " modified"); + var sections = Map.of("identity", new com.github.copilot.sdk.json.SectionOverride().setTransform(transformFn)); + var config = new com.github.copilot.sdk.json.SystemMessageConfig() + .setMode(com.github.copilot.sdk.SystemMessageMode.CUSTOMIZE).setSections(sections); + + ExtractedTransforms result = SessionRequestBuilder.extractTransformCallbacks(config); + + // Wire config should be different from original + assertNotSame(config, result.wireSystemMessage()); + // Callbacks should be extracted + assertNotNull(result.transformCallbacks()); + assertTrue(result.transformCallbacks().containsKey("identity")); + // Wire config should have transform action instead of callback + assertNotNull(result.wireSystemMessage().getSections()); + var wireSection = result.wireSystemMessage().getSections().get("identity"); + assertNotNull(wireSection); + assertEquals(com.github.copilot.sdk.json.SectionOverrideAction.TRANSFORM, wireSection.getAction()); + assertNull(wireSection.getTransform()); + } }