diff --git a/.github/badges/jacoco.svg b/.github/badges/jacoco.svg index 482adb13b..f1a7c5eb3 100644 --- a/.github/badges/jacoco.svg +++ b/.github/badges/jacoco.svg @@ -12,7 +12,7 @@ coverage coverage - 84.7% - 84.7% + 84.4% + 84.4% diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index d7dafb081..284e2b800 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -104,7 +104,7 @@ When porting from .NET: - 4-space indentation (enforced by Spotless with Eclipse formatter) - Fluent setter pattern for configuration classes (e.g., `new SessionConfig().setModel("gpt-5").setTools(tools)`) - Public APIs require Javadoc (enforced by Checkstyle, except `json` and `events` packages) -- Pre-commit hook runs `mvn spotless:check` - enable with: `git config core.hooksPath .githooks` +- Pre-commit hook runs `mvn spotless:check` - Must be manually enabled with: `git config core.hooksPath .githooks`, except in the Copilot coding agent environment. This hook is explicitly enabled in the Copilot coding agent environment. See [copilot-setup-steps.yml](workflows/copilot-setup-steps.yml). ### Handler Pattern @@ -244,6 +244,18 @@ This SDK is designed to be **lightweight with minimal dependencies**: 5. Check for security vulnerabilities 6. Get team approval for non-trivial additions +## Pre-commit Hooks and Formatting (Coding Agent) + +The repository has a pre-commit hook (`.githooks/pre-commit`) that is **automatically enabled** in the Copilot coding agent environment via `copilot-setup-steps.yml`. The hook runs `mvn spotless:check` on any commit that includes changes under `src/`. + +**If a commit fails due to the pre-commit hook:** + +1. Run `mvn spotless:apply` to auto-fix formatting issues. +2. Re-stage the changed files with `git add -u`. +3. Retry the commit. + +**Best practice:** Always run `mvn spotless:apply` before committing Java source changes to avoid hook failures in the first place. If you forget and the hook rejects the commit, follow the three steps above and continue. + ## Commit and PR Guidelines ### Commit Messages diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml index 6a0cdec5b..145629457 100644 --- a/.github/workflows/copilot-setup-steps.yml +++ b/.github/workflows/copilot-setup-steps.yml @@ -41,6 +41,10 @@ jobs: distribution: 'temurin' cache: 'maven' + # Enable repository pre-commit hooks (including Spotless checks for relevant source changes) + - name: Enable pre-commit hooks + run: git config core.hooksPath .githooks + # Verify installations - name: Verify tool installations run: | @@ -50,4 +54,6 @@ jobs: java -version gh --version gh aw version + echo "--- Git hooks path ---" + git config core.hooksPath echo "✅ All tools installed successfully" diff --git a/.github/workflows/weekly-upstream-sync.md b/.github/workflows/weekly-upstream-sync.md index 641f29301..0aaff8d1e 100644 --- a/.github/workflows/weekly-upstream-sync.md +++ b/.github/workflows/weekly-upstream-sync.md @@ -41,111 +41,77 @@ safe-outputs: noop: report-as-issue: false --- -# Weekly Upstream Sync Agentic Workflow -This document describes the `weekly-upstream-sync.yml` GitHub Actions workflow, which automates the detection of new changes in the official [Copilot SDK](https://github.com/github/copilot-sdk) and delegates the merge work to the Copilot coding agent. +# Weekly Upstream Sync -## Overview +You are an automation agent that detects new upstream changes and creates GitHub issues. You do **NOT** perform any code merges, edits, or pushes. Do **NOT** invoke any skills (especially `agentic-merge-upstream`). Your only job is to check for changes and use safe-output tools to create or close issues. -The workflow runs on a **weekly schedule** (every Monday at 10:00 UTC) and can also be triggered manually. It does **not** perform the actual merge — instead, it detects upstream changes and creates a GitHub issue assigned to `copilot`, instructing the agent to follow the [agentic-merge-upstream](../prompts/agentic-merge-upstream.prompt.md) prompt to port the changes. +## Instructions -The agent must also create the Pull Request with the label `upstream-sync`. This allows the workflow to track the merge progress and avoid creating duplicate issues if the agent is still working on a previous sync. +Follow these steps exactly: -## Trigger +### Step 1: Read `.lastmerge` -| Trigger | Schedule | -|---|---| -| `schedule` | Every Monday at 10:00 UTC (`0 10 * * 1`) | -| `workflow_dispatch` | Manual trigger from the Actions tab | +Read the file `.lastmerge` in the repository root. It contains the SHA of the last upstream commit that was merged into this Java SDK. -## Workflow Steps +### Step 2: Check for upstream changes -### 1. Checkout repository +Clone the upstream repository and compare commits: -Checks out the repo to read the `.lastmerge` file, which contains the SHA of the last upstream commit that was merged into the Java SDK. - -### 2. Check for upstream changes - -- Reads the last merged commit hash from `.lastmerge` -- Clones the upstream `github/copilot-sdk` repository -- Compares `.lastmerge` against upstream `HEAD` -- If they match: sets `has_changes=false` -- If they differ: counts new commits, generates a summary (up to 20 most recent), and sets outputs (`commit_count`, `upstream_head`, `last_merge`, `summary`) - -### 3. Close previous upstream-sync issues (when changes found) - -**Condition:** `has_changes == true` +```bash +LAST_MERGE=$(cat .lastmerge) +git clone --quiet https://github.com/github/copilot-sdk.git /tmp/gh-aw/agent/upstream +cd /tmp/gh-aw/agent/upstream +UPSTREAM_HEAD=$(git rev-parse HEAD) +``` -Before creating a new issue, closes any existing open issues with the `upstream-sync` label. This prevents stale issues from accumulating when previous sync attempts were incomplete or superseded. Each closed issue receives a comment explaining it was superseded. +If `LAST_MERGE` equals `UPSTREAM_HEAD`, there are **no new changes**. Go to Step 3a. -### 4. Close stale upstream-sync issues (when no changes found) +If they differ, count the new commits and generate a summary: -**Condition:** `has_changes == false` +```bash +COMMIT_COUNT=$(git rev-list --count "$LAST_MERGE".."$UPSTREAM_HEAD") +SUMMARY=$(git log --oneline "$LAST_MERGE".."$UPSTREAM_HEAD" | head -20) +``` -If the upstream is already up to date, closes any lingering open `upstream-sync` issues with a comment noting that no changes were detected. This handles the case where a previous issue was created but the changes were merged manually (updating `.lastmerge`) before the agent completed. +Go to Step 3b. -### 5. Create issue and assign to Copilot +### Step 3a: No changes detected -**Condition:** `has_changes == true` +1. Search for any open issues with the `upstream-sync` label using the GitHub MCP tools. +2. If there are open `upstream-sync` issues, close each one using the `close_issue` safe-output tool with a comment: "No new upstream changes detected. The Java SDK is up to date. Closing." +3. Call the `noop` safe-output tool with message: "No new upstream changes since last merge ()." +4. **Stop here.** Do not proceed further. -Creates a new GitHub issue with: +### Step 3b: Changes detected -- **Title:** `Upstream sync: N new commits (YYYY-MM-DD)` -- **Label:** `upstream-sync` -- **Assignee:** `copilot` -- **Body:** Contains commit count, commit range links, a summary of recent commits, and a link to the merge prompt +1. Search for any open issues with the `upstream-sync` label using the GitHub MCP tools. +2. Close each existing open `upstream-sync` issue using the `close_issue` safe-output tool with a comment: "Superseded by a newer upstream sync check." +3. Create a new issue using the `create_issue` safe-output tool with: + - **Title:** `Upstream sync: new commits ()` + - **Body:** Include the following information: + ``` + ## Automated Upstream Sync -The Copilot coding agent picks up the issue, creates a branch and PR, then follows the merge prompt to port the changes. + There are **** new commits in the [official Copilot SDK](https://github.com/github/copilot-sdk) since the last merge. -### 6. Summary + - **Last merged commit:** [``](https://github.com/github/copilot-sdk/commit/) + - **Upstream HEAD:** [``](https://github.com/github/copilot-sdk/commit/) -Writes a GitHub Actions step summary with: + ### Recent upstream commits -- Whether changes were detected -- Commit count and range -- Recent upstream commits -- Link to the created issue (if any) + ``` + + ``` -## Flow Diagram + ### Instructions -``` -┌─────────────────────┐ -│ Schedule / Manual │ -└──────────┬──────────┘ - │ - ▼ -┌─────────────────────┐ -│ Read .lastmerge │ -│ Clone upstream SDK │ -│ Compare commits │ -└──────────┬──────────┘ - │ - ┌─────┴─────┐ - │ │ - changes? no changes - │ │ - ▼ ▼ -┌──────────┐ ┌──────────────────┐ -│ Close old│ │ Close stale │ -│ issues │ │ issues │ -└────┬─────┘ └──────────────────┘ - │ - ▼ -┌──────────────────────────┐ -│ Create issue assigned to │ -│ copilot │ -└──────────────────────────┘ - │ - ▼ -┌──────────────────────────┐ -│ Agent follows prompt to │ -│ port changes → PR │ -└──────────────────────────┘ -``` + Follow the [agentic-merge-upstream](.github/prompts/agentic-merge-upstream.prompt.md) prompt to port these changes to the Java SDK. + ``` +4. After creating the issue, use the `assign_to_agent` safe-output tool to assign Copilot to the newly created issue. -## Related Files +## Important constraints -| File | Purpose | -|---|---| -| `.lastmerge` | Stores the SHA of the last merged upstream commit | -| [agentic-merge-upstream.prompt.md](../prompts/agentic-merge-upstream.prompt.md) | Detailed instructions the Copilot agent follows to port changes | -| `.github/scripts/upstream-sync/` | Helper scripts used by the merge prompt | +- **Do NOT edit any files**, create branches, or push code. +- **Do NOT invoke any skills** such as `agentic-merge-upstream` or `commit-as-pull-request`. +- **Do NOT attempt to merge or port upstream changes.** That is done by a separate agent that picks up the issue you create. +- You **MUST** call at least one safe-output tool (`create_issue`, `close_issue`, `noop`, etc.) before finishing. diff --git a/.lastmerge b/.lastmerge index a0cf76b72..83feb636c 100644 --- a/.lastmerge +++ b/.lastmerge @@ -1 +1 @@ -40887393a9e687dacc141a645799441b0313ff15 +c3fa6cbfb83d4a20b7912b1a17013d48f5a277a1 diff --git a/CHANGELOG.md b/CHANGELOG.md index e306db097..38f6e6589 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,30 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] -> **Upstream sync:** [`github/copilot-sdk@4088739`](https://github.com/github/copilot-sdk/commit/40887393a9e687dacc141a645799441b0313ff15) +> **Upstream sync:** [`github/copilot-sdk@c3fa6cb`](https://github.com/github/copilot-sdk/commit/c3fa6cbfb83d4a20b7912b1a17013d48f5a277a1) + +## [0.2.2-java.1] - 2026-04-07 + +> **Upstream sync:** [`github/copilot-sdk@c3fa6cb`](https://github.com/github/copilot-sdk/commit/c3fa6cbfb83d4a20b7912b1a17013d48f5a277a1) +### Added + +- Slash commands — register `/command` handlers invoked from the CLI TUI via `SessionConfig.setCommands()` (upstream: [`f7fd757`](https://github.com/github/copilot-sdk/commit/f7fd757)) +- `CommandDefinition`, `CommandContext`, `CommandHandler`, `CommandWireDefinition` — types for defining and handling slash commands +- `CommandExecuteEvent` — event dispatched when a registered slash command is executed +- Elicitation (UI dialogs) — incoming handler via `SessionConfig.setOnElicitationRequest()` and outgoing convenience methods via `session.getUi()` (upstream: [`f7fd757`](https://github.com/github/copilot-sdk/commit/f7fd757)) +- `ElicitationContext`, `ElicitationHandler`, `ElicitationParams`, `ElicitationResult`, `ElicitationResultAction`, `ElicitationSchema`, `InputOptions` — types for elicitation +- `ElicitationRequestedEvent` — event dispatched when an elicitation request is received +- `SessionUiApi` — convenience API on `session.getUi()` for `confirm()`, `select()`, `input()`, and `elicitation()` calls +- `SessionCapabilities` and `SessionUiCapabilities` — session capability reporting populated from create/resume response +- `CapabilitiesChangedEvent` — event dispatched when session capabilities are updated +- `CopilotClient.getSessionMetadata(String)` — O(1) session lookup by ID +- `GetSessionMetadataResponse` — response type for `getSessionMetadata` + +### Fixed + +- Permission events already resolved by a pre-hook now short-circuit before invoking the client-side handler +- `SessionUiApi` Javadoc now uses valid Java null-check syntax instead of `?.` +- README updated to say "GitHub Copilot CLI 1.0.17" instead of "GitHub Copilot 1.0.17" ## [0.2.1-java.1] - 2026-04-02 @@ -465,16 +488,22 @@ New types: `GetForegroundSessionResponse`, `SetForegroundSessionResponse` - Pre-commit hook for Spotless code formatting - Comprehensive API documentation -[Unreleased]: https://github.com/github/copilot-sdk-java/compare/v0.2.1-java.1...HEAD +[Unreleased]: https://github.com/github/copilot-sdk-java/compare/v0.2.2-java.1...HEAD +[0.2.2-java.1]: https://github.com/github/copilot-sdk-java/compare/v0.2.1-java.1...v0.2.2-java.1 +[Unreleased]: https://github.com/github/copilot-sdk-java/compare/v0.2.2-java.1...HEAD +[0.2.2-java.1]: https://github.com/github/copilot-sdk-java/compare/v0.2.1-java.1...v0.2.2-java.1 [0.2.1-java.1]: https://github.com/github/copilot-sdk-java/compare/v0.2.1-java.0...v0.2.1-java.1 -[Unreleased]: https://github.com/github/copilot-sdk-java/compare/v0.2.1-java.1...HEAD +[Unreleased]: https://github.com/github/copilot-sdk-java/compare/v0.2.2-java.1...HEAD +[0.2.2-java.1]: https://github.com/github/copilot-sdk-java/compare/v0.2.1-java.1...v0.2.2-java.1 [0.2.1-java.1]: https://github.com/github/copilot-sdk-java/compare/v0.2.1-java.0...v0.2.1-java.1 [0.2.1-java.0]: https://github.com/github/copilot-sdk-java/compare/v0.1.32-java.0...v0.2.1-java.0 -[Unreleased]: https://github.com/github/copilot-sdk-java/compare/v0.2.1-java.1...HEAD +[Unreleased]: https://github.com/github/copilot-sdk-java/compare/v0.2.2-java.1...HEAD +[0.2.2-java.1]: https://github.com/github/copilot-sdk-java/compare/v0.2.1-java.1...v0.2.2-java.1 [0.2.1-java.1]: https://github.com/github/copilot-sdk-java/compare/v0.2.1-java.0...v0.2.1-java.1 [0.2.1-java.0]: https://github.com/github/copilot-sdk-java/compare/v0.1.32-java.0...v0.2.1-java.0 [0.1.32-java.0]: https://github.com/github/copilot-sdk-java/compare/v1.0.11...v0.1.32-java.0 -[Unreleased]: https://github.com/github/copilot-sdk-java/compare/v0.2.1-java.1...HEAD +[Unreleased]: https://github.com/github/copilot-sdk-java/compare/v0.2.2-java.1...HEAD +[0.2.2-java.1]: https://github.com/github/copilot-sdk-java/compare/v0.2.1-java.1...v0.2.2-java.1 [0.2.1-java.1]: https://github.com/github/copilot-sdk-java/compare/v0.2.1-java.0...v0.2.1-java.1 [0.2.1-java.0]: https://github.com/github/copilot-sdk-java/compare/v0.1.32-java.0...v0.2.1-java.0 [0.1.32-java.0]: https://github.com/github/copilot-sdk-java/compare/v1.0.11...v0.1.32-java.0 diff --git a/README.md b/README.md index 3010b6839..539a33895 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. **JDK 25 recommended**. Selecting JDK 25 enables the use of virtual threads, as shown in the [Quick Start](#quick-start). -- GitHub Copilot 1.0.15-0 or later installed and in `PATH` (or provide custom `cliPath`) +- GitHub Copilot CLI 1.0.17 or later installed and in `PATH` (or provide custom `cliPath`) ### Maven @@ -33,7 +33,7 @@ Java SDK for programmatic control of GitHub Copilot CLI, enabling you to build A com.github copilot-sdk-java - 0.2.1-java.1 + 0.2.2-java.1 ``` @@ -60,7 +60,7 @@ Snapshot builds of the next development version are published to Maven Central S ### Gradle ```groovy -implementation 'com.github:copilot-sdk-java:0.2.1-java.1' +implementation 'com.github:copilot-sdk-java:0.2.2-java.1' ``` ## Quick Start diff --git a/pom.xml b/pom.xml index 43e1b436d..8e3e7e1df 100644 --- a/pom.xml +++ b/pom.xml @@ -7,7 +7,7 @@ com.github copilot-sdk-java - 0.2.1-java.1 + 0.2.2-java.1 jar GitHub Copilot SDK :: Java @@ -33,7 +33,7 @@ scm:git:https://github.com/github/copilot-sdk-java.git scm:git:https://github.com/github/copilot-sdk-java.git https://github.com/github/copilot-sdk-java - v0.2.1-java.1 + v0.2.2-java.1 @@ -51,6 +51,8 @@ ${copilot.sdk.clone.dir}/test false + + @@ -89,7 +91,7 @@ org.mockito mockito-core - 5.17.0 + 5.23.0 test @@ -245,8 +247,8 @@ maven-surefire-plugin 3.5.4 - - ${testExecutionAgentArgs} + + ${testExecutionAgentArgs} ${surefire.jvm.args} ${copilot.tests.dir} ${copilot.sdk.clone.dir} @@ -543,6 +545,18 @@ + + + jdk21+ + + [21,) + + + -XX:+EnableDynamicAgentLoading + + skip-test-harness diff --git a/src/main/java/com/github/copilot/sdk/CopilotClient.java b/src/main/java/com/github/copilot/sdk/CopilotClient.java index e2790f6a3..f00e2fd11 100644 --- a/src/main/java/com/github/copilot/sdk/CopilotClient.java +++ b/src/main/java/com/github/copilot/sdk/CopilotClient.java @@ -24,6 +24,7 @@ import com.github.copilot.sdk.json.DeleteSessionResponse; import com.github.copilot.sdk.json.GetAuthStatusResponse; import com.github.copilot.sdk.json.GetLastSessionIdResponse; +import com.github.copilot.sdk.json.GetSessionMetadataResponse; import com.github.copilot.sdk.json.GetModelsResponse; import com.github.copilot.sdk.json.GetStatusResponse; import com.github.copilot.sdk.json.ListSessionsResponse; @@ -374,6 +375,7 @@ public CompletableFuture createSession(SessionConfig config) { return connection.rpc.invoke("session.create", request, CreateSessionResponse.class).thenApply(response -> { session.setWorkspacePath(response.workspacePath()); + session.setCapabilities(response.capabilities()); // If the server returned a different sessionId (e.g. a v2 CLI that ignores // the client-supplied ID), re-key the sessions map. String returnedId = response.sessionId(); @@ -444,6 +446,7 @@ public CompletableFuture resumeSession(String sessionId, ResumeS return connection.rpc.invoke("session.resume", request, ResumeSessionResponse.class).thenApply(response -> { session.setWorkspacePath(response.workspacePath()); + session.setCapabilities(response.capabilities()); // If the server returned a different sessionId than what was requested, re-key. String returnedId = response.sessionId(); if (returnedId != null && !returnedId.equals(sessionId)) { @@ -657,6 +660,34 @@ public CompletableFuture> listSessions(SessionListFilter f }); } + /** + * Gets metadata for a specific session by ID. + *

+ * This provides an efficient O(1) lookup of a single session's metadata instead + * of listing all sessions. + * + *

Example Usage

+ * + *
{@code
+     * var metadata = client.getSessionMetadata("session-123").get();
+     * if (metadata != null) {
+     * 	System.out.println("Session started at: " + metadata.getStartTime());
+     * }
+     * }
+ * + * @param sessionId + * the ID of the session to look up + * @return a future that resolves with the {@link SessionMetadata}, or + * {@code null} if the session was not found + * @see SessionMetadata + * @since 1.0.0 + */ + public CompletableFuture getSessionMetadata(String sessionId) { + return ensureConnected().thenCompose(connection -> connection.rpc + .invoke("session.getMetadata", Map.of("sessionId", sessionId), GetSessionMetadataResponse.class) + .thenApply(GetSessionMetadataResponse::session)); + } + /** * Gets the ID of the session currently displayed in the TUI. *

diff --git a/src/main/java/com/github/copilot/sdk/CopilotSession.java b/src/main/java/com/github/copilot/sdk/CopilotSession.java index 844737fc2..23b1b5368 100644 --- a/src/main/java/com/github/copilot/sdk/CopilotSession.java +++ b/src/main/java/com/github/copilot/sdk/CopilotSession.java @@ -31,14 +31,27 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.github.copilot.sdk.events.AbstractSessionEvent; import com.github.copilot.sdk.events.AssistantMessageEvent; +import com.github.copilot.sdk.events.CapabilitiesChangedEvent; +import com.github.copilot.sdk.events.CommandExecuteEvent; +import com.github.copilot.sdk.events.ElicitationRequestedEvent; import com.github.copilot.sdk.events.ExternalToolRequestedEvent; import com.github.copilot.sdk.events.PermissionRequestedEvent; import com.github.copilot.sdk.events.SessionErrorEvent; import com.github.copilot.sdk.events.SessionEventParser; import com.github.copilot.sdk.events.SessionIdleEvent; import com.github.copilot.sdk.json.AgentInfo; +import com.github.copilot.sdk.json.CommandContext; +import com.github.copilot.sdk.json.CommandDefinition; +import com.github.copilot.sdk.json.CommandHandler; +import com.github.copilot.sdk.json.ElicitationContext; +import com.github.copilot.sdk.json.ElicitationHandler; +import com.github.copilot.sdk.json.ElicitationParams; +import com.github.copilot.sdk.json.ElicitationResult; +import com.github.copilot.sdk.json.ElicitationResultAction; +import com.github.copilot.sdk.json.ElicitationSchema; import com.github.copilot.sdk.json.GetMessagesResponse; import com.github.copilot.sdk.json.HookInvocation; +import com.github.copilot.sdk.json.InputOptions; import com.github.copilot.sdk.json.MessageOptions; import com.github.copilot.sdk.json.PermissionHandler; import com.github.copilot.sdk.json.PermissionInvocation; @@ -49,9 +62,12 @@ import com.github.copilot.sdk.json.PreToolUseHookInput; import com.github.copilot.sdk.json.SendMessageRequest; import com.github.copilot.sdk.json.SendMessageResponse; +import com.github.copilot.sdk.json.SessionCapabilities; import com.github.copilot.sdk.json.SessionEndHookInput; import com.github.copilot.sdk.json.SessionHooks; import com.github.copilot.sdk.json.SessionStartHookInput; +import com.github.copilot.sdk.json.SessionUiApi; +import com.github.copilot.sdk.json.SessionUiCapabilities; import com.github.copilot.sdk.json.ToolDefinition; import com.github.copilot.sdk.json.ToolResultObject; import com.github.copilot.sdk.json.UserInputHandler; @@ -116,11 +132,15 @@ public final class CopilotSession implements AutoCloseable { */ private volatile String sessionId; private volatile String workspacePath; + private volatile SessionCapabilities capabilities = new SessionCapabilities(); + private final SessionUiApi ui; private final JsonRpcClient rpc; private final Set> eventHandlers = ConcurrentHashMap.newKeySet(); private final Map toolHandlers = new ConcurrentHashMap<>(); + private final Map commandHandlers = new ConcurrentHashMap<>(); private final AtomicReference permissionHandler = new AtomicReference<>(); private final AtomicReference userInputHandler = new AtomicReference<>(); + private final AtomicReference elicitationHandler = new AtomicReference<>(); private final AtomicReference hooksHandler = new AtomicReference<>(); private volatile EventErrorHandler eventErrorHandler; private volatile EventErrorPolicy eventErrorPolicy = EventErrorPolicy.PROPAGATE_AND_LOG_ERRORS; @@ -163,6 +183,7 @@ public final class CopilotSession implements AutoCloseable { this.sessionId = sessionId; this.rpc = rpc; this.workspacePath = workspacePath; + this.ui = new SessionUiApiImpl(); var executor = new ScheduledThreadPoolExecutor(1, r -> { var t = new Thread(r, "sendAndWait-timeout"); t.setDaemon(true); @@ -225,6 +246,30 @@ void setWorkspacePath(String workspacePath) { this.workspacePath = workspacePath; } + /** + * Gets the capabilities reported by the host for this session. + *

+ * Capabilities are populated from the session create/resume response and + * updated in real time via {@code capabilities.changed} events. + * + * @return the session capabilities (never {@code null}) + */ + public SessionCapabilities getCapabilities() { + return capabilities; + } + + /** + * Gets the UI API for eliciting information from the user during this session. + *

+ * All methods on this API throw {@link IllegalStateException} if the host does + * not report elicitation support via {@link #getCapabilities()}. + * + * @return the UI API + */ + public SessionUiApi getUi() { + return ui; + } + /** * Sets a custom error handler for exceptions thrown by event handlers. *

@@ -669,11 +714,49 @@ private void handleBroadcastEventAsync(AbstractSessionEvent event) { if (data == null || data.requestId() == null || data.permissionRequest() == null) { return; } + if (Boolean.TRUE.equals(data.resolvedByHook())) { + return; // Already resolved by a permissionRequest hook; no client action needed. + } PermissionHandler handler = permissionHandler.get(); if (handler == null) { return; // This client doesn't handle permissions; another client will } executePermissionAndRespondAsync(data.requestId(), data.permissionRequest(), handler); + } else if (event instanceof CommandExecuteEvent cmdEvent) { + var data = cmdEvent.getData(); + if (data == null || data.requestId() == null || data.commandName() == null) { + return; + } + executeCommandAndRespondAsync(data.requestId(), data.commandName(), data.command(), data.args()); + } else if (event instanceof ElicitationRequestedEvent elicitEvent) { + var data = elicitEvent.getData(); + if (data == null || data.requestId() == null) { + return; + } + ElicitationHandler handler = elicitationHandler.get(); + if (handler != null) { + ElicitationSchema schema = null; + if (data.requestedSchema() != null) { + schema = new ElicitationSchema().setType(data.requestedSchema().type()) + .setProperties(data.requestedSchema().properties()) + .setRequired(data.requestedSchema().required()); + } + var context = new ElicitationContext().setSessionId(sessionId).setMessage(data.message()) + .setRequestedSchema(schema).setMode(data.mode()).setElicitationSource(data.elicitationSource()) + .setUrl(data.url()); + handleElicitationRequestAsync(context, data.requestId()); + } + } else if (event instanceof CapabilitiesChangedEvent capEvent) { + var data = capEvent.getData(); + if (data != null) { + var newCapabilities = new SessionCapabilities(); + if (data.ui() != null) { + newCapabilities.setUi(new SessionUiCapabilities().setElicitation(data.ui().elicitation())); + } else { + newCapabilities.setUi(capabilities.getUi()); + } + capabilities = newCapabilities; + } } } @@ -816,6 +899,250 @@ void registerTools(List tools) { } } + /** + * Executes a command handler and sends the result back via + * {@code session.commands.handlePendingCommand}. + */ + private void executeCommandAndRespondAsync(String requestId, String commandName, String command, String args) { + CommandHandler handler = commandHandlers.get(commandName); + Runnable task = () -> { + if (handler == null) { + try { + rpc.invoke("session.commands.handlePendingCommand", Map.of("sessionId", sessionId, "requestId", + requestId, "error", "Unknown command: " + commandName), Object.class); + } catch (Exception e) { + LOG.log(Level.WARNING, "Error sending command error for requestId=" + requestId, e); + } + return; + } + try { + var ctx = new CommandContext().setSessionId(sessionId).setCommand(command).setCommandName(commandName) + .setArgs(args); + handler.handle(ctx).thenRun(() -> { + try { + rpc.invoke("session.commands.handlePendingCommand", + Map.of("sessionId", sessionId, "requestId", requestId), Object.class); + } catch (Exception e) { + LOG.log(Level.WARNING, "Error sending command result for requestId=" + requestId, e); + } + }).exceptionally(ex -> { + try { + String msg = ex.getMessage() != null ? ex.getMessage() : ex.toString(); + rpc.invoke("session.commands.handlePendingCommand", + Map.of("sessionId", sessionId, "requestId", requestId, "error", msg), Object.class); + } catch (Exception e) { + LOG.log(Level.WARNING, "Error sending command error for requestId=" + requestId, e); + } + return null; + }); + } catch (Exception e) { + LOG.log(Level.WARNING, "Error executing command for requestId=" + requestId, e); + try { + String msg = e.getMessage() != null ? e.getMessage() : e.toString(); + rpc.invoke("session.commands.handlePendingCommand", + Map.of("sessionId", sessionId, "requestId", requestId, "error", msg), Object.class); + } catch (Exception sendEx) { + LOG.log(Level.WARNING, "Error sending command error for requestId=" + requestId, sendEx); + } + } + }; + try { + if (executor != null) { + CompletableFuture.runAsync(task, executor); + } else { + CompletableFuture.runAsync(task); + } + } catch (RejectedExecutionException e) { + LOG.log(Level.WARNING, "Executor rejected command task for requestId=" + requestId + "; running inline", e); + task.run(); + } + } + + /** + * Dispatches an elicitation request to the registered handler and responds via + * {@code session.ui.handlePendingElicitation}. Auto-cancels on handler errors. + */ + private void handleElicitationRequestAsync(ElicitationContext context, String requestId) { + ElicitationHandler handler = elicitationHandler.get(); + if (handler == null) { + return; + } + Runnable task = () -> { + try { + handler.handle(context).thenAccept(result -> { + try { + String actionStr = result.getAction() != null + ? result.getAction().getValue() + : ElicitationResultAction.CANCEL.getValue(); + Map resultMap = result.getContent() != null + ? Map.of("action", actionStr, "content", result.getContent()) + : Map.of("action", actionStr); + rpc.invoke("session.ui.handlePendingElicitation", + Map.of("sessionId", sessionId, "requestId", requestId, "result", resultMap), + Object.class); + } catch (Exception e) { + LOG.log(Level.WARNING, "Error sending elicitation result for requestId=" + requestId, e); + } + }).exceptionally(ex -> { + try { + rpc.invoke("session.ui.handlePendingElicitation", Map.of("sessionId", sessionId, "requestId", + requestId, "result", Map.of("action", ElicitationResultAction.CANCEL.getValue())), + Object.class); + } catch (Exception e) { + LOG.log(Level.WARNING, "Error sending elicitation cancel for requestId=" + requestId, e); + } + return null; + }); + } catch (Exception e) { + LOG.log(Level.WARNING, "Error executing elicitation handler for requestId=" + requestId, e); + try { + rpc.invoke( + "session.ui.handlePendingElicitation", Map.of("sessionId", sessionId, "requestId", + requestId, "result", Map.of("action", ElicitationResultAction.CANCEL.getValue())), + Object.class); + } catch (Exception sendEx) { + LOG.log(Level.WARNING, "Error sending elicitation cancel for requestId=" + requestId, sendEx); + } + } + }; + try { + if (executor != null) { + CompletableFuture.runAsync(task, executor); + } else { + CompletableFuture.runAsync(task); + } + } catch (RejectedExecutionException e) { + LOG.log(Level.WARNING, "Executor rejected elicitation task for requestId=" + requestId + "; running inline", + e); + task.run(); + } + } + + /** + * Throws if the host does not support elicitation. + */ + private void assertElicitation() { + SessionCapabilities caps = capabilities; + if (caps == null || caps.getUi() == null || !Boolean.TRUE.equals(caps.getUi().getElicitation())) { + throw new IllegalStateException("Elicitation is not supported by the host. " + + "Check session.getCapabilities().getUi()?.getElicitation() before calling UI methods."); + } + } + + /** + * Implements {@link SessionUiApi} backed by the session's RPC connection. + */ + private final class SessionUiApiImpl implements SessionUiApi { + + @Override + public CompletableFuture elicitation(ElicitationParams params) { + assertElicitation(); + var schema = new java.util.HashMap(); + schema.put("type", params.getRequestedSchema().getType()); + schema.put("properties", params.getRequestedSchema().getProperties()); + if (params.getRequestedSchema().getRequired() != null) { + schema.put("required", params.getRequestedSchema().getRequired()); + } + return rpc.invoke("session.ui.elicitation", + Map.of("sessionId", sessionId, "message", params.getMessage(), "requestedSchema", schema), + ElicitationRpcResponse.class).thenApply(resp -> { + var result = new ElicitationResult(); + if (resp.action() != null) { + for (ElicitationResultAction a : ElicitationResultAction.values()) { + if (a.getValue().equalsIgnoreCase(resp.action())) { + result.setAction(a); + break; + } + } + } + if (result.getAction() == null) { + result.setAction(ElicitationResultAction.CANCEL); + } + result.setContent(resp.content()); + return result; + }); + } + + @Override + public CompletableFuture confirm(String message) { + assertElicitation(); + var field = Map.of("type", "boolean", "default", (Object) true); + var schema = Map.of("type", (Object) "object", "properties", (Object) Map.of("confirmed", (Object) field), + "required", (Object) new String[]{"confirmed"}); + return rpc.invoke("session.ui.elicitation", + Map.of("sessionId", sessionId, "message", message, "requestedSchema", schema), + ElicitationRpcResponse.class).thenApply(resp -> { + if ("accept".equalsIgnoreCase(resp.action()) && resp.content() != null) { + Object val = resp.content().get("confirmed"); + if (val instanceof Boolean b) { + return b; + } + if (val instanceof com.fasterxml.jackson.databind.node.BooleanNode bn) { + return bn.booleanValue(); + } + if (val instanceof String s) { + return Boolean.parseBoolean(s); + } + } + return false; + }); + } + + @Override + public CompletableFuture select(String message, String[] options) { + assertElicitation(); + var field = Map.of("type", (Object) "string", "enum", (Object) options); + var schema = Map.of("type", (Object) "object", "properties", (Object) Map.of("selection", (Object) field), + "required", (Object) new String[]{"selection"}); + return rpc.invoke("session.ui.elicitation", + Map.of("sessionId", sessionId, "message", message, "requestedSchema", schema), + ElicitationRpcResponse.class).thenApply(resp -> { + if ("accept".equalsIgnoreCase(resp.action()) && resp.content() != null) { + Object val = resp.content().get("selection"); + return val != null ? val.toString() : null; + } + return null; + }); + } + + @Override + public CompletableFuture input(String message, InputOptions options) { + assertElicitation(); + var field = new java.util.LinkedHashMap(); + field.put("type", "string"); + if (options != null) { + if (options.getTitle() != null) + field.put("title", options.getTitle()); + if (options.getDescription() != null) + field.put("description", options.getDescription()); + if (options.getMinLength() != null) + field.put("minLength", options.getMinLength()); + if (options.getMaxLength() != null) + field.put("maxLength", options.getMaxLength()); + if (options.getFormat() != null) + field.put("format", options.getFormat()); + if (options.getDefaultValue() != null) + field.put("default", options.getDefaultValue()); + } + var schema = Map.of("type", (Object) "object", "properties", (Object) Map.of("value", (Object) field), + "required", (Object) new String[]{"value"}); + return rpc.invoke("session.ui.elicitation", + Map.of("sessionId", sessionId, "message", message, "requestedSchema", schema), + ElicitationRpcResponse.class).thenApply(resp -> { + if ("accept".equalsIgnoreCase(resp.action()) && resp.content() != null) { + Object val = resp.content().get("value"); + return val != null ? val.toString() : null; + } + return null; + }); + } + } + + @JsonIgnoreProperties(ignoreUnknown = true) + private record ElicitationRpcResponse(@JsonProperty("action") String action, + @JsonProperty("content") Map content) { + } + /** * Retrieves a registered tool by name. * @@ -888,6 +1215,50 @@ void registerUserInputHandler(UserInputHandler handler) { userInputHandler.set(handler); } + /** + * Registers command handlers for this session. + *

+ * Called internally when creating or resuming a session with commands. + * + * @param commands + * the command definitions to register + */ + void registerCommands(java.util.List commands) { + commandHandlers.clear(); + if (commands != null) { + for (CommandDefinition cmd : commands) { + if (cmd.getName() != null && cmd.getHandler() != null) { + commandHandlers.put(cmd.getName(), cmd.getHandler()); + } + } + } + } + + /** + * Registers an elicitation handler for this session. + *

+ * Called internally when creating or resuming a session with an elicitation + * handler. + * + * @param handler + * the handler to invoke when an elicitation request is received + */ + void registerElicitationHandler(ElicitationHandler handler) { + elicitationHandler.set(handler); + } + + /** + * Sets the capabilities reported by the host for this session. + *

+ * Called internally after session create/resume response. + * + * @param sessionCapabilities + * the capabilities to set, or {@code null} for empty capabilities + */ + void setCapabilities(SessionCapabilities sessionCapabilities) { + this.capabilities = sessionCapabilities != null ? sessionCapabilities : new SessionCapabilities(); + } + /** * Handles a user input request from the Copilot CLI. *

@@ -1366,8 +1737,10 @@ public void close() { eventHandlers.clear(); toolHandlers.clear(); + commandHandlers.clear(); permissionHandler.set(null); userInputHandler.set(null); + elicitationHandler.set(null); hooksHandler.set(null); } diff --git a/src/main/java/com/github/copilot/sdk/SessionRequestBuilder.java b/src/main/java/com/github/copilot/sdk/SessionRequestBuilder.java index 6f1cd573c..d74bbfaf3 100644 --- a/src/main/java/com/github/copilot/sdk/SessionRequestBuilder.java +++ b/src/main/java/com/github/copilot/sdk/SessionRequestBuilder.java @@ -10,6 +10,7 @@ import java.util.function.Function; import com.github.copilot.sdk.json.CreateSessionRequest; +import com.github.copilot.sdk.json.CommandWireDefinition; import com.github.copilot.sdk.json.ResumeSessionConfig; import com.github.copilot.sdk.json.ResumeSessionRequest; import com.github.copilot.sdk.json.SectionOverride; @@ -122,6 +123,16 @@ static CreateSessionRequest buildCreateRequest(SessionConfig config, String sess request.setDisabledSkills(config.getDisabledSkills()); request.setConfigDir(config.getConfigDir()); + if (config.getCommands() != null && !config.getCommands().isEmpty()) { + var wireCommands = config.getCommands().stream() + .map(c -> new CommandWireDefinition(c.getName(), c.getDescription())) + .collect(java.util.stream.Collectors.toList()); + request.setCommands(wireCommands); + } + if (config.getOnElicitationRequest() != null) { + request.setRequestElicitation(true); + } + return request; } @@ -183,6 +194,16 @@ static ResumeSessionRequest buildResumeRequest(String sessionId, ResumeSessionCo request.setDisabledSkills(config.getDisabledSkills()); request.setInfiniteSessions(config.getInfiniteSessions()); + if (config.getCommands() != null && !config.getCommands().isEmpty()) { + var wireCommands = config.getCommands().stream() + .map(c -> new CommandWireDefinition(c.getName(), c.getDescription())) + .collect(java.util.stream.Collectors.toList()); + request.setCommands(wireCommands); + } + if (config.getOnElicitationRequest() != null) { + request.setRequestElicitation(true); + } + return request; } @@ -211,6 +232,12 @@ static void configureSession(CopilotSession session, SessionConfig config) { if (config.getHooks() != null) { session.registerHooks(config.getHooks()); } + if (config.getCommands() != null) { + session.registerCommands(config.getCommands()); + } + if (config.getOnElicitationRequest() != null) { + session.registerElicitationHandler(config.getOnElicitationRequest()); + } if (config.getOnEvent() != null) { session.on(config.getOnEvent()); } @@ -241,6 +268,12 @@ static void configureSession(CopilotSession session, ResumeSessionConfig config) if (config.getHooks() != null) { session.registerHooks(config.getHooks()); } + if (config.getCommands() != null) { + session.registerCommands(config.getCommands()); + } + if (config.getOnElicitationRequest() != null) { + session.registerElicitationHandler(config.getOnElicitationRequest()); + } if (config.getOnEvent() != null) { session.on(config.getOnEvent()); } 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 4626bb4f8..51f6d8712 100644 --- a/src/main/java/com/github/copilot/sdk/events/AbstractSessionEvent.java +++ b/src/main/java/com/github/copilot/sdk/events/AbstractSessionEvent.java @@ -65,8 +65,8 @@ public abstract sealed class AbstractSessionEvent permits ToolExecutionCompleteEvent, // Broadcast request/completion events (protocol v3) ExternalToolRequestedEvent, ExternalToolCompletedEvent, PermissionRequestedEvent, PermissionCompletedEvent, - CommandQueuedEvent, CommandCompletedEvent, ExitPlanModeRequestedEvent, ExitPlanModeCompletedEvent, - SystemNotificationEvent, + CommandQueuedEvent, CommandCompletedEvent, CommandExecuteEvent, ElicitationRequestedEvent, + CapabilitiesChangedEvent, ExitPlanModeRequestedEvent, ExitPlanModeCompletedEvent, SystemNotificationEvent, // User events UserMessageEvent, PendingMessagesModifiedEvent, // Skill events diff --git a/src/main/java/com/github/copilot/sdk/events/CapabilitiesChangedEvent.java b/src/main/java/com/github/copilot/sdk/events/CapabilitiesChangedEvent.java new file mode 100644 index 000000000..0db68189d --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/events/CapabilitiesChangedEvent.java @@ -0,0 +1,44 @@ +/*--------------------------------------------------------------------------------------------- + * 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: capabilities.changed + *

+ * Broadcast when the host's session capabilities change. The SDK updates + * {@link com.github.copilot.sdk.CopilotSession#getCapabilities()} accordingly. + * + * @since 1.0.0 + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class CapabilitiesChangedEvent extends AbstractSessionEvent { + + @JsonProperty("data") + private CapabilitiesChangedData data; + + @Override + public String getType() { + return "capabilities.changed"; + } + + public CapabilitiesChangedData getData() { + return data; + } + + public void setData(CapabilitiesChangedData data) { + this.data = data; + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record CapabilitiesChangedData(@JsonProperty("ui") CapabilitiesChangedUi ui) { + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record CapabilitiesChangedUi(@JsonProperty("elicitation") Boolean elicitation) { + } +} diff --git a/src/main/java/com/github/copilot/sdk/events/CommandExecuteEvent.java b/src/main/java/com/github/copilot/sdk/events/CommandExecuteEvent.java new file mode 100644 index 000000000..c08c4a88d --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/events/CommandExecuteEvent.java @@ -0,0 +1,43 @@ +/*--------------------------------------------------------------------------------------------- + * 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: command.execute + *

+ * Broadcast when the user executes a slash command registered by this client. + * Clients that have a matching command handler should respond via + * {@code session.commands.handlePendingCommand}. + * + * @since 1.0.0 + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class CommandExecuteEvent extends AbstractSessionEvent { + + @JsonProperty("data") + private CommandExecuteData data; + + @Override + public String getType() { + return "command.execute"; + } + + public CommandExecuteData getData() { + return data; + } + + public void setData(CommandExecuteData data) { + this.data = data; + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record CommandExecuteData(@JsonProperty("requestId") String requestId, + @JsonProperty("command") String command, @JsonProperty("commandName") String commandName, + @JsonProperty("args") String args) { + } +} diff --git a/src/main/java/com/github/copilot/sdk/events/ElicitationRequestedEvent.java b/src/main/java/com/github/copilot/sdk/events/ElicitationRequestedEvent.java new file mode 100644 index 000000000..e459dfb77 --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/events/ElicitationRequestedEvent.java @@ -0,0 +1,54 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk.events; + +import java.util.List; +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Event: elicitation.requested + *

+ * Broadcast when the server or an MCP tool requests structured input from the + * user. Clients that have an elicitation handler should respond via + * {@code session.ui.handlePendingElicitation}. + * + * @since 1.0.0 + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class ElicitationRequestedEvent extends AbstractSessionEvent { + + @JsonProperty("data") + private ElicitationRequestedData data; + + @Override + public String getType() { + return "elicitation.requested"; + } + + public ElicitationRequestedData getData() { + return data; + } + + public void setData(ElicitationRequestedData data) { + this.data = data; + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record ElicitationRequestedData(@JsonProperty("requestId") String requestId, + @JsonProperty("toolCallId") String toolCallId, @JsonProperty("elicitationSource") String elicitationSource, + @JsonProperty("message") String message, @JsonProperty("mode") String mode, + @JsonProperty("requestedSchema") ElicitationRequestedSchema requestedSchema, + @JsonProperty("url") String url) { + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record ElicitationRequestedSchema(@JsonProperty("type") String type, + @JsonProperty("properties") Map properties, + @JsonProperty("required") List required) { + } +} diff --git a/src/main/java/com/github/copilot/sdk/events/PermissionRequestedEvent.java b/src/main/java/com/github/copilot/sdk/events/PermissionRequestedEvent.java index d8f9ec147..7ebce5ac7 100644 --- a/src/main/java/com/github/copilot/sdk/events/PermissionRequestedEvent.java +++ b/src/main/java/com/github/copilot/sdk/events/PermissionRequestedEvent.java @@ -38,6 +38,7 @@ public void setData(PermissionRequestedData data) { @JsonIgnoreProperties(ignoreUnknown = true) public record PermissionRequestedData(@JsonProperty("requestId") String requestId, - @JsonProperty("permissionRequest") PermissionRequest permissionRequest) { + @JsonProperty("permissionRequest") PermissionRequest permissionRequest, + @JsonProperty("resolvedByHook") Boolean resolvedByHook) { } } 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 308317e6b..dda971769 100644 --- a/src/main/java/com/github/copilot/sdk/events/SessionEventParser.java +++ b/src/main/java/com/github/copilot/sdk/events/SessionEventParser.java @@ -99,6 +99,9 @@ public class SessionEventParser { TYPE_MAP.put("permission.completed", PermissionCompletedEvent.class); TYPE_MAP.put("command.queued", CommandQueuedEvent.class); TYPE_MAP.put("command.completed", CommandCompletedEvent.class); + TYPE_MAP.put("command.execute", CommandExecuteEvent.class); + TYPE_MAP.put("elicitation.requested", ElicitationRequestedEvent.class); + TYPE_MAP.put("capabilities.changed", CapabilitiesChangedEvent.class); TYPE_MAP.put("exit_plan_mode.requested", ExitPlanModeRequestedEvent.class); TYPE_MAP.put("exit_plan_mode.completed", ExitPlanModeCompletedEvent.class); TYPE_MAP.put("system.notification", SystemNotificationEvent.class); diff --git a/src/main/java/com/github/copilot/sdk/json/CommandContext.java b/src/main/java/com/github/copilot/sdk/json/CommandContext.java new file mode 100644 index 000000000..4657699bb --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/json/CommandContext.java @@ -0,0 +1,74 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk.json; + +/** + * Context passed to a {@link CommandHandler} when a slash command is executed. + * + * @since 1.0.0 + */ +public class CommandContext { + + private String sessionId; + private String command; + private String commandName; + private String args; + + /** Gets the session ID where the command was invoked. @return the session ID */ + public String getSessionId() { + return sessionId; + } + + /** Sets the session ID. @param sessionId the session ID @return this */ + public CommandContext setSessionId(String sessionId) { + this.sessionId = sessionId; + return this; + } + + /** + * Gets the full command text (e.g., {@code /deploy production}). + * + * @return the full command text + */ + public String getCommand() { + return command; + } + + /** Sets the full command text. @param command the command text @return this */ + public CommandContext setCommand(String command) { + this.command = command; + return this; + } + + /** + * Gets the command name without the leading {@code /}. + * + * @return the command name + */ + public String getCommandName() { + return commandName; + } + + /** Sets the command name. @param commandName the command name @return this */ + public CommandContext setCommandName(String commandName) { + this.commandName = commandName; + return this; + } + + /** + * Gets the raw argument string after the command name. + * + * @return the argument string + */ + public String getArgs() { + return args; + } + + /** Sets the argument string. @param args the argument string @return this */ + public CommandContext setArgs(String args) { + this.args = args; + return this; + } +} diff --git a/src/main/java/com/github/copilot/sdk/json/CommandDefinition.java b/src/main/java/com/github/copilot/sdk/json/CommandDefinition.java new file mode 100644 index 000000000..33a6cbada --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/json/CommandDefinition.java @@ -0,0 +1,98 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk.json; + +/** + * Defines a slash command that users can invoke from the CLI TUI. + *

+ * Register commands via {@link SessionConfig#setCommands(java.util.List)} or + * {@link ResumeSessionConfig#setCommands(java.util.List)}. Each command appears + * as {@code /name} in the CLI TUI. + * + *

Example Usage

+ * + *
{@code
+ * var config = new SessionConfig().setCommands(List.of(
+ * 		new CommandDefinition().setName("deploy").setDescription("Deploy the application").setHandler(context -> {
+ * 			System.out.println("Deploying: " + context.getArgs());
+ * 			return CompletableFuture.completedFuture(null);
+ * 		})));
+ * }
+ * + * @see CommandHandler + * @see CommandContext + * @since 1.0.0 + */ +public class CommandDefinition { + + private String name; + private String description; + private CommandHandler handler; + + /** + * Gets the command name (without leading {@code /}). + * + * @return the command name + */ + public String getName() { + return name; + } + + /** + * Sets the command name (without leading {@code /}). + *

+ * For example, {@code "deploy"} registers the {@code /deploy} command. + * + * @param name + * the command name + * @return this instance for method chaining + */ + public CommandDefinition setName(String name) { + this.name = name; + return this; + } + + /** + * Gets the human-readable description shown in the command completion UI. + * + * @return the description, or {@code null} if not set + */ + public String getDescription() { + return description; + } + + /** + * Sets the human-readable description shown in the command completion UI. + * + * @param description + * the description + * @return this instance for method chaining + */ + public CommandDefinition setDescription(String description) { + this.description = description; + return this; + } + + /** + * Gets the handler invoked when the command is executed. + * + * @return the command handler + */ + public CommandHandler getHandler() { + return handler; + } + + /** + * Sets the handler invoked when the command is executed. + * + * @param handler + * the command handler + * @return this instance for method chaining + */ + public CommandDefinition setHandler(CommandHandler handler) { + this.handler = handler; + return this; + } +} diff --git a/src/main/java/com/github/copilot/sdk/json/CommandHandler.java b/src/main/java/com/github/copilot/sdk/json/CommandHandler.java new file mode 100644 index 000000000..d63955638 --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/json/CommandHandler.java @@ -0,0 +1,41 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk.json; + +import java.util.concurrent.CompletableFuture; + +/** + * Functional interface for handling slash-command executions. + *

+ * Implement this interface to define the behavior of a registered slash + * command. The handler is invoked when the user executes the command in the CLI + * TUI. + * + *

Example Usage

+ * + *
{@code
+ * CommandHandler deployHandler = context -> {
+ * 	System.out.println("Deploying with args: " + context.getArgs());
+ * 	// perform deployment...
+ * 	return CompletableFuture.completedFuture(null);
+ * };
+ * }
+ * + * @see CommandDefinition + * @since 1.0.0 + */ +@FunctionalInterface +public interface CommandHandler { + + /** + * Handles a slash-command execution. + * + * @param context + * the command context containing session ID, command text, and + * arguments + * @return a future that completes when the command handling is done + */ + CompletableFuture handle(CommandContext context); +} diff --git a/src/main/java/com/github/copilot/sdk/json/CommandWireDefinition.java b/src/main/java/com/github/copilot/sdk/json/CommandWireDefinition.java new file mode 100644 index 000000000..2ee65c58e --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/json/CommandWireDefinition.java @@ -0,0 +1,58 @@ +/*--------------------------------------------------------------------------------------------- + * 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; + +/** + * Wire-format representation of a command definition for RPC serialization. + *

+ * This is a low-level class used internally. Use {@link CommandDefinition} to + * define commands for a session. + * + * @since 1.0.0 + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public final class CommandWireDefinition { + + @JsonProperty("name") + private String name; + + @JsonProperty("description") + private String description; + + /** Creates an empty definition. */ + public CommandWireDefinition() { + } + + /** Creates a definition with name and description. */ + public CommandWireDefinition(String name, String description) { + this.name = name; + this.description = description; + } + + /** Gets the command name. @return the name */ + public String getName() { + return name; + } + + /** Sets the command name. @param name the name @return this */ + public CommandWireDefinition setName(String name) { + this.name = name; + return this; + } + + /** Gets the description. @return the description */ + public String getDescription() { + return description; + } + + /** Sets the description. @param description the description @return this */ + public CommandWireDefinition setDescription(String description) { + this.description = description; + return this; + } +} 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 c0243f14b..d030631de 100644 --- a/src/main/java/com/github/copilot/sdk/json/CreateSessionRequest.java +++ b/src/main/java/com/github/copilot/sdk/json/CreateSessionRequest.java @@ -91,6 +91,12 @@ public final class CreateSessionRequest { @JsonProperty("configDir") private String configDir; + @JsonProperty("commands") + private List commands; + + @JsonProperty("requestElicitation") + private Boolean requestElicitation; + /** Gets the model name. @return the model */ public String getModel() { return model; @@ -312,4 +318,24 @@ public String getConfigDir() { public void setConfigDir(String configDir) { this.configDir = configDir; } + + /** Gets the commands wire definitions. @return the commands */ + public List getCommands() { + return commands == null ? null : Collections.unmodifiableList(commands); + } + + /** Sets the commands wire definitions. @param commands the commands */ + public void setCommands(List commands) { + this.commands = commands; + } + + /** Gets the requestElicitation flag. @return the flag */ + public Boolean getRequestElicitation() { + return requestElicitation; + } + + /** Sets the requestElicitation flag. @param requestElicitation the flag */ + public void setRequestElicitation(Boolean requestElicitation) { + this.requestElicitation = requestElicitation; + } } diff --git a/src/main/java/com/github/copilot/sdk/json/CreateSessionResponse.java b/src/main/java/com/github/copilot/sdk/json/CreateSessionResponse.java index 5b1a177f0..b47af050b 100644 --- a/src/main/java/com/github/copilot/sdk/json/CreateSessionResponse.java +++ b/src/main/java/com/github/copilot/sdk/json/CreateSessionResponse.java @@ -11,9 +11,12 @@ * @param workspacePath * the workspace path, or {@code null} if infinite sessions are * disabled + * @param capabilities + * the capabilities reported by the host, or {@code null} * @since 1.0.0 */ @JsonInclude(JsonInclude.Include.NON_NULL) public record CreateSessionResponse(@JsonProperty("sessionId") String sessionId, - @JsonProperty("workspacePath") String workspacePath) { + @JsonProperty("workspacePath") String workspacePath, + @JsonProperty("capabilities") SessionCapabilities capabilities) { } diff --git a/src/main/java/com/github/copilot/sdk/json/ElicitationContext.java b/src/main/java/com/github/copilot/sdk/json/ElicitationContext.java new file mode 100644 index 000000000..87687b194 --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/json/ElicitationContext.java @@ -0,0 +1,112 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk.json; + +/** + * Context for an elicitation request received from the server or MCP tools. + * + * @since 1.0.0 + */ +public class ElicitationContext { + + private String sessionId; + private String message; + private ElicitationSchema requestedSchema; + private String mode; + private String elicitationSource; + private String url; + + /** + * Gets the session ID that triggered the elicitation request. @return the + * session ID + */ + public String getSessionId() { + return sessionId; + } + + /** Sets the session ID. @param sessionId the session ID @return this */ + public ElicitationContext setSessionId(String sessionId) { + this.sessionId = sessionId; + return this; + } + + /** + * Gets the message describing what information is needed from the user. + * + * @return the message + */ + public String getMessage() { + return message; + } + + /** Sets the message. @param message the message @return this */ + public ElicitationContext setMessage(String message) { + this.message = message; + return this; + } + + /** + * Gets the JSON Schema describing the form fields to present (form mode only). + * + * @return the schema, or {@code null} + */ + public ElicitationSchema getRequestedSchema() { + return requestedSchema; + } + + /** Sets the schema. @param requestedSchema the schema @return this */ + public ElicitationContext setRequestedSchema(ElicitationSchema requestedSchema) { + this.requestedSchema = requestedSchema; + return this; + } + + /** + * Gets the elicitation mode: {@code "form"} for structured input, {@code "url"} + * for browser redirect. + * + * @return the mode, or {@code null} (defaults to {@code "form"}) + */ + public String getMode() { + return mode; + } + + /** Sets the mode. @param mode the mode @return this */ + public ElicitationContext setMode(String mode) { + this.mode = mode; + return this; + } + + /** + * Gets the source that initiated the request (e.g., MCP server name). + * + * @return the elicitation source, or {@code null} + */ + public String getElicitationSource() { + return elicitationSource; + } + + /** + * Sets the elicitation source. @param elicitationSource the source @return this + */ + public ElicitationContext setElicitationSource(String elicitationSource) { + this.elicitationSource = elicitationSource; + return this; + } + + /** + * Gets the URL to open in the user's browser (url mode only). + * + * @return the URL, or {@code null} + */ + public String getUrl() { + return url; + } + + /** Sets the URL. @param url the URL @return this */ + public ElicitationContext setUrl(String url) { + this.url = url; + return this; + } +} diff --git a/src/main/java/com/github/copilot/sdk/json/ElicitationHandler.java b/src/main/java/com/github/copilot/sdk/json/ElicitationHandler.java new file mode 100644 index 000000000..d0a0d0616 --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/json/ElicitationHandler.java @@ -0,0 +1,44 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk.json; + +import java.util.concurrent.CompletableFuture; + +/** + * Functional interface for handling elicitation requests from the server. + *

+ * Register an elicitation handler via + * {@link SessionConfig#setOnElicitationRequest(ElicitationHandler)} or + * {@link ResumeSessionConfig#setOnElicitationRequest(ElicitationHandler)}. When + * provided, the server routes elicitation requests to this handler and reports + * elicitation as a supported capability. + * + *

Example Usage

+ * + *
{@code
+ * ElicitationHandler handler = context -> {
+ * 	// Show the form to the user and collect responses
+ * 	Map formValues = showForm(context.getMessage(), context.getRequestedSchema());
+ * 	return CompletableFuture.completedFuture(
+ * 			new ElicitationResult().setAction(ElicitationResultAction.ACCEPT).setContent(formValues));
+ * };
+ * }
+ * + * @see ElicitationContext + * @see ElicitationResult + * @since 1.0.0 + */ +@FunctionalInterface +public interface ElicitationHandler { + + /** + * Handles an elicitation request from the server. + * + * @param context + * the elicitation context containing the message, schema, and mode + * @return a future that resolves with the elicitation result + */ + CompletableFuture handle(ElicitationContext context); +} diff --git a/src/main/java/com/github/copilot/sdk/json/ElicitationParams.java b/src/main/java/com/github/copilot/sdk/json/ElicitationParams.java new file mode 100644 index 000000000..8bd81022e --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/json/ElicitationParams.java @@ -0,0 +1,58 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk.json; + +/** + * Parameters for an elicitation request sent from the SDK to the host. + * + * @since 1.0.0 + */ +public class ElicitationParams { + + private String message; + private ElicitationSchema requestedSchema; + + /** + * Gets the message describing what information is needed from the user. + * + * @return the message + */ + public String getMessage() { + return message; + } + + /** + * Sets the message describing what information is needed from the user. + * + * @param message + * the message + * @return this instance for method chaining + */ + public ElicitationParams setMessage(String message) { + this.message = message; + return this; + } + + /** + * Gets the JSON Schema describing the form fields to present. + * + * @return the requested schema + */ + public ElicitationSchema getRequestedSchema() { + return requestedSchema; + } + + /** + * Sets the JSON Schema describing the form fields to present. + * + * @param requestedSchema + * the schema + * @return this instance for method chaining + */ + public ElicitationParams setRequestedSchema(ElicitationSchema requestedSchema) { + this.requestedSchema = requestedSchema; + return this; + } +} diff --git a/src/main/java/com/github/copilot/sdk/json/ElicitationResult.java b/src/main/java/com/github/copilot/sdk/json/ElicitationResult.java new file mode 100644 index 000000000..3ba30b83d --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/json/ElicitationResult.java @@ -0,0 +1,68 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk.json; + +import java.util.Map; + +/** + * Result returned from an elicitation dialog. + * + * @since 1.0.0 + */ +public class ElicitationResult { + + private ElicitationResultAction action; + private Map content; + + /** + * Gets the user action taken on the elicitation dialog. + *

+ * {@link ElicitationResultAction#ACCEPT} means the user submitted the form, + * {@link ElicitationResultAction#DECLINE} means the user rejected the request, + * and {@link ElicitationResultAction#CANCEL} means the user dismissed the + * dialog. + * + * @return the user action + */ + public ElicitationResultAction getAction() { + return action; + } + + /** + * Sets the user action taken on the elicitation dialog. + * + * @param action + * the user action + * @return this instance for method chaining + */ + public ElicitationResult setAction(ElicitationResultAction action) { + this.action = action; + return this; + } + + /** + * Gets the form values submitted by the user. + *

+ * Only present when {@link #getAction()} is + * {@link ElicitationResultAction#ACCEPT}. + * + * @return the submitted form values, or {@code null} if the user did not accept + */ + public Map getContent() { + return content; + } + + /** + * Sets the form values submitted by the user. + * + * @param content + * the submitted form values + * @return this instance for method chaining + */ + public ElicitationResult setContent(Map content) { + this.content = content; + return this; + } +} diff --git a/src/main/java/com/github/copilot/sdk/json/ElicitationResultAction.java b/src/main/java/com/github/copilot/sdk/json/ElicitationResultAction.java new file mode 100644 index 000000000..fd280cdeb --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/json/ElicitationResultAction.java @@ -0,0 +1,33 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk.json; + +/** + * Action value for an {@link ElicitationResult}. + * + * @since 1.0.0 + */ +public enum ElicitationResultAction { + + /** The user submitted the form (accepted). */ + ACCEPT("accept"), + + /** The user explicitly rejected the request. */ + DECLINE("decline"), + + /** The user dismissed the dialog without responding. */ + CANCEL("cancel"); + + private final String value; + + ElicitationResultAction(String value) { + this.value = value; + } + + /** Returns the wire-format string value. @return the string value */ + public String getValue() { + return value; + } +} diff --git a/src/main/java/com/github/copilot/sdk/json/ElicitationSchema.java b/src/main/java/com/github/copilot/sdk/json/ElicitationSchema.java new file mode 100644 index 000000000..c3d548775 --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/json/ElicitationSchema.java @@ -0,0 +1,92 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk.json; + +import java.util.List; +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * JSON Schema describing the form fields to present for an elicitation dialog. + * + * @since 1.0.0 + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class ElicitationSchema { + + @JsonProperty("type") + private String type = "object"; + + @JsonProperty("properties") + private Map properties; + + @JsonProperty("required") + private List required; + + /** + * Gets the schema type indicator (always {@code "object"}). + * + * @return the type + */ + public String getType() { + return type; + } + + /** + * Sets the schema type indicator. + * + * @param type + * the type (typically {@code "object"}) + * @return this instance for method chaining + */ + public ElicitationSchema setType(String type) { + this.type = type; + return this; + } + + /** + * Gets the form field definitions, keyed by field name. + * + * @return the properties map + */ + public Map getProperties() { + return properties; + } + + /** + * Sets the form field definitions, keyed by field name. + * + * @param properties + * the properties map + * @return this instance for method chaining + */ + public ElicitationSchema setProperties(Map properties) { + this.properties = properties; + return this; + } + + /** + * Gets the list of required field names. + * + * @return the required field names, or {@code null} + */ + public List getRequired() { + return required; + } + + /** + * Sets the list of required field names. + * + * @param required + * the required field names + * @return this instance for method chaining + */ + public ElicitationSchema setRequired(List required) { + this.required = required; + return this; + } +} diff --git a/src/main/java/com/github/copilot/sdk/json/GetSessionMetadataResponse.java b/src/main/java/com/github/copilot/sdk/json/GetSessionMetadataResponse.java new file mode 100644 index 000000000..eeceb4177 --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/json/GetSessionMetadataResponse.java @@ -0,0 +1,19 @@ +/*--------------------------------------------------------------------------------------------- + * 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; + +/** + * Internal response object from getting session metadata by ID. + * + * @param session + * the session metadata, or {@code null} if not found + * @since 1.0.0 + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public record GetSessionMetadataResponse(@JsonProperty("session") SessionMetadata session) { +} diff --git a/src/main/java/com/github/copilot/sdk/json/InputOptions.java b/src/main/java/com/github/copilot/sdk/json/InputOptions.java new file mode 100644 index 000000000..9b0b6c8dd --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/json/InputOptions.java @@ -0,0 +1,108 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk.json; + +/** + * Options for the {@link SessionUiApi#input(String, InputOptions)} convenience + * method. + * + * @since 1.0.0 + */ +public class InputOptions { + + private String title; + private String description; + private Integer minLength; + private Integer maxLength; + private String format; + private String defaultValue; + + /** Gets the title label for the input field. @return the title */ + public String getTitle() { + return title; + } + + /** + * Sets the title label for the input field. @param title the title @return this + */ + public InputOptions setTitle(String title) { + this.title = title; + return this; + } + + /** Gets the descriptive text shown below the field. @return the description */ + public String getDescription() { + return description; + } + + /** + * Sets the descriptive text shown below the field. @param description the + * description @return this + */ + public InputOptions setDescription(String description) { + this.description = description; + return this; + } + + /** Gets the minimum character length. @return the min length */ + public Integer getMinLength() { + return minLength; + } + + /** + * Sets the minimum character length. @param minLength the min length @return + * this + */ + public InputOptions setMinLength(Integer minLength) { + this.minLength = minLength; + return this; + } + + /** Gets the maximum character length. @return the max length */ + public Integer getMaxLength() { + return maxLength; + } + + /** + * Sets the maximum character length. @param maxLength the max length @return + * this + */ + public InputOptions setMaxLength(Integer maxLength) { + this.maxLength = maxLength; + return this; + } + + /** + * Gets the semantic format hint (e.g., {@code "email"}, {@code "uri"}, + * {@code "date"}, {@code "date-time"}). + * + * @return the format hint + */ + public String getFormat() { + return format; + } + + /** Sets the semantic format hint. @param format the format @return this */ + public InputOptions setFormat(String format) { + this.format = format; + return this; + } + + /** + * Gets the default value pre-populated in the field. @return the default value + */ + public String getDefaultValue() { + return defaultValue; + } + + /** + * Sets the default value pre-populated in the field. @param defaultValue the + * default value @return this + */ + public InputOptions setDefaultValue(String defaultValue) { + this.defaultValue = defaultValue; + return this; + } +} diff --git a/src/main/java/com/github/copilot/sdk/json/ResumeSessionConfig.java b/src/main/java/com/github/copilot/sdk/json/ResumeSessionConfig.java index eab3c789c..139f5238b 100644 --- a/src/main/java/com/github/copilot/sdk/json/ResumeSessionConfig.java +++ b/src/main/java/com/github/copilot/sdk/json/ResumeSessionConfig.java @@ -58,6 +58,8 @@ public class ResumeSessionConfig { private List disabledSkills; private InfiniteSessionConfig infiniteSessions; private Consumer onEvent; + private List commands; + private ElicitationHandler onElicitationRequest; /** * Gets the AI model to use. @@ -555,6 +557,56 @@ public ResumeSessionConfig setOnEvent(Consumer onEvent) { return this; } + /** + * Gets the slash commands registered for this session. + * + * @return the list of command definitions, or {@code null} + */ + public List getCommands() { + return commands == null ? null : Collections.unmodifiableList(commands); + } + + /** + * Sets slash commands registered for this session. + *

+ * When the CLI has a TUI, each command appears as {@code /name} for the user to + * invoke. The handler is called when the user executes the command. + * + * @param commands + * the list of command definitions + * @return this config for method chaining + * @see CommandDefinition + */ + public ResumeSessionConfig setCommands(List commands) { + this.commands = commands; + return this; + } + + /** + * Gets the elicitation request handler. + * + * @return the elicitation handler, or {@code null} + */ + public ElicitationHandler getOnElicitationRequest() { + return onElicitationRequest; + } + + /** + * Sets a handler for elicitation requests from the server or MCP tools. + *

+ * When provided, the server will route elicitation requests to this handler and + * report elicitation as a supported capability. + * + * @param onElicitationRequest + * the elicitation handler + * @return this config for method chaining + * @see ElicitationHandler + */ + public ResumeSessionConfig setOnElicitationRequest(ElicitationHandler onElicitationRequest) { + this.onElicitationRequest = onElicitationRequest; + return this; + } + /** * Creates a shallow clone of this {@code ResumeSessionConfig} instance. *

@@ -591,6 +643,8 @@ public ResumeSessionConfig clone() { copy.disabledSkills = this.disabledSkills != null ? new ArrayList<>(this.disabledSkills) : null; copy.infiniteSessions = this.infiniteSessions; copy.onEvent = this.onEvent; + copy.commands = this.commands != null ? new ArrayList<>(this.commands) : null; + copy.onElicitationRequest = this.onElicitationRequest; return copy; } } 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 31d88399a..7be9a6281 100644 --- a/src/main/java/com/github/copilot/sdk/json/ResumeSessionRequest.java +++ b/src/main/java/com/github/copilot/sdk/json/ResumeSessionRequest.java @@ -95,6 +95,12 @@ public final class ResumeSessionRequest { @JsonProperty("infiniteSessions") private InfiniteSessionConfig infiniteSessions; + @JsonProperty("commands") + private List commands; + + @JsonProperty("requestElicitation") + private Boolean requestElicitation; + /** Gets the session ID. @return the session ID */ public String getSessionId() { return sessionId; @@ -332,4 +338,24 @@ public InfiniteSessionConfig getInfiniteSessions() { public void setInfiniteSessions(InfiniteSessionConfig infiniteSessions) { this.infiniteSessions = infiniteSessions; } + + /** Gets the commands wire definitions. @return the commands */ + public List getCommands() { + return commands == null ? null : Collections.unmodifiableList(commands); + } + + /** Sets the commands wire definitions. @param commands the commands */ + public void setCommands(List commands) { + this.commands = commands; + } + + /** Gets the requestElicitation flag. @return the flag */ + public Boolean getRequestElicitation() { + return requestElicitation; + } + + /** Sets the requestElicitation flag. @param requestElicitation the flag */ + public void setRequestElicitation(Boolean requestElicitation) { + this.requestElicitation = requestElicitation; + } } diff --git a/src/main/java/com/github/copilot/sdk/json/ResumeSessionResponse.java b/src/main/java/com/github/copilot/sdk/json/ResumeSessionResponse.java index 654c1486c..8349c5d30 100644 --- a/src/main/java/com/github/copilot/sdk/json/ResumeSessionResponse.java +++ b/src/main/java/com/github/copilot/sdk/json/ResumeSessionResponse.java @@ -11,9 +11,12 @@ * @param workspacePath * the workspace path, or {@code null} if infinite sessions are * disabled + * @param capabilities + * the capabilities reported by the host, or {@code null} * @since 1.0.0 */ @JsonInclude(JsonInclude.Include.NON_NULL) public record ResumeSessionResponse(@JsonProperty("sessionId") String sessionId, - @JsonProperty("workspacePath") String workspacePath) { + @JsonProperty("workspacePath") String workspacePath, + @JsonProperty("capabilities") SessionCapabilities capabilities) { } diff --git a/src/main/java/com/github/copilot/sdk/json/SessionCapabilities.java b/src/main/java/com/github/copilot/sdk/json/SessionCapabilities.java new file mode 100644 index 000000000..4eb4fc025 --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/json/SessionCapabilities.java @@ -0,0 +1,39 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk.json; + +/** + * Represents the capabilities reported by the host for a session. + *

+ * Capabilities are populated from the session create/resume response and + * updated in real time via {@code capabilities.changed} events. + * + * @since 1.0.0 + */ +public class SessionCapabilities { + + private SessionUiCapabilities ui; + + /** + * Gets the UI-related capabilities. + * + * @return the UI capabilities, or {@code null} if not reported + */ + public SessionUiCapabilities getUi() { + return ui; + } + + /** + * Sets the UI-related capabilities. + * + * @param ui + * the UI capabilities + * @return this instance for method chaining + */ + public SessionCapabilities setUi(SessionUiCapabilities ui) { + this.ui = ui; + return this; + } +} diff --git a/src/main/java/com/github/copilot/sdk/json/SessionConfig.java b/src/main/java/com/github/copilot/sdk/json/SessionConfig.java index 76c15660d..5dcd39788 100644 --- a/src/main/java/com/github/copilot/sdk/json/SessionConfig.java +++ b/src/main/java/com/github/copilot/sdk/json/SessionConfig.java @@ -58,6 +58,8 @@ public class SessionConfig { private List disabledSkills; private String configDir; private Consumer onEvent; + private List commands; + private ElicitationHandler onElicitationRequest; /** * Gets the custom session ID. @@ -595,6 +597,56 @@ public SessionConfig setOnEvent(Consumer onEvent) { return this; } + /** + * Gets the slash commands registered for this session. + * + * @return the list of command definitions, or {@code null} + */ + public List getCommands() { + return commands == null ? null : Collections.unmodifiableList(commands); + } + + /** + * Sets slash commands registered for this session. + *

+ * When the CLI has a TUI, each command appears as {@code /name} for the user to + * invoke. The handler is called when the user executes the command. + * + * @param commands + * the list of command definitions + * @return this config instance for method chaining + * @see CommandDefinition + */ + public SessionConfig setCommands(List commands) { + this.commands = commands; + return this; + } + + /** + * Gets the elicitation request handler. + * + * @return the elicitation handler, or {@code null} + */ + public ElicitationHandler getOnElicitationRequest() { + return onElicitationRequest; + } + + /** + * Sets a handler for elicitation requests from the server or MCP tools. + *

+ * When provided, the server will route elicitation requests to this handler and + * report elicitation as a supported capability. + * + * @param onElicitationRequest + * the elicitation handler + * @return this config instance for method chaining + * @see ElicitationHandler + */ + public SessionConfig setOnElicitationRequest(ElicitationHandler onElicitationRequest) { + this.onElicitationRequest = onElicitationRequest; + return this; + } + /** * Creates a shallow clone of this {@code SessionConfig} instance. *

@@ -631,6 +683,8 @@ public SessionConfig clone() { copy.disabledSkills = this.disabledSkills != null ? new ArrayList<>(this.disabledSkills) : null; copy.configDir = this.configDir; copy.onEvent = this.onEvent; + copy.commands = this.commands != null ? new ArrayList<>(this.commands) : null; + copy.onElicitationRequest = this.onElicitationRequest; return copy; } } diff --git a/src/main/java/com/github/copilot/sdk/json/SessionUiApi.java b/src/main/java/com/github/copilot/sdk/json/SessionUiApi.java new file mode 100644 index 000000000..f0a43f261 --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/json/SessionUiApi.java @@ -0,0 +1,86 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk.json; + +import java.util.concurrent.CompletableFuture; + +/** + * Provides UI methods for eliciting information from the user during a session. + *

+ * All methods on this interface throw {@link IllegalStateException} if the host + * does not report elicitation support via + * {@link com.github.copilot.sdk.CopilotSession#getCapabilities()}. Check + * {@code session.getCapabilities().getUi() != null && + * Boolean.TRUE.equals(session.getCapabilities().getUi().getElicitation())} + * before calling. + * + *

Example Usage

+ * + *
{@code
+ * var caps = session.getCapabilities();
+ * if (caps.getUi() != null && Boolean.TRUE.equals(caps.getUi().getElicitation())) {
+ * 	boolean confirmed = session.getUi().confirm("Are you sure?").get();
+ * }
+ * }
+ * + * @see com.github.copilot.sdk.CopilotSession#getUi() + * @since 1.0.0 + */ +public interface SessionUiApi { + + /** + * Shows a generic elicitation dialog with a custom schema. + * + * @param params + * the elicitation parameters including message and schema + * @return a future that resolves with the {@link ElicitationResult} + * @throws IllegalStateException + * if the host does not support elicitation + */ + CompletableFuture elicitation(ElicitationParams params); + + /** + * Shows a confirmation dialog and returns the user's boolean answer. + *

+ * Returns {@code false} if the user declines or cancels. + * + * @param message + * the message to display + * @return a future that resolves to {@code true} if the user confirmed + * @throws IllegalStateException + * if the host does not support elicitation + */ + CompletableFuture confirm(String message); + + /** + * Shows a selection dialog with the given options. + *

+ * Returns the selected value, or {@code null} if the user declines/cancels. + * + * @param message + * the message to display + * @param options + * the options to present + * @return a future that resolves to the selected string, or {@code null} + * @throws IllegalStateException + * if the host does not support elicitation + */ + CompletableFuture select(String message, String[] options); + + /** + * Shows a text input dialog. + *

+ * Returns the entered text, or {@code null} if the user declines/cancels. + * + * @param message + * the message to display + * @param options + * optional input field options, or {@code null} + * @return a future that resolves to the entered string, or {@code null} + * @throws IllegalStateException + * if the host does not support elicitation + */ + CompletableFuture input(String message, InputOptions options); +} diff --git a/src/main/java/com/github/copilot/sdk/json/SessionUiCapabilities.java b/src/main/java/com/github/copilot/sdk/json/SessionUiCapabilities.java new file mode 100644 index 000000000..9b8e0b587 --- /dev/null +++ b/src/main/java/com/github/copilot/sdk/json/SessionUiCapabilities.java @@ -0,0 +1,37 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk.json; + +/** + * UI-specific capability flags for a session. + * + * @since 1.0.0 + */ +public class SessionUiCapabilities { + + private Boolean elicitation; + + /** + * Returns whether the host supports interactive elicitation dialogs. + * + * @return {@code true} if elicitation is supported, {@code false} or + * {@code null} otherwise + */ + public Boolean getElicitation() { + return elicitation; + } + + /** + * Sets whether the host supports interactive elicitation dialogs. + * + * @param elicitation + * {@code true} if elicitation is supported + * @return this instance for method chaining + */ + public SessionUiCapabilities setElicitation(Boolean elicitation) { + this.elicitation = elicitation; + return this; + } +} diff --git a/src/site/markdown/advanced.md b/src/site/markdown/advanced.md index bc9302840..5ae5c8f94 100644 --- a/src/site/markdown/advanced.md +++ b/src/site/markdown/advanced.md @@ -47,6 +47,13 @@ This guide covers advanced scenarios for extending and customizing your Copilot - [Custom Event Error Handler](#Custom_Event_Error_Handler) - [Event Error Policy](#Event_Error_Policy) - [OpenTelemetry](#OpenTelemetry) +- [Slash Commands](#Slash_Commands) + - [Registering Commands](#Registering_Commands) +- [Elicitation (UI Dialogs)](#Elicitation_UI_Dialogs) + - [Incoming Elicitation Handler](#Incoming_Elicitation_Handler) + - [Session Capabilities](#Session_Capabilities) + - [Outgoing Elicitation via session.getUi()](#Outgoing_Elicitation_via_session.getUi) +- [Getting Session Metadata by ID](#Getting_Session_Metadata_by_ID) --- @@ -1093,6 +1100,143 @@ See [TelemetryConfig](apidocs/com/github/copilot/sdk/json/TelemetryConfig.html) --- +## Slash Commands + +Register custom slash commands that users can invoke from the CLI TUI with `/commandname`. + +### Registering Commands + +```java +var config = new SessionConfig() + .setOnPermissionRequest(PermissionHandler.APPROVE_ALL) + .setCommands(List.of( + new CommandDefinition() + .setName("deploy") + .setDescription("Deploy the current branch") + .setHandler(context -> { + System.out.println("Deploying with args: " + context.getArgs()); + // perform deployment ... + return CompletableFuture.completedFuture(null); + }), + new CommandDefinition() + .setName("rollback") + .setDescription("Roll back the last deployment") + .setHandler(context -> { + // perform rollback ... + return CompletableFuture.completedFuture(null); + }) + )); + +try (CopilotClient client = new CopilotClient()) { + client.start().get(); + var session = client.createSession(config).get(); + // Users can now type /deploy or /rollback in the TUI +} +``` + +Each `CommandDefinition` requires a `name` (without the leading `/`), an optional `description` shown in the TUI's command completion UI, and a `CommandHandler` that is invoked when the user executes the command. + +The `CommandContext` passed to the handler provides: +- `getSessionId()` — the ID of the session where the command was invoked +- `getCommand()` — the full command text (e.g., `/deploy production`) +- `getCommandName()` — command name without the leading `/` (e.g., `deploy`) +- `getArgs()` — the argument string after the command name (e.g., `production`) + +--- + +## Elicitation (UI Dialogs) + +Elicitation allows your application to present structured UI dialogs to the user. There are two directions: + +1. **Incoming** — The server or an MCP tool requests input from the user via your `onElicitationRequest` handler. +2. **Outgoing** — Your session-side code proactively requests input via `session.getUi()`. + +### Incoming Elicitation Handler + +Register a handler to receive elicitation requests from the server: + +```java +var config = new SessionConfig() + .setOnPermissionRequest(PermissionHandler.APPROVE_ALL) + .setOnElicitationRequest(context -> { + System.out.println("Elicitation request: " + context.getMessage()); + // Show the form to the user ... + var content = Map.of("confirmed", true); + return CompletableFuture.completedFuture( + new ElicitationResult() + .setAction(ElicitationResultAction.ACCEPT) + .setContent(content) + ); + }); +``` + +When `onElicitationRequest` is set, the SDK reports elicitation as a supported capability and the server will route elicitation requests to your handler. + +### Session Capabilities + +After `createSession` or `resumeSession`, check `session.getCapabilities()` to see what the host supports: + +```java +var session = client.createSession(config).get(); + +var caps = session.getCapabilities(); +if (caps.getUi() != null && Boolean.TRUE.equals(caps.getUi().getElicitation())) { + System.out.println("Elicitation is supported"); +} +``` + +Capabilities are updated in real time when a `capabilities.changed` event is received. + +### Outgoing Elicitation via `session.getUi()` + +If the host reports elicitation support, you can call the convenience methods on `session.getUi()`: + +```java +var ui = session.getUi(); + +// Boolean confirmation +boolean confirmed = ui.confirm("Are you sure you want to proceed?").get(); + +// Selection from options +String choice = ui.select("Choose an environment", new String[]{"dev", "staging", "prod"}).get(); + +// Text input +String value = ui.input("Enter your name", null).get(); + +// Custom schema +var result = ui.elicitation(new ElicitationParams() + .setMessage("Enter deployment details") + .setRequestedSchema(new ElicitationSchema() + .setProperties(Map.of( + "branch", Map.of("type", "string"), + "environment", Map.of("type", "string", "enum", List.of("dev", "staging", "prod")) + )) + .setRequired(List.of("branch", "environment")) + )).get(); +``` + +All `getUi()` methods throw `IllegalStateException` if the host does not support elicitation. Always check capabilities first. + +--- + +## Getting Session Metadata by ID + +Retrieve metadata for a specific session without listing all sessions: + +```java +SessionMetadata metadata = client.getSessionMetadata("session-123").get(); +if (metadata != null) { + System.out.println("Session: " + metadata.getSessionId()); + System.out.println("Started: " + metadata.getStartTime()); +} else { + System.out.println("Session not found"); +} +``` + +This is more efficient than `listSessions()` when you already know the session ID, as it performs a direct O(1) lookup instead of scanning all sessions. + +--- + ## Next Steps - 📖 **[Documentation](documentation.html)** - Core concepts, events, streaming, models, tool filtering, reasoning effort diff --git a/src/site/markdown/cookbook/error-handling.md b/src/site/markdown/cookbook/error-handling.md index 5ee5ef2ca..4240dc1ff 100644 --- a/src/site/markdown/cookbook/error-handling.md +++ b/src/site/markdown/cookbook/error-handling.md @@ -30,7 +30,7 @@ jbang BasicErrorHandling.java **Code:** ```java -//DEPS com.github:copilot-sdk-java:0.2.1-java.1 +//DEPS com.github:copilot-sdk-java:0.2.2-java.1 import com.github.copilot.sdk.CopilotClient; import com.github.copilot.sdk.events.AssistantMessageEvent; import com.github.copilot.sdk.json.MessageOptions; @@ -64,7 +64,7 @@ public class BasicErrorHandling { ## Handling specific error types ```java -//DEPS com.github:copilot-sdk-java:0.2.1-java.1 +//DEPS com.github:copilot-sdk-java:0.2.2-java.1 import com.github.copilot.sdk.CopilotClient; import java.util.concurrent.ExecutionException; @@ -99,7 +99,7 @@ public class SpecificErrorHandling { ## Timeout handling ```java -//DEPS com.github:copilot-sdk-java:0.2.1-java.1 +//DEPS com.github:copilot-sdk-java:0.2.2-java.1 import com.github.copilot.sdk.CopilotSession; import com.github.copilot.sdk.events.AssistantMessageEvent; import com.github.copilot.sdk.json.MessageOptions; @@ -130,7 +130,7 @@ public class TimeoutHandling { ## Aborting a request ```java -//DEPS com.github:copilot-sdk-java:0.2.1-java.1 +//DEPS com.github:copilot-sdk-java:0.2.2-java.1 import com.github.copilot.sdk.CopilotSession; import com.github.copilot.sdk.json.MessageOptions; import java.util.concurrent.Executors; @@ -162,7 +162,7 @@ public class AbortRequest { ## Graceful shutdown ```java -//DEPS com.github:copilot-sdk-java:0.2.1-java.1 +//DEPS com.github:copilot-sdk-java:0.2.2-java.1 import com.github.copilot.sdk.CopilotClient; public class GracefulShutdown { @@ -192,7 +192,7 @@ public class GracefulShutdown { ## Try-with-resources pattern ```java -//DEPS com.github:copilot-sdk-java:0.2.1-java.1 +//DEPS com.github:copilot-sdk-java:0.2.2-java.1 import com.github.copilot.sdk.CopilotClient; import com.github.copilot.sdk.events.AssistantMessageEvent; import com.github.copilot.sdk.json.MessageOptions; @@ -224,7 +224,7 @@ public class TryWithResources { ## Handling tool errors ```java -//DEPS com.github:copilot-sdk-java:0.2.1-java.1 +//DEPS com.github:copilot-sdk-java:0.2.2-java.1 import com.github.copilot.sdk.CopilotClient; import com.github.copilot.sdk.events.AssistantMessageEvent; import com.github.copilot.sdk.json.MessageOptions; diff --git a/src/site/markdown/cookbook/managing-local-files.md b/src/site/markdown/cookbook/managing-local-files.md index aa9ba23bc..9535772b2 100644 --- a/src/site/markdown/cookbook/managing-local-files.md +++ b/src/site/markdown/cookbook/managing-local-files.md @@ -34,7 +34,7 @@ jbang ManagingLocalFiles.java **Code:** ```java -//DEPS com.github:copilot-sdk-java:0.2.1-java.1 +//DEPS com.github:copilot-sdk-java:0.2.2-java.1 import com.github.copilot.sdk.CopilotClient; import com.github.copilot.sdk.events.AssistantMessageEvent; import com.github.copilot.sdk.events.SessionIdleEvent; @@ -161,7 +161,7 @@ session.send(new MessageOptions().setPrompt(prompt)); ## Interactive file organization ```java -//DEPS com.github:copilot-sdk-java:0.2.1-java.1 +//DEPS com.github:copilot-sdk-java:0.2.2-java.1 import java.io.BufferedReader; import java.io.InputStreamReader; diff --git a/src/site/markdown/cookbook/multiple-sessions.md b/src/site/markdown/cookbook/multiple-sessions.md index fe5c2f0d9..776b6db6d 100644 --- a/src/site/markdown/cookbook/multiple-sessions.md +++ b/src/site/markdown/cookbook/multiple-sessions.md @@ -30,7 +30,7 @@ jbang MultipleSessions.java **Code:** ```java -//DEPS com.github:copilot-sdk-java:0.2.1-java.1 +//DEPS com.github:copilot-sdk-java:0.2.2-java.1 import com.github.copilot.sdk.CopilotClient; import com.github.copilot.sdk.events.AssistantMessageEvent; import com.github.copilot.sdk.json.MessageOptions; @@ -123,7 +123,7 @@ try { ## Managing session lifecycle with CompletableFuture ```java -//DEPS com.github:copilot-sdk-java:0.2.1-java.1 +//DEPS com.github:copilot-sdk-java:0.2.2-java.1 import java.util.concurrent.CompletableFuture; import java.util.List; diff --git a/src/site/markdown/cookbook/persisting-sessions.md b/src/site/markdown/cookbook/persisting-sessions.md index e3fd11b13..e653b8a6a 100644 --- a/src/site/markdown/cookbook/persisting-sessions.md +++ b/src/site/markdown/cookbook/persisting-sessions.md @@ -30,7 +30,7 @@ jbang PersistingSessions.java **Code:** ```java -//DEPS com.github:copilot-sdk-java:0.2.1-java.1 +//DEPS com.github:copilot-sdk-java:0.2.2-java.1 import com.github.copilot.sdk.CopilotClient; import com.github.copilot.sdk.events.AssistantMessageEvent; import com.github.copilot.sdk.json.MessageOptions; @@ -127,7 +127,7 @@ public class DeleteSession { ## Getting session history ```java -//DEPS com.github:copilot-sdk-java:0.2.1-java.1 +//DEPS com.github:copilot-sdk-java:0.2.2-java.1 import com.github.copilot.sdk.CopilotClient; import com.github.copilot.sdk.events.AssistantMessageEvent; import com.github.copilot.sdk.events.UserMessageEvent; @@ -162,7 +162,7 @@ public class SessionHistory { ## Complete example with session management ```java -//DEPS com.github:copilot-sdk-java:0.2.1-java.1 +//DEPS com.github:copilot-sdk-java:0.2.2-java.1 import java.util.Scanner; public class SessionManager { diff --git a/src/site/markdown/cookbook/pr-visualization.md b/src/site/markdown/cookbook/pr-visualization.md index dbd240a40..ad2939842 100644 --- a/src/site/markdown/cookbook/pr-visualization.md +++ b/src/site/markdown/cookbook/pr-visualization.md @@ -34,7 +34,7 @@ jbang PRVisualization.java github/copilot-sdk ## Full example: PRVisualization.java ```java -//DEPS com.github:copilot-sdk-java:0.2.1-java.1 +//DEPS com.github:copilot-sdk-java:0.2.2-java.1 import com.github.copilot.sdk.CopilotClient; import com.github.copilot.sdk.events.AssistantMessageEvent; import com.github.copilot.sdk.events.ToolExecutionStartEvent; diff --git a/src/site/markdown/documentation.md b/src/site/markdown/documentation.md index 8a9f919ac..a96f66698 100644 --- a/src/site/markdown/documentation.md +++ b/src/site/markdown/documentation.md @@ -245,6 +245,19 @@ The SDK supports event types organized by category. All events extend `AbstractS |-------|-------------|-------------| | `CommandQueuedEvent` | `command.queued` | A command was queued for execution | | `CommandCompletedEvent` | `command.completed` | A queued command completed | +| `CommandExecuteEvent` | `command.execute` | A registered slash command was dispatched for execution | + +### Elicitation Events + +| Event | Type String | Description | +|-------|-------------|-------------| +| `ElicitationRequestedEvent` | `elicitation.requested` | An elicitation (UI dialog) request was received | + +### Capability Events + +| Event | Type String | Description | +|-------|-------------|-------------| +| `CapabilitiesChangedEvent` | `capabilities.changed` | Session capabilities were updated | ### Plan Mode Events @@ -633,6 +646,8 @@ When resuming a session, you can optionally reconfigure many settings. This is u | `skillDirectories` | Directories to load skills from | | `disabledSkills` | Skills to disable | | `infiniteSessions` | Configure infinite session behavior | +| `commands` | Slash command definitions for the resumed session | +| `onElicitationRequest` | Handler for incoming elicitation requests | | `disableResume` | When `true`, resumes without emitting a `session.resume` event | | `onEvent` | Event handler registered before session resumption | @@ -691,6 +706,8 @@ Complete list of all `SessionConfig` options for `createSession()`: | `skillDirectories` | List<String> | Directories to load skills from | [Skills](advanced.html#Skills_Configuration) | | `disabledSkills` | List<String> | Skills to disable by name | [Skills](advanced.html#Skills_Configuration) | | `configDir` | String | Custom configuration directory | [Config Dir](advanced.html#Custom_Configuration_Directory) | +| `commands` | List<CommandDefinition> | Slash command definitions | [Slash Commands](advanced.html#Slash_Commands) | +| `onElicitationRequest` | ElicitationHandler | Handler for incoming elicitation requests | [Elicitation](advanced.html#Elicitation_UI_Dialogs) | | `onEvent` | Consumer<AbstractSessionEvent> | Event handler registered before session creation | [Early Event Registration](advanced.html#Early_Event_Registration) | ### Cloning SessionConfig diff --git a/src/site/markdown/index.md b/src/site/markdown/index.md index 2f93c4ce9..b599484d9 100644 --- a/src/site/markdown/index.md +++ b/src/site/markdown/index.md @@ -9,7 +9,7 @@ Welcome to the documentation for the **GitHub Copilot SDK for Java** — a Java ### Requirements - Java 17 or later -- GitHub Copilot CLI 0.0.411-1 or later installed and in PATH (or provide custom `cliPath`) +- GitHub Copilot CLI 1.0.17 or later installed and in PATH (or provide custom `cliPath`) ### Installation diff --git a/src/site/site.xml b/src/site/site.xml index f89ebe076..d012c0335 100644 --- a/src/site/site.xml +++ b/src/site/site.xml @@ -59,6 +59,9 @@ + + + diff --git a/src/test/java/com/github/copilot/sdk/AgentInfoTest.java b/src/test/java/com/github/copilot/sdk/AgentInfoTest.java new file mode 100644 index 000000000..0893773e7 --- /dev/null +++ b/src/test/java/com/github/copilot/sdk/AgentInfoTest.java @@ -0,0 +1,64 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Test; + +import com.github.copilot.sdk.json.AgentInfo; + +/** + * Unit tests for {@link AgentInfo} getters, setters, and fluent chaining. + */ +class AgentInfoTest { + + @Test + void defaultValuesAreNull() { + var agent = new AgentInfo(); + assertNull(agent.getName()); + assertNull(agent.getDisplayName()); + assertNull(agent.getDescription()); + } + + @Test + void nameGetterSetter() { + var agent = new AgentInfo(); + agent.setName("coder"); + assertEquals("coder", agent.getName()); + } + + @Test + void displayNameGetterSetter() { + var agent = new AgentInfo(); + agent.setDisplayName("Code Assistant"); + assertEquals("Code Assistant", agent.getDisplayName()); + } + + @Test + void descriptionGetterSetter() { + var agent = new AgentInfo(); + agent.setDescription("Helps with coding tasks"); + assertEquals("Helps with coding tasks", agent.getDescription()); + } + + @Test + void fluentChainingReturnsThis() { + var agent = new AgentInfo().setName("coder").setDisplayName("Code Assistant") + .setDescription("Helps with coding tasks"); + + assertEquals("coder", agent.getName()); + assertEquals("Code Assistant", agent.getDisplayName()); + assertEquals("Helps with coding tasks", agent.getDescription()); + } + + @Test + void fluentChainingReturnsSameInstance() { + var agent = new AgentInfo(); + assertSame(agent, agent.setName("test")); + assertSame(agent, agent.setDisplayName("Test")); + assertSame(agent, agent.setDescription("A test agent")); + } +} diff --git a/src/test/java/com/github/copilot/sdk/CliServerManagerTest.java b/src/test/java/com/github/copilot/sdk/CliServerManagerTest.java index f17201583..32257b0a5 100644 --- a/src/test/java/com/github/copilot/sdk/CliServerManagerTest.java +++ b/src/test/java/com/github/copilot/sdk/CliServerManagerTest.java @@ -13,6 +13,7 @@ import org.junit.jupiter.api.Test; import com.github.copilot.sdk.json.CopilotClientOptions; +import com.github.copilot.sdk.json.TelemetryConfig; /** * Unit tests for {@link CliServerManager} covering parseCliUrl, @@ -212,4 +213,30 @@ void startCliServerWithNullCliPath() throws Exception { assertNotNull(e); } } + + @Test + void startCliServerWithTelemetryAllOptions() throws Exception { + // The telemetry env vars are applied before ProcessBuilder.start() + // so even with a nonexistent CLI path, the telemetry code path is exercised + var telemetry = new TelemetryConfig().setOtlpEndpoint("http://localhost:4318").setFilePath("/tmp/telemetry.log") + .setExporterType("otlp-http").setSourceName("test-app").setCaptureContent(true); + var options = new CopilotClientOptions().setCliPath("/nonexistent/copilot").setTelemetry(telemetry) + .setUseStdio(true); + var manager = new CliServerManager(options); + + var ex = assertThrows(IOException.class, () -> manager.startCliServer()); + assertNotNull(ex); + } + + @Test + void startCliServerWithTelemetryCaptureContentFalse() throws Exception { + // Test the false branch of getCaptureContent() + var telemetry = new TelemetryConfig().setCaptureContent(false); + var options = new CopilotClientOptions().setCliPath("/nonexistent/copilot").setTelemetry(telemetry) + .setUseStdio(true); + var manager = new CliServerManager(options); + + var ex = assertThrows(IOException.class, () -> manager.startCliServer()); + assertNotNull(ex); + } } diff --git a/src/test/java/com/github/copilot/sdk/CommandsTest.java b/src/test/java/com/github/copilot/sdk/CommandsTest.java new file mode 100644 index 000000000..baf26b39b --- /dev/null +++ b/src/test/java/com/github/copilot/sdk/CommandsTest.java @@ -0,0 +1,156 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.List; +import java.util.concurrent.CompletableFuture; + +import org.junit.jupiter.api.Test; + +import com.github.copilot.sdk.json.CommandContext; +import com.github.copilot.sdk.json.CommandDefinition; +import com.github.copilot.sdk.json.CommandHandler; +import com.github.copilot.sdk.json.CommandWireDefinition; +import com.github.copilot.sdk.json.PermissionHandler; +import com.github.copilot.sdk.json.ResumeSessionConfig; +import com.github.copilot.sdk.json.SessionConfig; + +/** + * Unit tests for the Commands feature (CommandDefinition, CommandContext, + * SessionConfig.commands, ResumeSessionConfig.commands, and the wire + * representation). + * + *

+ * Ported from {@code CommandsTests.cs} in the upstream dotnet SDK. + *

+ */ +class CommandsTest { + + @Test + void commandDefinitionHasRequiredProperties() { + CommandHandler handler = context -> CompletableFuture.completedFuture(null); + var cmd = new CommandDefinition().setName("deploy").setDescription("Deploy the app").setHandler(handler); + + assertEquals("deploy", cmd.getName()); + assertEquals("Deploy the app", cmd.getDescription()); + assertNotNull(cmd.getHandler()); + } + + @Test + void commandContextHasAllProperties() { + var ctx = new CommandContext().setSessionId("session-1").setCommand("/deploy production") + .setCommandName("deploy").setArgs("production"); + + assertEquals("session-1", ctx.getSessionId()); + assertEquals("/deploy production", ctx.getCommand()); + assertEquals("deploy", ctx.getCommandName()); + assertEquals("production", ctx.getArgs()); + } + + @Test + void sessionConfigCommandsAreCloned() { + CommandHandler handler = ctx -> CompletableFuture.completedFuture(null); + var config = new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL) + .setCommands(List.of(new CommandDefinition().setName("deploy").setHandler(handler))); + + var clone = config.clone(); + + assertNotNull(clone.getCommands()); + assertEquals(1, clone.getCommands().size()); + assertEquals("deploy", clone.getCommands().get(0).getName()); + + // Collections should be independent — clone list is a copy + assertNotSame(config.getCommands(), clone.getCommands()); + } + + @Test + void resumeConfigCommandsAreCloned() { + CommandHandler handler = ctx -> CompletableFuture.completedFuture(null); + var config = new ResumeSessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL) + .setCommands(List.of(new CommandDefinition().setName("deploy").setHandler(handler))); + + var clone = config.clone(); + + assertNotNull(clone.getCommands()); + assertEquals(1, clone.getCommands().size()); + assertEquals("deploy", clone.getCommands().get(0).getName()); + } + + @Test + void buildCreateRequestIncludesCommandWireDefinitions() { + CommandHandler handler = ctx -> CompletableFuture.completedFuture(null); + var config = new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL).setCommands( + List.of(new CommandDefinition().setName("deploy").setDescription("Deploy").setHandler(handler), + new CommandDefinition().setName("rollback").setHandler(handler))); + + var request = SessionRequestBuilder.buildCreateRequest(config); + + assertNotNull(request.getCommands()); + assertEquals(2, request.getCommands().size()); + assertEquals("deploy", request.getCommands().get(0).getName()); + assertEquals("Deploy", request.getCommands().get(0).getDescription()); + assertEquals("rollback", request.getCommands().get(1).getName()); + assertNull(request.getCommands().get(1).getDescription()); + } + + @Test + void buildResumeRequestIncludesCommandWireDefinitions() { + CommandHandler handler = ctx -> CompletableFuture.completedFuture(null); + var config = new ResumeSessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL).setCommands( + List.of(new CommandDefinition().setName("deploy").setDescription("Deploy").setHandler(handler))); + + var request = SessionRequestBuilder.buildResumeRequest("session-1", config); + + assertNotNull(request.getCommands()); + assertEquals(1, request.getCommands().size()); + assertEquals("deploy", request.getCommands().get(0).getName()); + assertEquals("Deploy", request.getCommands().get(0).getDescription()); + } + + @Test + void buildCreateRequestWithNoCommandsHasNullCommandsList() { + var config = new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL); + + var request = SessionRequestBuilder.buildCreateRequest(config); + + assertNull(request.getCommands()); + } + + @Test + void commandWireDefinitionHasNameAndDescription() { + var wire = new CommandWireDefinition("deploy", "Deploy the app"); + + assertEquals("deploy", wire.getName()); + assertEquals("Deploy the app", wire.getDescription()); + } + + @Test + void commandWireDefinitionNullDescriptionAllowed() { + var wire = new CommandWireDefinition("rollback", null); + + assertEquals("rollback", wire.getName()); + assertNull(wire.getDescription()); + } + + @Test + void commandWireDefinitionFluentSetters() { + var wire = new CommandWireDefinition(); + wire.setName("status"); + wire.setDescription("Show deployment status"); + + assertEquals("status", wire.getName()); + assertEquals("Show deployment status", wire.getDescription()); + } + + @Test + void commandWireDefinitionFluentSettersChaining() { + var wire = new CommandWireDefinition().setName("logs").setDescription("View application logs"); + + assertEquals("logs", wire.getName()); + assertEquals("View application logs", wire.getDescription()); + } +} diff --git a/src/test/java/com/github/copilot/sdk/ConfigCloneTest.java b/src/test/java/com/github/copilot/sdk/ConfigCloneTest.java index bf4881d5c..f3787705f 100644 --- a/src/test/java/com/github/copilot/sdk/ConfigCloneTest.java +++ b/src/test/java/com/github/copilot/sdk/ConfigCloneTest.java @@ -17,10 +17,13 @@ import com.github.copilot.sdk.events.AbstractSessionEvent; import com.github.copilot.sdk.json.CopilotClientOptions; +import com.github.copilot.sdk.json.InfiniteSessionConfig; import com.github.copilot.sdk.json.MessageOptions; import com.github.copilot.sdk.json.ModelInfo; import com.github.copilot.sdk.json.ResumeSessionConfig; import com.github.copilot.sdk.json.SessionConfig; +import com.github.copilot.sdk.json.SystemMessageConfig; +import com.github.copilot.sdk.json.TelemetryConfig; class ConfigCloneTest { @@ -193,4 +196,97 @@ void clonePreservesNullFields() { MessageOptions msgClone = msg.clone(); assertNull(msgClone.getMode()); } + + @Test + @SuppressWarnings("deprecation") + void copilotClientOptionsDeprecatedAutoRestart() { + CopilotClientOptions opts = new CopilotClientOptions(); + assertFalse(opts.isAutoRestart()); + opts.setAutoRestart(true); + assertTrue(opts.isAutoRestart()); + } + + @Test + void copilotClientOptionsSetCliArgsNullClearsExisting() { + CopilotClientOptions opts = new CopilotClientOptions(); + opts.setCliArgs(new String[]{"--flag1"}); + assertNotNull(opts.getCliArgs()); + + // Setting null should clear the existing array + opts.setCliArgs(null); + assertNotNull(opts.getCliArgs()); + assertEquals(0, opts.getCliArgs().length); + } + + @Test + void copilotClientOptionsSetEnvironmentNullClearsExisting() { + CopilotClientOptions opts = new CopilotClientOptions(); + opts.setEnvironment(Map.of("KEY", "VALUE")); + assertNotNull(opts.getEnvironment()); + + // Setting null should clear the existing map (clears in-place → returns empty + // map) + opts.setEnvironment(null); + var env = opts.getEnvironment(); + assertTrue(env == null || env.isEmpty()); + } + + @Test + @SuppressWarnings("deprecation") + void copilotClientOptionsDeprecatedGithubToken() { + CopilotClientOptions opts = new CopilotClientOptions(); + opts.setGithubToken("ghp_deprecated_token"); + assertEquals("ghp_deprecated_token", opts.getGithubToken()); + assertEquals("ghp_deprecated_token", opts.getGitHubToken()); + } + + @Test + void copilotClientOptionsSetTelemetry() { + var telemetry = new TelemetryConfig().setOtlpEndpoint("http://localhost:4318"); + var opts = new CopilotClientOptions(); + opts.setTelemetry(telemetry); + assertSame(telemetry, opts.getTelemetry()); + } + + @Test + void copilotClientOptionsSetUseLoggedInUserNull() { + var opts = new CopilotClientOptions(); + opts.setUseLoggedInUser(null); + // null → Boolean.FALSE + assertEquals(Boolean.FALSE, opts.getUseLoggedInUser()); + } + + @Test + void resumeSessionConfigAllSetters() { + var config = new ResumeSessionConfig(); + + var sysMsg = new SystemMessageConfig(); + config.setSystemMessage(sysMsg); + assertSame(sysMsg, config.getSystemMessage()); + + config.setAvailableTools(List.of("bash", "read_file")); + assertEquals(List.of("bash", "read_file"), config.getAvailableTools()); + + config.setExcludedTools(List.of("write_file")); + assertEquals(List.of("write_file"), config.getExcludedTools()); + + config.setReasoningEffort("high"); + assertEquals("high", config.getReasoningEffort()); + + config.setWorkingDirectory("/project/src"); + assertEquals("/project/src", config.getWorkingDirectory()); + + config.setConfigDir("/home/user/.config/copilot"); + assertEquals("/home/user/.config/copilot", config.getConfigDir()); + + config.setSkillDirectories(List.of("/skills/custom")); + assertEquals(List.of("/skills/custom"), config.getSkillDirectories()); + + config.setDisabledSkills(List.of("some-skill")); + assertEquals(List.of("some-skill"), config.getDisabledSkills()); + + var infiniteConfig = new InfiniteSessionConfig().setEnabled(true); + config.setInfiniteSessions(infiniteConfig); + assertSame(infiniteConfig, config.getInfiniteSessions()); + } } diff --git a/src/test/java/com/github/copilot/sdk/CopilotSessionTest.java b/src/test/java/com/github/copilot/sdk/CopilotSessionTest.java index 787312cef..39406d260 100644 --- a/src/test/java/com/github/copilot/sdk/CopilotSessionTest.java +++ b/src/test/java/com/github/copilot/sdk/CopilotSessionTest.java @@ -7,6 +7,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.fail; @@ -821,4 +822,31 @@ void testSessionListFilterFluentAPI() throws Exception { session.close(); } } + + /** + * Verifies that getSessionMetadata returns metadata for a known session ID. + * + * @see Snapshot: session/should_get_session_metadata_by_id + */ + @Test + void testShouldGetSessionMetadataById() throws Exception { + ctx.configureForTest("session", "should_get_session_metadata_by_id"); + + try (CopilotClient client = ctx.createClient()) { + var session = client + .createSession(new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL)).get(); + + session.sendAndWait(new MessageOptions().setPrompt("Say hello")).get(60, TimeUnit.SECONDS); + + var metadata = client.getSessionMetadata(session.getSessionId()).get(30, TimeUnit.SECONDS); + assertNotNull(metadata, "Metadata should not be null for known session"); + assertEquals(session.getSessionId(), metadata.getSessionId(), "Metadata session ID should match"); + + // A non-existent session should return null + var notFound = client.getSessionMetadata("non-existent-session-id").get(30, TimeUnit.SECONDS); + assertNull(notFound, "Non-existent session should return null"); + + session.close(); + } + } } diff --git a/src/test/java/com/github/copilot/sdk/DataObjectCoverageTest.java b/src/test/java/com/github/copilot/sdk/DataObjectCoverageTest.java new file mode 100644 index 000000000..203f5faed --- /dev/null +++ b/src/test/java/com/github/copilot/sdk/DataObjectCoverageTest.java @@ -0,0 +1,172 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.github.copilot.sdk.json.GetForegroundSessionResponse; +import com.github.copilot.sdk.json.PermissionRequest; +import com.github.copilot.sdk.json.PermissionRequestResult; +import com.github.copilot.sdk.json.PostToolUseHookInput; +import com.github.copilot.sdk.json.PostToolUseHookOutput; +import com.github.copilot.sdk.json.PreToolUseHookInput; +import com.github.copilot.sdk.json.PreToolUseHookOutput; +import com.github.copilot.sdk.json.SectionOverride; +import com.github.copilot.sdk.json.SetForegroundSessionResponse; +import com.github.copilot.sdk.json.ToolBinaryResult; +import com.github.copilot.sdk.json.ToolResultObject; + +/** + * Unit tests for various data transfer objects and record types that were + * missing coverage, including hook output factory methods, record constructors, + * and getters for hook inputs. + */ +class DataObjectCoverageTest { + + // ===== PreToolUseHookOutput factory methods ===== + + @Test + void preToolUseHookOutputDenyWithReason() { + var output = PreToolUseHookOutput.deny("Security policy violation"); + assertEquals("deny", output.permissionDecision()); + assertEquals("Security policy violation", output.permissionDecisionReason()); + assertNull(output.modifiedArgs()); + } + + @Test + void preToolUseHookOutputAsk() { + var output = PreToolUseHookOutput.ask(); + assertEquals("ask", output.permissionDecision()); + assertNull(output.permissionDecisionReason()); + } + + @Test + void preToolUseHookOutputWithModifiedArgs() { + ObjectNode args = JsonNodeFactory.instance.objectNode(); + args.put("path", "/safe/path"); + + var output = PreToolUseHookOutput.withModifiedArgs("allow", args); + assertEquals("allow", output.permissionDecision()); + assertEquals(args, output.modifiedArgs()); + } + + // ===== PostToolUseHookOutput record ===== + + @Test + void postToolUseHookOutputRecord() { + var output = new PostToolUseHookOutput(null, "Extra context", false); + assertNull(output.modifiedResult()); + assertEquals("Extra context", output.additionalContext()); + assertFalse(output.suppressOutput()); + } + + // ===== ToolBinaryResult record ===== + + @Test + void toolBinaryResultRecord() { + var result = new ToolBinaryResult("base64data==", "image/png", "image", "A chart"); + assertEquals("base64data==", result.data()); + assertEquals("image/png", result.mimeType()); + assertEquals("image", result.type()); + assertEquals("A chart", result.description()); + } + + // ===== GetForegroundSessionResponse record ===== + + @Test + void getForegroundSessionResponseRecord() { + var response = new GetForegroundSessionResponse("session-123", "/home/user/project"); + assertEquals("session-123", response.sessionId()); + assertEquals("/home/user/project", response.workspacePath()); + } + + // ===== SetForegroundSessionResponse record ===== + + @Test + void setForegroundSessionResponseRecord() { + var successResponse = new SetForegroundSessionResponse(true, null); + assertTrue(successResponse.success()); + assertNull(successResponse.error()); + + var errorResponse = new SetForegroundSessionResponse(false, "Session not found"); + assertFalse(errorResponse.success()); + assertEquals("Session not found", errorResponse.error()); + } + + // ===== ToolResultObject factory methods ===== + + @Test + void toolResultObjectErrorWithTextAndError() { + var result = ToolResultObject.error("partial output", "File not found"); + assertEquals("error", result.resultType()); + assertEquals("partial output", result.textResultForLlm()); + assertEquals("File not found", result.error()); + } + + @Test + void toolResultObjectFailure() { + var result = ToolResultObject.failure("Tool unavailable", "Unknown tool"); + assertEquals("failure", result.resultType()); + assertEquals("Tool unavailable", result.textResultForLlm()); + assertEquals("Unknown tool", result.error()); + } + + // ===== PermissionRequest additional setters ===== + + @Test + void permissionRequestSetExtensionData() { + var req = new PermissionRequest(); + req.setExtensionData(java.util.Map.of("key", "value")); + assertEquals("value", req.getExtensionData().get("key")); + } + + // ===== SectionOverride setContent ===== + + @Test + void sectionOverrideSetContent() { + var override = new SectionOverride(); + override.setContent("Custom content"); + assertEquals("Custom content", override.getContent()); + } + + // ===== PreToolUseHookInput getters ===== + + @Test + void preToolUseHookInputGetters() { + var input = new PreToolUseHookInput(); + // Default values + assertEquals(0L, input.getTimestamp()); + assertNull(input.getCwd()); + assertNull(input.getToolArgs()); + } + + // ===== PostToolUseHookInput getters ===== + + @Test + void postToolUseHookInputGetters() { + var input = new PostToolUseHookInput(); + // Default values + assertEquals(0L, input.getTimestamp()); + assertNull(input.getCwd()); + assertNull(input.getToolArgs()); + } + + // ===== PermissionRequestResult setRules ===== + + @Test + void permissionRequestResultSetRules() { + var result = new PermissionRequestResult().setKind("allow"); + var rules = new java.util.ArrayList(); + rules.add("bash:read"); + rules.add("bash:write"); + result.setRules(rules); + assertEquals(2, result.getRules().size()); + assertEquals("bash:read", result.getRules().get(0)); + } +} diff --git a/src/test/java/com/github/copilot/sdk/ElicitationTest.java b/src/test/java/com/github/copilot/sdk/ElicitationTest.java new file mode 100644 index 000000000..d6e2ac0b5 --- /dev/null +++ b/src/test/java/com/github/copilot/sdk/ElicitationTest.java @@ -0,0 +1,190 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +import org.junit.jupiter.api.Test; + +import com.github.copilot.sdk.json.ElicitationContext; +import com.github.copilot.sdk.json.ElicitationHandler; +import com.github.copilot.sdk.json.ElicitationParams; +import com.github.copilot.sdk.json.ElicitationResult; +import com.github.copilot.sdk.json.ElicitationResultAction; +import com.github.copilot.sdk.json.ElicitationSchema; +import com.github.copilot.sdk.json.InputOptions; +import com.github.copilot.sdk.json.PermissionHandler; +import com.github.copilot.sdk.json.ResumeSessionConfig; +import com.github.copilot.sdk.json.SessionCapabilities; +import com.github.copilot.sdk.json.SessionConfig; +import com.github.copilot.sdk.json.SessionUiCapabilities; + +/** + * Unit tests for the Elicitation feature and Session Capabilities. + * + *

+ * Ported from {@code ElicitationTests.cs} in the upstream dotnet SDK. + *

+ */ +class ElicitationTest { + + @Test + void sessionCapabilitiesTypesAreProperlyStructured() { + var capabilities = new SessionCapabilities().setUi(new SessionUiCapabilities().setElicitation(true)); + + assertNotNull(capabilities.getUi()); + assertTrue(capabilities.getUi().getElicitation()); + + // Test with null UI + var emptyCapabilities = new SessionCapabilities(); + assertNull(emptyCapabilities.getUi()); + } + + @Test + void defaultCapabilitiesAreEmpty() { + var capabilities = new SessionCapabilities(); + + assertNull(capabilities.getUi()); + } + + @Test + void elicitationResultActionValues() { + assertEquals("accept", ElicitationResultAction.ACCEPT.getValue()); + assertEquals("decline", ElicitationResultAction.DECLINE.getValue()); + assertEquals("cancel", ElicitationResultAction.CANCEL.getValue()); + } + + @Test + void elicitationResultHasActionAndContent() { + var content = Map.of("name", (Object) "Alice"); + var result = new ElicitationResult().setAction(ElicitationResultAction.ACCEPT).setContent(content); + + assertEquals(ElicitationResultAction.ACCEPT, result.getAction()); + assertEquals(content, result.getContent()); + } + + @Test + void elicitationSchemaHasTypeAndProperties() { + var properties = Map.of("name", (Object) Map.of("type", "string")); + var schema = new ElicitationSchema().setType("object").setProperties(properties).setRequired(List.of("name")); + + assertEquals("object", schema.getType()); + assertEquals(properties, schema.getProperties()); + assertEquals(List.of("name"), schema.getRequired()); + } + + @Test + void elicitationSchemaDefaultTypeIsObject() { + var schema = new ElicitationSchema(); + + assertEquals("object", schema.getType()); + } + + @Test + void elicitationContextHasAllProperties() { + var properties = Map.of("field", (Object) Map.of("type", "string")); + var schema = new ElicitationSchema().setProperties(properties); + + var ctx = new ElicitationContext().setSessionId("session-1").setMessage("Please enter your name") + .setRequestedSchema(schema).setMode("form").setElicitationSource("mcp-server").setUrl(null); + + assertEquals("session-1", ctx.getSessionId()); + assertEquals("Please enter your name", ctx.getMessage()); + assertEquals(schema, ctx.getRequestedSchema()); + assertEquals("form", ctx.getMode()); + assertEquals("mcp-server", ctx.getElicitationSource()); + assertNull(ctx.getUrl()); + } + + @Test + void elicitationParamsHasMessageAndSchema() { + var schema = new ElicitationSchema().setProperties(Map.of("field", (Object) Map.of("type", "string"))); + var params = new ElicitationParams().setMessage("Enter name").setRequestedSchema(schema); + + assertEquals("Enter name", params.getMessage()); + assertEquals(schema, params.getRequestedSchema()); + } + + @Test + void inputOptionsHasAllFields() { + var opts = new InputOptions().setTitle("My Title").setDescription("My Desc").setMinLength(1).setMaxLength(100) + .setFormat("email").setDefaultValue("default@example.com"); + + assertEquals("My Title", opts.getTitle()); + assertEquals("My Desc", opts.getDescription()); + assertEquals(1, opts.getMinLength()); + assertEquals(100, opts.getMaxLength()); + assertEquals("email", opts.getFormat()); + assertEquals("default@example.com", opts.getDefaultValue()); + } + + @Test + void sessionConfigOnElicitationRequestIsCloned() { + ElicitationHandler handler = ctx -> CompletableFuture + .completedFuture(new ElicitationResult().setAction(ElicitationResultAction.ACCEPT)); + + var config = new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL) + .setOnElicitationRequest(handler); + + var clone = config.clone(); + + // Handler reference is shared (not deep-cloned), but the field is copied + assertNotNull(clone.getOnElicitationRequest()); + assertSame(handler, clone.getOnElicitationRequest()); + } + + @Test + void resumeConfigOnElicitationRequestIsCloned() { + ElicitationHandler handler = ctx -> CompletableFuture + .completedFuture(new ElicitationResult().setAction(ElicitationResultAction.CANCEL)); + + var config = new ResumeSessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL) + .setOnElicitationRequest(handler); + + var clone = config.clone(); + + assertNotNull(clone.getOnElicitationRequest()); + assertSame(handler, clone.getOnElicitationRequest()); + } + + @Test + void buildCreateRequestSetsRequestElicitationWhenHandlerPresent() { + ElicitationHandler handler = ctx -> CompletableFuture + .completedFuture(new ElicitationResult().setAction(ElicitationResultAction.ACCEPT)); + + var config = new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL) + .setOnElicitationRequest(handler); + + var request = SessionRequestBuilder.buildCreateRequest(config); + + assertTrue(Boolean.TRUE.equals(request.getRequestElicitation())); + } + + @Test + void buildCreateRequestDoesNotSetRequestElicitationWhenNoHandler() { + var config = new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL); + + var request = SessionRequestBuilder.buildCreateRequest(config); + + assertNull(request.getRequestElicitation()); + } + + @Test + void buildResumeRequestSetsRequestElicitationWhenHandlerPresent() { + ElicitationHandler handler = ctx -> CompletableFuture + .completedFuture(new ElicitationResult().setAction(ElicitationResultAction.ACCEPT)); + + var config = new ResumeSessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL) + .setOnElicitationRequest(handler); + + var request = SessionRequestBuilder.buildResumeRequest("session-1", config); + + assertTrue(Boolean.TRUE.equals(request.getRequestElicitation())); + } +} diff --git a/src/test/java/com/github/copilot/sdk/ModelInfoTest.java b/src/test/java/com/github/copilot/sdk/ModelInfoTest.java new file mode 100644 index 000000000..f36d0c4bd --- /dev/null +++ b/src/test/java/com/github/copilot/sdk/ModelInfoTest.java @@ -0,0 +1,68 @@ +/*--------------------------------------------------------------------------------------------- + * 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.github.copilot.sdk.json.ModelInfo; +import com.github.copilot.sdk.json.ModelSupports; +import com.github.copilot.sdk.json.SessionMetadata; + +/** + * Unit tests for {@link ModelInfo}, {@link ModelSupports}, and + * {@link SessionMetadata} getters and setters. + */ +class ModelInfoTest { + + @Test + void modelSupportsReasoningEffortGetterSetter() { + var supports = new ModelSupports(); + assertFalse(supports.isReasoningEffort()); + + supports.setReasoningEffort(true); + assertTrue(supports.isReasoningEffort()); + } + + @Test + void modelSupportsFluentChaining() { + var supports = new ModelSupports().setVision(true).setReasoningEffort(true); + assertTrue(supports.isVision()); + assertTrue(supports.isReasoningEffort()); + } + + @Test + void modelInfoSupportedReasoningEffortsGetterSetter() { + var model = new ModelInfo(); + assertNull(model.getSupportedReasoningEfforts()); + + model.setSupportedReasoningEfforts(List.of("low", "medium", "high")); + assertEquals(List.of("low", "medium", "high"), model.getSupportedReasoningEfforts()); + } + + @Test + void modelInfoDefaultReasoningEffortGetterSetter() { + var model = new ModelInfo(); + assertNull(model.getDefaultReasoningEffort()); + + model.setDefaultReasoningEffort("medium"); + assertEquals("medium", model.getDefaultReasoningEffort()); + } + + @Test + void sessionMetadataGettersAndSetters() { + var meta = new SessionMetadata(); + assertNull(meta.getStartTime()); + assertNull(meta.getModifiedTime()); + assertNull(meta.getSummary()); + assertFalse(meta.isRemote()); + + meta.setRemote(true); + assertTrue(meta.isRemote()); + } +} diff --git a/src/test/java/com/github/copilot/sdk/RpcHandlerDispatcherTest.java b/src/test/java/com/github/copilot/sdk/RpcHandlerDispatcherTest.java index 79f5d7c7e..315a38b90 100644 --- a/src/test/java/com/github/copilot/sdk/RpcHandlerDispatcherTest.java +++ b/src/test/java/com/github/copilot/sdk/RpcHandlerDispatcherTest.java @@ -542,4 +542,52 @@ void hooksInvokeWithNoHooksRegistered() throws Exception { JsonNode output = response.get("result").get("output"); assertTrue(output == null || output.isNull(), "Output should be null when no hooks registered"); } + + // ===== systemMessage.transform tests ===== + + @Test + void systemMessageTransformWithUnknownSession() throws Exception { + ObjectNode params = MAPPER.createObjectNode(); + params.put("sessionId", "nonexistent"); + params.putObject("sections"); + + invokeHandler("systemMessage.transform", "40", params); + + JsonNode response = readResponse(); + assertNotNull(response.get("error")); + assertEquals(-32602, response.get("error").get("code").asInt()); + } + + @Test + void systemMessageTransformWithNullSessionId() throws Exception { + ObjectNode params = MAPPER.createObjectNode(); + // sessionId omitted → null → session lookup returns null → error + params.putObject("sections"); + + invokeHandler("systemMessage.transform", "41", params); + + JsonNode response = readResponse(); + assertNotNull(response.get("error")); + assertEquals(-32602, response.get("error").get("code").asInt()); + } + + @Test + void systemMessageTransformWithKnownSessionNoCallbacks() throws Exception { + // Session without transform callbacks returns the sections unchanged + createSession("s1"); + + ObjectNode params = MAPPER.createObjectNode(); + params.put("sessionId", "s1"); + ObjectNode sections = params.putObject("sections"); + ObjectNode sectionData = sections.putObject("identity"); + sectionData.put("content", "Original content"); + + invokeHandler("systemMessage.transform", "42", params); + + JsonNode response = readResponse(); + assertNotNull(response.get("result")); + JsonNode resultSections = response.get("result").get("sections"); + assertNotNull(resultSections); + assertEquals("Original content", resultSections.get("identity").get("content").asText()); + } } diff --git a/src/test/java/com/github/copilot/sdk/SessionEventParserTest.java b/src/test/java/com/github/copilot/sdk/SessionEventParserTest.java index 5898d5301..4a63bb243 100644 --- a/src/test/java/com/github/copilot/sdk/SessionEventParserTest.java +++ b/src/test/java/com/github/copilot/sdk/SessionEventParserTest.java @@ -2385,4 +2385,188 @@ void testParseSystemNotificationEvent() throws Exception { assertNotNull(event.getData()); assertTrue(event.getData().content().contains("Agent completed")); } + + @Test + void testParseCapabilitiesChangedEvent() throws Exception { + String json = """ + { + "type": "capabilities.changed", + "data": { + "ui": { + "elicitation": true + } + } + } + """; + + AbstractSessionEvent event = parseJson(json); + assertNotNull(event); + assertInstanceOf(CapabilitiesChangedEvent.class, event); + assertEquals("capabilities.changed", event.getType()); + + var castedEvent = (CapabilitiesChangedEvent) event; + assertNotNull(castedEvent.getData()); + assertNotNull(castedEvent.getData().ui()); + assertTrue(castedEvent.getData().ui().elicitation()); + + // Verify setData round-trip + var newData = new CapabilitiesChangedEvent.CapabilitiesChangedData( + new CapabilitiesChangedEvent.CapabilitiesChangedUi(false)); + castedEvent.setData(newData); + assertFalse(castedEvent.getData().ui().elicitation()); + } + + @Test + void testParseCommandExecuteEvent() throws Exception { + String json = """ + { + "type": "command.execute", + "data": { + "requestId": "req-001", + "command": "/deploy production", + "commandName": "deploy", + "args": "production" + } + } + """; + + AbstractSessionEvent event = parseJson(json); + assertNotNull(event); + assertInstanceOf(CommandExecuteEvent.class, event); + assertEquals("command.execute", event.getType()); + + var castedEvent = (CommandExecuteEvent) event; + assertNotNull(castedEvent.getData()); + assertEquals("req-001", castedEvent.getData().requestId()); + assertEquals("/deploy production", castedEvent.getData().command()); + assertEquals("deploy", castedEvent.getData().commandName()); + assertEquals("production", castedEvent.getData().args()); + + // Verify setData round-trip + castedEvent.setData(new CommandExecuteEvent.CommandExecuteData("req-002", "/rollback", "rollback", null)); + assertEquals("req-002", castedEvent.getData().requestId()); + } + + @Test + void testParseElicitationRequestedEvent() throws Exception { + String json = """ + { + "type": "elicitation.requested", + "data": { + "requestId": "elix-001", + "toolCallId": "tc-123", + "elicitationSource": "mcp_tool", + "message": "Please provide your name", + "mode": "form", + "requestedSchema": { + "type": "object", + "properties": { + "name": {"type": "string"} + }, + "required": ["name"] + }, + "url": null + } + } + """; + + AbstractSessionEvent event = parseJson(json); + assertNotNull(event); + assertInstanceOf(ElicitationRequestedEvent.class, event); + assertEquals("elicitation.requested", event.getType()); + + var castedEvent = (ElicitationRequestedEvent) event; + assertNotNull(castedEvent.getData()); + assertEquals("elix-001", castedEvent.getData().requestId()); + assertEquals("tc-123", castedEvent.getData().toolCallId()); + assertEquals("mcp_tool", castedEvent.getData().elicitationSource()); + assertEquals("Please provide your name", castedEvent.getData().message()); + assertEquals("form", castedEvent.getData().mode()); + assertNotNull(castedEvent.getData().requestedSchema()); + assertEquals("object", castedEvent.getData().requestedSchema().type()); + assertNotNull(castedEvent.getData().requestedSchema().properties()); + assertNotNull(castedEvent.getData().requestedSchema().required()); + assertTrue(castedEvent.getData().requestedSchema().required().contains("name")); + + // Verify setData round-trip + castedEvent.setData(new ElicitationRequestedEvent.ElicitationRequestedData("elix-002", null, null, "Enter URL", + "url", null, "https://example.com")); + assertEquals("elix-002", castedEvent.getData().requestId()); + assertEquals("url", castedEvent.getData().mode()); + } + + @Test + void testParseSessionContextChangedEvent() throws Exception { + String json = """ + { + "type": "session.context_changed", + "data": { + "cwd": "/home/user/project", + "gitRoot": "/home/user/project", + "repository": "my-repo", + "branch": "main" + } + } + """; + + AbstractSessionEvent event = parseJson(json); + assertNotNull(event); + assertInstanceOf(SessionContextChangedEvent.class, event); + assertEquals("session.context_changed", event.getType()); + + var castedEvent = (SessionContextChangedEvent) event; + assertNotNull(castedEvent.getData()); + assertEquals("/home/user/project", castedEvent.getData().getCwd()); + + // Verify setData round-trip + castedEvent.setData(null); + assertNull(castedEvent.getData()); + } + + @Test + void testParseSessionTaskCompleteEvent() throws Exception { + String json = """ + { + "type": "session.task_complete", + "data": { + "summary": "Task completed successfully" + } + } + """; + + AbstractSessionEvent event = parseJson(json); + assertNotNull(event); + assertInstanceOf(SessionTaskCompleteEvent.class, event); + assertEquals("session.task_complete", event.getType()); + + var castedEvent = (SessionTaskCompleteEvent) event; + assertNotNull(castedEvent.getData()); + assertEquals("Task completed successfully", castedEvent.getData().summary()); + + // Verify setData round-trip + castedEvent.setData(new SessionTaskCompleteEvent.SessionTaskCompleteData("New summary")); + assertEquals("New summary", castedEvent.getData().summary()); + } + + @Test + void testParseSubagentDeselectedEvent() throws Exception { + String json = """ + { + "type": "subagent.deselected", + "data": {} + } + """; + + AbstractSessionEvent event = parseJson(json); + assertNotNull(event); + assertInstanceOf(SubagentDeselectedEvent.class, event); + assertEquals("subagent.deselected", event.getType()); + + var castedEvent = (SubagentDeselectedEvent) event; + assertNotNull(castedEvent.getData()); + + // Verify setData round-trip + castedEvent.setData(new SubagentDeselectedEvent.SubagentDeselectedData()); + assertNotNull(castedEvent.getData()); + } } diff --git a/src/test/java/com/github/copilot/sdk/SessionRequestBuilderTest.java b/src/test/java/com/github/copilot/sdk/SessionRequestBuilderTest.java index 1a64b7534..75457583e 100644 --- a/src/test/java/com/github/copilot/sdk/SessionRequestBuilderTest.java +++ b/src/test/java/com/github/copilot/sdk/SessionRequestBuilderTest.java @@ -13,6 +13,9 @@ import org.junit.jupiter.api.Test; import com.github.copilot.sdk.json.CreateSessionRequest; +import com.github.copilot.sdk.json.ElicitationHandler; +import com.github.copilot.sdk.json.ElicitationResult; +import com.github.copilot.sdk.json.ElicitationResultAction; import com.github.copilot.sdk.json.ResumeSessionConfig; import com.github.copilot.sdk.json.ResumeSessionRequest; import com.github.copilot.sdk.json.SessionConfig; @@ -305,4 +308,96 @@ void extractTransformCallbacks_customizeModeWithTransform_extractsCallbacks() { assertEquals(com.github.copilot.sdk.json.SectionOverrideAction.TRANSFORM, wireSection.getAction()); assertNull(wireSection.getTransform()); } + + @Test + @SuppressWarnings("deprecation") + void buildCreateRequestWithSessionId_usesProvidedSessionId() { + var config = new SessionConfig(); + config.setSessionId("my-session-id"); + + // The deprecated single-arg overload uses the sessionId from config when set + CreateSessionRequest request = SessionRequestBuilder.buildCreateRequest(config); + + assertEquals("my-session-id", request.getSessionId()); + } + + @Test + void configureSessionWithNullConfig_returnsEarly() { + // configureSession with null config should return without error + CopilotSession session = new CopilotSession("session-1", null); + // Covers the null config early-return branch (L219-220) + assertDoesNotThrow(() -> SessionRequestBuilder.configureSession(session, (SessionConfig) null)); + } + + @Test + void configureSessionWithCommands_registersCommands() { + CopilotSession session = new CopilotSession("session-1", null); + + var cmd = new com.github.copilot.sdk.json.CommandDefinition().setName("deploy") + .setHandler(ctx -> CompletableFuture.completedFuture(null)); + var config = new SessionConfig().setCommands(List.of(cmd)); + + // Covers config.getCommands() != null branch (L235-236) + SessionRequestBuilder.configureSession(session, config); + // If no exception thrown, the branch was covered + } + + @Test + void configureSessionWithElicitationHandler_registersHandler() { + CopilotSession session = new CopilotSession("session-1", null); + + ElicitationHandler handler = (context) -> CompletableFuture + .completedFuture(new ElicitationResult().setAction(ElicitationResultAction.CANCEL)); + var config = new SessionConfig().setOnElicitationRequest(handler); + + // Covers config.getOnElicitationRequest() != null branch (L238-239) + SessionRequestBuilder.configureSession(session, config); + } + + @Test + void configureSessionWithOnEvent_registersEventHandler() { + CopilotSession session = new CopilotSession("session-1", null); + + var config = new SessionConfig().setOnEvent(event -> { + }); + + // Covers config.getOnEvent() != null branch (L241-242) + SessionRequestBuilder.configureSession(session, config); + } + + @Test + void configureResumedSessionWithCommands_registersCommands() { + CopilotSession session = new CopilotSession("session-1", null); + + var cmd = new com.github.copilot.sdk.json.CommandDefinition().setName("rollback") + .setHandler(ctx -> CompletableFuture.completedFuture(null)); + var config = new ResumeSessionConfig().setCommands(List.of(cmd)); + + // Covers ResumeSessionConfig.getCommands() != null branch (L271-272) + SessionRequestBuilder.configureSession(session, config); + } + + @Test + void configureResumedSessionWithElicitationHandler_registersHandler() { + CopilotSession session = new CopilotSession("session-1", null); + + ElicitationHandler handler = (context) -> CompletableFuture + .completedFuture(new ElicitationResult().setAction(ElicitationResultAction.CANCEL)); + var config = new ResumeSessionConfig().setOnElicitationRequest(handler); + + // Covers ResumeSessionConfig.getOnElicitationRequest() != null branch + // (L274-275) + SessionRequestBuilder.configureSession(session, config); + } + + @Test + void configureResumedSessionWithOnEvent_registersEventHandler() { + CopilotSession session = new CopilotSession("session-1", null); + + var config = new ResumeSessionConfig().setOnEvent(event -> { + }); + + // Covers ResumeSessionConfig.getOnEvent() != null branch (L277-278) + SessionRequestBuilder.configureSession(session, config); + } } diff --git a/src/test/java/com/github/copilot/sdk/TelemetryConfigTest.java b/src/test/java/com/github/copilot/sdk/TelemetryConfigTest.java new file mode 100644 index 000000000..99b360d2d --- /dev/null +++ b/src/test/java/com/github/copilot/sdk/TelemetryConfigTest.java @@ -0,0 +1,77 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.sdk; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Test; + +import com.github.copilot.sdk.json.TelemetryConfig; + +/** + * Unit tests for {@link TelemetryConfig} getters, setters, and fluent chaining. + */ +class TelemetryConfigTest { + + @Test + void defaultValuesAreNull() { + var config = new TelemetryConfig(); + assertNull(config.getOtlpEndpoint()); + assertNull(config.getFilePath()); + assertNull(config.getExporterType()); + assertNull(config.getSourceName()); + assertNull(config.getCaptureContent()); + } + + @Test + void otlpEndpointGetterSetter() { + var config = new TelemetryConfig(); + config.setOtlpEndpoint("http://localhost:4318"); + assertEquals("http://localhost:4318", config.getOtlpEndpoint()); + } + + @Test + void filePathGetterSetter() { + var config = new TelemetryConfig(); + config.setFilePath("/tmp/telemetry.log"); + assertEquals("/tmp/telemetry.log", config.getFilePath()); + } + + @Test + void exporterTypeGetterSetter() { + var config = new TelemetryConfig(); + config.setExporterType("otlp-http"); + assertEquals("otlp-http", config.getExporterType()); + } + + @Test + void sourceNameGetterSetter() { + var config = new TelemetryConfig(); + config.setSourceName("my-app"); + assertEquals("my-app", config.getSourceName()); + } + + @Test + void captureContentGetterSetter() { + var config = new TelemetryConfig(); + config.setCaptureContent(true); + assertTrue(config.getCaptureContent()); + + config.setCaptureContent(false); + assertFalse(config.getCaptureContent()); + } + + @Test + void fluentChainingReturnsThis() { + var config = new TelemetryConfig().setOtlpEndpoint("http://localhost:4318").setFilePath("/tmp/spans.json") + .setExporterType("file").setSourceName("sdk-test").setCaptureContent(true); + + assertEquals("http://localhost:4318", config.getOtlpEndpoint()); + assertEquals("/tmp/spans.json", config.getFilePath()); + assertEquals("file", config.getExporterType()); + assertEquals("sdk-test", config.getSourceName()); + assertTrue(config.getCaptureContent()); + } +}