diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 23561c3..f19495d 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -177,9 +177,13 @@ src/github/copilot_sdk/ ├── client.clj # Main client API (create-client, create-session, etc.) ├── session.clj # Session operations (send!, send-and-wait!, etc.) ├── helpers.clj # Convenience functions (query, query-seq!, query-chan, etc.) -├── specs.clj # clojure.spec definitions -├── instrument.clj # Function specs and instrumentation -└── util.clj # Internal utilities +├── tools.clj # Helper functions for defining tools (define-tool, result-success, etc.) +├── specs.clj # clojure.spec definitions for all data shapes +├── instrument.clj # Function specs (fdefs) and instrumentation +├── util.clj # Wire conversion (camelCase ↔ kebab-case), MCP helpers +├── protocol.clj # JSON-RPC 2.0 protocol over NIO channels +├── process.clj # CLI process management (spawning, lifecycle) +└── logging.clj # Logging facade via clojure.tools.logging ``` ## Documentation diff --git a/.github/skills/update-upstream/SKILL.md b/.github/skills/update-upstream/SKILL.md new file mode 100644 index 0000000..6770801 --- /dev/null +++ b/.github/skills/update-upstream/SKILL.md @@ -0,0 +1,151 @@ +--- +name: update-upstream +description: Sync the Clojure Copilot SDK with upstream copilot-sdk changes. Runs update.sh, performs gap analysis against Node.js and Python SDKs, ports changes with red/green TDD, runs full CI (E2E tests + examples), gets parallel multi-model code reviews, updates docs, and creates a PR. Use when syncing with new upstream releases or checking for unported changes. +compatibility: Requires copilot CLI authenticated, gh CLI, clojure CLI, bb (babashka). Upstream repo at ../copilot-sdk. +--- + +# Update Upstream Skill + +Sync the copilot-sdk-clojure project with upstream [github/copilot-sdk](https://github.com/github/copilot-sdk) changes. This skill codifies the full upstream sync workflow — from discovery through implementation, review, docs, and PR creation. + +**Prerequisites**: Read `AGENTS.md` at the repo root for project structure, design philosophy, API compatibility rules, testing commands, and version management. Read `references/PROJECT.md` (relative to this skill) for upstream↔Clojure file mapping and wire conversion notes. + +## Process + +### Phase 1: Discovery + +1. Run `./update.sh` from the repo root to pull the latest upstream and list releases. +2. Check the current Clojure SDK version in `build.clj` (format: `UPSTREAM.CLJ_PATCH` — see AGENTS.md § Version Management). +3. List upstream commits since our last synced version: + ``` + cd ../copilot-sdk && git log --oneline ..HEAD -- nodejs/ + ``` +4. For each commit, classify: + - **Port** — Code changes to `nodejs/src/` (types, client, session, generated) + - **Skip** — CI/tooling, language-specific (Python/Go/.NET only) + +### Phase 2: Gap Analysis + +Launch three parallel explore agents to build a comprehensive inventory. Use the file mapping in `references/PROJECT.md` to locate the right files. + +1. **Node.js SDK** — Read upstream files listed in references/PROJECT.md (types.ts, client.ts, session.ts, index.ts, generated/). Catalog all public types, methods, event types, and event data fields. +2. **Python SDK** — Read `python/copilot/client.py`, `session.py`, `__init__.py`, `generated/`. Note behavioral differences from Node.js. +3. **Clojure SDK** — Read all `src/github/copilot_sdk/*.clj`. Catalog public functions, specs, event sets, wire conversion. + +Compare inventories to identify gaps: +- Missing behavioral guards (e.g., `resolvedByHook`) +- Missing spec fields (new event data, new config options) +- Missing permission result kinds, event types +- API signature changes (e.g., handler arity) + +### Phase 3: Planning + +Create a structured plan in the session plan.md with: +- **Code gaps** — Behavioral changes needed (HIGH priority) +- **Spec gaps** — Missing fields/values (MEDIUM priority) +- **Doc gaps** — Documentation needing updates +- **Example gaps** — Missing examples for new features +- **Idiomatic review** — Areas to check for Clojure idiom adherence + +Show the plan to the user. Wait for approval before implementing. + +### Phase 4: Implementation (Red/Green TDD) + +For each code/spec change: + +1. **RED** — Write a failing test first in `test/github/copilot_sdk/integration_test.clj` +2. Run tests: `bb test` — confirm failure (it's OK to just run the tests you added as you iterate) +3. **GREEN** — Implement the minimal change in `src/` +4. Run tests again — confirm all pass (0 failures) + +See `references/PROJECT.md` for the upstream↔Clojure file mapping to know which source files to modify. See `AGENTS.md` § Instrumented Testing for the spec/fdef workflow when adding new public functions. + +Wire format notes — see `references/PROJECT.md` § Wire Conversion Cheat Sheet. Key rule: camel-snake-kebab does **not** add `?` suffixes for booleans. + +### Phase 5: Full Validation + +Run the full CI pipeline (see `AGENTS.md` § Before Committing): + +```bash +bb ci:full +``` + +This runs E2E tests, examples, doc validation, and JAR build. If copilot CLI is unavailable, run `bb ci` instead. + +Carefully review example output for regressions. + +### Phase 6: Multi-Model Code Review + +Launch parallel code-review agents using at least three distinct model families (e.g., Claude Opus 4.6, GPT-5.4, and Gemini 3 Pro - or later if available). Different model families catch different categories of issues — use whichever specific models are currently available. + +Each reviewer gets the same context: what changed, why, and what to focus on (correctness, spec completeness, test coverage, Clojure idioms, wire conversion accuracy). + +Compile a combined assessment table: + +| # | Finding | Source | Severity | Validity | Decision | +|---|---------|--------|----------|----------|----------| + +For each finding: +- **Valid + actionable** → Fix it, re-run tests +- **Valid + pre-existing** → Note as out of scope, track separately +- **Invalid / false positive** → Document rationale for dismissal + +Iterate: fix → re-test → re-review until no actionable findings remain. + +### Phase 7: Documentation + +Invoke the `update-docs` skill. See `AGENTS.md` § Documentation for the full list of doc files that may need updating. + +At minimum: +1. Update `doc/reference/API.md` — new specs, event fields, behavior changes +2. Update `CHANGELOG.md` — entries under `[Unreleased]` (see `AGENTS.md` § Changelog for formatting) +3. Run `bb validate-docs` + +### Phase 8: PR Creation + +1. Create a feature branch: `git checkout -b upstream-sync/v` +2. Commit changes in logical commits to make them easy to review commit by commit and with descriptive message and `Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>` +3. Push and create PR with `gh pr create` +4. PR body should include: summary, changes list, validation results, review findings table + +### Phase 9: Reflecing on code review feedback. + +Once the PR is created, Copilot Code Review (and possibly humans) will provide code review feedback. + +run: /pr auto Consider Copilot Code Review feedback. For each piece of feedback, determine if its valid and important, or invalid (false positive) and/or not important. For all valid feedback, address it. For each piece of feedback (valid or not), comment in the thread for that particular feedback comment and explain how you addressed the feedback or your rationale for not addressing, or a suggestion for addressing in the future (create issues for future/follow up). Once all feedback is addressed/commented, rerequest a review, and continue iterating this processes until there is no more code review feedback - you should keep iterating this process until you see a review from Copilot Code Review which generates no comments, but no more than 10 rounds (to avoid exploding costs). If more than 10 rounds is needed, prompt the users asking what to do next. + +### Phase 10: Skill Self-Review + +After each upstream sync, review this skill itself for accuracy and relevance: + +1. **Project structure** — Compare the file listing in `AGENTS.md` § Project Structure against the actual contents of `src/github/copilot_sdk/`. Flag any new, renamed, or removed source files. +2. **Upstream file mapping** — Compare the mapping table in `references/PROJECT.md` against the current upstream `nodejs/src/` directory. Flag new or removed upstream files. +3. **Common Pitfalls** — Check whether any pitfalls were encountered during this sync that aren't listed, or whether any listed pitfalls are no longer relevant (e.g., patterns that have been refactored away). +4. **Process phases** — Note any workflow steps that were awkward, missing, or unnecessary during this sync. + +If any drift is found, describe your findings to the user and propose specific edits to `SKILL.md`, `references/PROJECT.md`, or `AGENTS.md`. **Do not commit** the skill updates — wait for the maintainer to review and approve them. + +## Key Principles + +1. **API parity with upstream Node.js SDK.** Only port what the official SDK exposes. Don't add CLI-only features unless clearly marked experimental. See `AGENTS.md` § API Compatibility Rules. + +2. **Idiomatic Clojure, not a transliteration.** Use immutable data, core.async for events/concurrency, specs for validation, kebab-case keywords. See `AGENTS.md` § Design Philosophy. + +3. **Red/green TDD is mandatory.** Never implement without a failing test first. This catches wire conversion bugs (camelCase → kebab-case) and spec mismatches early. + +4. **Multi-model review catches different things.** Different model families tend to find different categories of issues — API contracts, logic/concurrency bugs, spec inconsistencies. Use at least three distinct families. + +5. **Wire conversion is the #1 source of bugs.** Always verify camelCase → kebab-case conversion for new fields. See `references/PROJECT.md` § Wire Conversion Cheat Sheet. + +6. **Event data specs use open maps.** `s/keys` allows extra keys, so new upstream fields pass through automatically. Add explicit specs for documentation and validation, not to gate functionality. + +7. **Pre-existing issues are out of scope.** If a reviewer finds a real issue that wasn't introduced by this sync, note it but don't fix it in the sync PR. Track separately. + +## Common Pitfalls + +These are verified sources of real bugs in this codebase: + +- **Boolean wire fields don't get `?` suffix.** camel-snake-kebab converts `resolvedByHook` → `:resolved-by-hook`, not `:resolved-by-hook?`. Code that manually maps wire booleans to `?`-suffixed keywords (like `:preview?`) must do so explicitly. +- **Forgetting `instrument.clj` allowlists.** Every new public function needs an `s/fdef` and entries in both `instrument-all!` and `unstrument-all!` lists. Integration tests run instrumented, so missing entries cause silent spec gaps. +- **Closed config key sets.** When adding session config options, update both `session-config-keys` and `resume-session-config-keys` in `specs.clj`. Missing a set causes the option to be silently stripped. +- **Mock data vs wire format.** Test fixtures should use wire-shaped data (camelCase) for mock server responses and Clojure-shaped data (kebab-case with `?` suffixes where applicable) for client-side assertions. diff --git a/.github/skills/update-upstream/references/PROJECT.md b/.github/skills/update-upstream/references/PROJECT.md new file mode 100644 index 0000000..102e745 --- /dev/null +++ b/.github/skills/update-upstream/references/PROJECT.md @@ -0,0 +1,41 @@ +# Upstream Sync Reference + +This reference supplements `AGENTS.md` (the canonical project reference) with sync-specific context. + +For project structure, testing commands, version format, changelog conventions, and code quality expectations, see `AGENTS.md`. + +## Upstream ↔ Clojure File Mapping + +When syncing, map upstream changes to the corresponding Clojure files: + +### Upstream (../copilot-sdk) + +| Upstream File | Contains | +|---------------|----------| +| `nodejs/src/types.ts` | Canonical type definitions (`SessionConfig`, `MessageOptions`, etc.) | +| `nodejs/src/client.ts` | `CopilotClient` methods, what params go on the wire | +| `nodejs/src/session.ts` | `CopilotSession` methods, event handling | +| `nodejs/src/index.ts` | Public exports (defines the public API surface) | +| `nodejs/src/generated/session-events.ts` | All event types and data shapes | +| `nodejs/src/generated/rpc.ts` | RPC method signatures | + +### Clojure Counterparts + +| Upstream Area | Clojure File | Notes | +|---------------|-------------|-------| +| Types / config | `specs.clj` | Session config, event data, permissions, tools | +| Client methods | `client.clj` | Broadcast handlers, create-client, create-session | +| Session methods | `session.clj` | send/receive, UI convenience methods | +| Event types | `client.clj` | `event-types` set, `subscribe-events!` | +| RPC methods | `protocol.clj` | JSON-RPC protocol layer | +| Public exports | `client.clj`, `helpers.clj`, `tools.clj` | Public API surface | +| Function specs | `instrument.clj` | fdefs for all public functions | + +## Wire Conversion Cheat Sheet + +camelCase → kebab-case conversion is handled by `util/wire->clj` and `util/clj->wire`. + +Test a conversion: `(csk/->kebab-case-keyword :yourCamelCaseField)` + +**Key rule**: camel-snake-kebab does **not** add a `?` suffix for booleans. +`resolvedByHook` → `:resolved-by-hook` (not `:resolved-by-hook?`). diff --git a/CHANGELOG.md b/CHANGELOG.md index 08314fb..b4efd2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,26 @@ All notable changes to this project will be documented in this file. This change ## [Unreleased] +### Added +- **Session RPC wrappers** — new experimental functions for session-level RPCs previously only accessible via `proto/send-request!`: + - `mode-get`, `mode-set!` — get/set agent mode (interactive/plan/autopilot) + - `plan-read`, `plan-update!`, `plan-delete!` — read/update/delete session plan file + - `workspace-list-files`, `workspace-read-file`, `workspace-create-file!` — session workspace file operations + - `agent-list`, `agent-get-current`, `agent-select!`, `agent-deselect!`, `agent-reload!` — custom agent management + - `fleet-start!` — start parallel sub-sessions +- **MCP config wrappers** — new experimental server-level functions in `client`: + - `mcp-config-list`, `mcp-config-add!`, `mcp-config-update!`, `mcp-config-remove!` — MCP server configuration management +- **Hooks integration tests** — 6 tests covering all hook types (preToolUse, postToolUse, sessionStart, unknownType, handler exceptions, no-hooks) +- **User input handler tests** — 2 tests for `userInput.request` server→client RPC +- **System message transform tests** — 3 tests for `systemMessage.transform` callback invocation, error fallback, and passthrough +- **Tool result normalization tests** — 3 tests for string, nil, and structured ToolResultObject results via v3 broadcast +- **Session RPC wrapper tests** — 18 integration tests for all new RPC wrapper functions +- **Mock server enhancements** — `send-rpc-request!` for testing server→client RPCs, response routing in server loop, 30+ new method stubs +- Full `s/fdef` instrumentation for all 19 new public functions + +### Changed (v0.2.1 sync) +- **`session.error` event data spec enriched** — optional `:status-code` (int), `:provider-call-id` (string), and `:url` (string) fields added to `::session.error-data` spec. These fields carry HTTP status codes, GitHub request tracing IDs, and actionable URLs from upstream error events (upstream PR #999, runtime 1.0.17). + ## [0.2.1.0] - 2026-04-04 ### Added (v0.2.1 sync) - **`resolvedByHook` guard on `permission.requested`** — when the runtime resolves a permission request via a `permissionRequest` hook, the broadcast event includes `resolvedByHook: true`. The SDK now skips the client's `:on-permission-request` handler and does not send the `handlePendingPermissionRequest` RPC, preventing duplicate responses. Event subscribers still observe the event (upstream PR #999, runtime 1.0.17). diff --git a/doc/reference/API.md b/doc/reference/API.md index 05bbc81..75fa5c0 100644 --- a/doc/reference/API.md +++ b/doc/reference/API.md @@ -486,6 +486,28 @@ Each quota snapshot map contains: | `:overage-allowed-with-exhausted-quota?` | boolean | Whether overage is allowed when quota is exhausted | | `:reset-date` | string (optional) | ISO 8601 date when quota resets | +#### `mcp-config-list` / `mcp-config-add!` / `mcp-config-update!` / `mcp-config-remove!` + +> **Experimental:** These wrap server-level MCP configuration RPCs and may change. + +```clojure +;; List configured MCP servers +(copilot/mcp-config-list client) +;; => {:servers [...]} + +;; Add a new MCP server config +(copilot/mcp-config-add! client {:name "my-server" + :command "npx" + :args ["-y" "@modelcontextprotocol/server-filesystem" "/tmp"] + :tools ["*"]}) + +;; Update an existing config +(copilot/mcp-config-update! client {:name "my-server" :tools ["read_file"]}) + +;; Remove a config +(copilot/mcp-config-remove! client {:name "my-server"}) +``` + #### `state` ```clojure @@ -930,6 +952,32 @@ Get the client that owns this session. ;; Enable/disable MCP servers (session/mcp-enable! my-session "my-server") (session/mcp-disable! my-session "my-server") + +;; Get/set agent mode +(session/mode-get my-session) +;; => {:mode "interactive"} +(session/mode-set! my-session "plan") + +;; Read/update session plan +(session/plan-read my-session) +;; => {:exists? true :content "# Plan\n..." :file-path "/path/to/plan.md"} +(session/plan-update! my-session "# Updated Plan\n...") +(session/plan-delete! my-session) + +;; Workspace file operations +(session/workspace-list-files my-session) +;; => {:files ["notes.md" "data.json"]} +(session/workspace-read-file my-session "notes.md") +;; => {:content "..."} +(session/workspace-create-file! my-session "output.txt" "result data") + +;; Custom agent management +(session/agent-list my-session) +;; => {:agents [{:name "researcher" ...} ...]} +(session/agent-select! my-session "researcher") +(session/agent-get-current my-session) +;; => {:name "researcher"} +(session/agent-deselect! my-session) ``` **Skills** @@ -959,6 +1007,45 @@ Get the client that owns this session. | `session/extensions-disable!` | Disable an extension by ID. | | `session/extensions-reload!` | Reload all extensions. | +**Mode** + +| Function | Description | +|----------|-------------| +| `session/mode-get` | Get current agent mode. Returns `{:mode "interactive"\|"plan"\|"autopilot"}`. | +| `session/mode-set!` | Set agent mode. Accepts `"interactive"`, `"plan"`, or `"autopilot"`. | + +**Plan** + +| Function | Description | +|----------|-------------| +| `session/plan-read` | Read the session plan file. Returns `{:exists? :content :file-path}`. | +| `session/plan-update!` | Update the plan file content. | +| `session/plan-delete!` | Delete the plan file. | + +**Workspace** + +| Function | Description | +|----------|-------------| +| `session/workspace-list-files` | List files in the session workspace. Returns `{:files [...]}`. | +| `session/workspace-read-file` | Read a workspace file by relative path. Returns `{:content "..."}`. | +| `session/workspace-create-file!` | Create a file in the workspace with given path and content. | + +**Agents** + +| Function | Description | +|----------|-------------| +| `session/agent-list` | List available custom agents. Returns `{:agents [...]}`. | +| `session/agent-get-current` | Get the currently selected agent. Returns `{:name "..."}` or `{:name nil}`. | +| `session/agent-select!` | Select a custom agent by name. | +| `session/agent-deselect!` | Deselect the current custom agent. | +| `session/agent-reload!` | Reload all custom agents. | + +**Fleet** + +| Function | Description | +|----------|-------------| +| `session/fleet-start!` | Start parallel sub-sessions. Accepts a params map. | + **Other** | Function | Description | @@ -1120,7 +1207,7 @@ Convert an unqualified event keyword to a namespace-qualified `:copilot/` keywor |------------|-------------| | `:copilot/session.start` | Session created | | `:copilot/session.resume` | Session resumed | -| `:copilot/session.error` | Session error occurred | +| `:copilot/session.error` | Session error occurred; data: `{:error-type "..." :message "..." :stack "..." :status-code 429 :provider-call-id "..." :url "..."}` (`:stack`, `:status-code`, `:provider-call-id`, `:url` optional) | | `:copilot/session.idle` | Session finished processing | | `:copilot/session.info` | Informational session update | | `:copilot/session.model_change` | Session model changed | diff --git a/src/github/copilot_sdk/client.clj b/src/github/copilot_sdk/client.clj index b427ab2..2a0fece 100644 --- a/src/github/copilot_sdk/client.clj +++ b/src/github/copilot_sdk/client.clj @@ -1210,6 +1210,47 @@ (:reset-date v) (assoc :reset-date (:reset-date v))))) {} snapshots))) +;; --------------------------------------------------------------------------- +;; MCP Config RPCs (server-level, not session-scoped) +;; --------------------------------------------------------------------------- + +(defn ^:experimental mcp-config-list + "List all MCP server configurations. + Returns a map with :servers (vector of server config maps)." + [client] + (ensure-connected! client) + (let [conn (:connection-io @(:state client))] + (util/wire->clj + (proto/send-request! conn "mcp.config.list" {})))) + +(defn ^:experimental mcp-config-add! + "Add an MCP server configuration. + params is a map with server config using plain keys (:name, :command, :args, + :tools, etc.) — NOT the :mcp-prefixed keys used in session config :mcp-servers." + [client params] + (ensure-connected! client) + (let [conn (:connection-io @(:state client))] + (util/wire->clj + (proto/send-request! conn "mcp.config.add" params)))) + +(defn ^:experimental mcp-config-update! + "Update an MCP server configuration. + params is a map with server config using plain keys (see mcp-config-add!)." + [client params] + (ensure-connected! client) + (let [conn (:connection-io @(:state client))] + (util/wire->clj + (proto/send-request! conn "mcp.config.update" params)))) + +(defn ^:experimental mcp-config-remove! + "Remove an MCP server configuration. + params is a map identifying the server using plain keys (see mcp-config-add!)." + [client params] + (ensure-connected! client) + (let [conn (:connection-io @(:state client))] + (util/wire->clj + (proto/send-request! conn "mcp.config.remove" params)))) + (defn- validate-session-config! "Validate session config, throwing on invalid input." [config] diff --git a/src/github/copilot_sdk/instrument.clj b/src/github/copilot_sdk/instrument.clj index bf743ed..af2df33 100644 --- a/src/github/copilot_sdk/instrument.clj +++ b/src/github/copilot_sdk/instrument.clj @@ -352,6 +352,83 @@ :args (s/cat) :ret nil?) +;; ----------------------------------------------------------------------------- +;; Session RPC wrapper function specs (experimental) +;; ----------------------------------------------------------------------------- + +(s/fdef github.copilot-sdk.session/mode-get + :args (s/cat :session ::specs/session) + :ret map?) + +(s/fdef github.copilot-sdk.session/mode-set! + :args (s/cat :session ::specs/session :mode string?) + :ret map?) + +(s/fdef github.copilot-sdk.session/plan-read + :args (s/cat :session ::specs/session) + :ret map?) + +(s/fdef github.copilot-sdk.session/plan-update! + :args (s/cat :session ::specs/session :content string?) + :ret map?) + +(s/fdef github.copilot-sdk.session/plan-delete! + :args (s/cat :session ::specs/session) + :ret map?) + +(s/fdef github.copilot-sdk.session/workspace-list-files + :args (s/cat :session ::specs/session) + :ret map?) + +(s/fdef github.copilot-sdk.session/workspace-read-file + :args (s/cat :session ::specs/session :path string?) + :ret map?) + +(s/fdef github.copilot-sdk.session/workspace-create-file! + :args (s/cat :session ::specs/session :path string? :content string?) + :ret map?) + +(s/fdef github.copilot-sdk.session/agent-list + :args (s/cat :session ::specs/session) + :ret map?) + +(s/fdef github.copilot-sdk.session/agent-get-current + :args (s/cat :session ::specs/session) + :ret map?) + +(s/fdef github.copilot-sdk.session/agent-select! + :args (s/cat :session ::specs/session :agent-name string?) + :ret map?) + +(s/fdef github.copilot-sdk.session/agent-deselect! + :args (s/cat :session ::specs/session) + :ret map?) + +(s/fdef github.copilot-sdk.session/agent-reload! + :args (s/cat :session ::specs/session) + :ret map?) + +(s/fdef github.copilot-sdk.session/fleet-start! + :args (s/cat :session ::specs/session :params map?) + :ret map?) + +;; Client-level MCP config function specs +(s/fdef github.copilot-sdk.client/mcp-config-list + :args (s/cat :client ::specs/client) + :ret map?) + +(s/fdef github.copilot-sdk.client/mcp-config-add! + :args (s/cat :client ::specs/client :params map?) + :ret map?) + +(s/fdef github.copilot-sdk.client/mcp-config-update! + :args (s/cat :client ::specs/client :params map?) + :ret map?) + +(s/fdef github.copilot-sdk.client/mcp-config-remove! + :args (s/cat :client ::specs/client :params map?) + :ret map?) + ;; ----------------------------------------------------------------------------- ;; Instrument all public API functions ;; ----------------------------------------------------------------------------- @@ -415,6 +492,24 @@ github.copilot-sdk.session/compaction-compact! github.copilot-sdk.session/shell-exec! github.copilot-sdk.session/shell-kill! + github.copilot-sdk.session/mode-get + github.copilot-sdk.session/mode-set! + github.copilot-sdk.session/plan-read + github.copilot-sdk.session/plan-update! + github.copilot-sdk.session/plan-delete! + github.copilot-sdk.session/workspace-list-files + github.copilot-sdk.session/workspace-read-file + github.copilot-sdk.session/workspace-create-file! + github.copilot-sdk.session/agent-list + github.copilot-sdk.session/agent-get-current + github.copilot-sdk.session/agent-select! + github.copilot-sdk.session/agent-deselect! + github.copilot-sdk.session/agent-reload! + github.copilot-sdk.session/fleet-start! + github.copilot-sdk.client/mcp-config-list + github.copilot-sdk.client/mcp-config-add! + github.copilot-sdk.client/mcp-config-update! + github.copilot-sdk.client/mcp-config-remove! github.copilot-sdk.session/ui-elicitation! github.copilot-sdk.session/capabilities github.copilot-sdk.session/elicitation-supported? @@ -489,6 +584,24 @@ github.copilot-sdk.session/compaction-compact! github.copilot-sdk.session/shell-exec! github.copilot-sdk.session/shell-kill! + github.copilot-sdk.session/mode-get + github.copilot-sdk.session/mode-set! + github.copilot-sdk.session/plan-read + github.copilot-sdk.session/plan-update! + github.copilot-sdk.session/plan-delete! + github.copilot-sdk.session/workspace-list-files + github.copilot-sdk.session/workspace-read-file + github.copilot-sdk.session/workspace-create-file! + github.copilot-sdk.session/agent-list + github.copilot-sdk.session/agent-get-current + github.copilot-sdk.session/agent-select! + github.copilot-sdk.session/agent-deselect! + github.copilot-sdk.session/agent-reload! + github.copilot-sdk.session/fleet-start! + github.copilot-sdk.client/mcp-config-list + github.copilot-sdk.client/mcp-config-add! + github.copilot-sdk.client/mcp-config-update! + github.copilot-sdk.client/mcp-config-remove! github.copilot-sdk.session/ui-elicitation! github.copilot-sdk.session/capabilities github.copilot-sdk.session/elicitation-supported? diff --git a/src/github/copilot_sdk/session.clj b/src/github/copilot_sdk/session.clj index 3ab5760..8c74921 100644 --- a/src/github/copilot_sdk/session.clj +++ b/src/github/copilot_sdk/session.clj @@ -1130,6 +1130,146 @@ (proto/send-request! conn "session.shell.kill" {:sessionId session-id :processId process-id}))) +;; -- Mode ------------------------------------------------------------------- + +(defn ^:experimental mode-get + "Get the current agent mode for the session. + Returns a map with :mode (\"interactive\", \"plan\", or \"autopilot\")." + [session] + (let [{:keys [session-id client]} session + conn (connection-io client)] + (util/wire->clj + (proto/send-request! conn "session.mode.get" {:sessionId session-id})))) + +(defn ^:experimental mode-set! + "Set the agent mode for the session. + mode should be \"interactive\", \"plan\", or \"autopilot\"." + [session mode] + (let [{:keys [session-id client]} session + conn (connection-io client)] + (util/wire->clj + (proto/send-request! conn "session.mode.set" {:sessionId session-id :mode mode})))) + +;; -- Plan ------------------------------------------------------------------- + +(defn ^:experimental plan-read + "Read the plan file for the session. + Returns a map with :exists? (boolean), :content (string or nil), + and :file-path (string or nil)." + [session] + (let [{:keys [session-id client]} session + conn (connection-io client) + result (util/wire->clj + (proto/send-request! conn "session.plan.read" {:sessionId session-id}))] + (if (contains? result :exists) + (-> result + (assoc :exists? (:exists result)) + (dissoc :exists)) + result))) + +(defn ^:experimental plan-update! + "Update the plan file content for the session." + [session content] + (let [{:keys [session-id client]} session + conn (connection-io client)] + (util/wire->clj + (proto/send-request! conn "session.plan.update" {:sessionId session-id :content content})))) + +(defn ^:experimental plan-delete! + "Delete the plan file for the session." + [session] + (let [{:keys [session-id client]} session + conn (connection-io client)] + (util/wire->clj + (proto/send-request! conn "session.plan.delete" {:sessionId session-id})))) + +;; -- Workspace -------------------------------------------------------------- + +(defn ^:experimental workspace-list-files + "List files in the session workspace directory. + Returns a map with :files (vector of relative file paths)." + [session] + (let [{:keys [session-id client]} session + conn (connection-io client)] + (util/wire->clj + (proto/send-request! conn "session.workspace.listFiles" {:sessionId session-id})))) + +(defn ^:experimental workspace-read-file + "Read a file from the session workspace. + path is relative to the workspace files directory. + Returns a map with :content (string)." + [session path] + (let [{:keys [session-id client]} session + conn (connection-io client)] + (util/wire->clj + (proto/send-request! conn "session.workspace.readFile" {:sessionId session-id :path path})))) + +(defn ^:experimental workspace-create-file! + "Create a file in the session workspace. + path is relative to the workspace files directory." + [session path content] + (let [{:keys [session-id client]} session + conn (connection-io client)] + (util/wire->clj + (proto/send-request! conn "session.workspace.createFile" + {:sessionId session-id :path path :content content})))) + +;; -- Agent ------------------------------------------------------------------ + +(defn ^:experimental agent-list + "List all custom agents available to the session. + Returns a map with :agents (vector of agent info maps)." + [session] + (let [{:keys [session-id client]} session + conn (connection-io client)] + (util/wire->clj + (proto/send-request! conn "session.agent.list" {:sessionId session-id})))) + +(defn ^:experimental agent-get-current + "Get the currently active custom agent for the session. + Returns a map with :name (string or nil)." + [session] + (let [{:keys [session-id client]} session + conn (connection-io client)] + (util/wire->clj + (proto/send-request! conn "session.agent.getCurrent" {:sessionId session-id})))) + +(defn ^:experimental agent-select! + "Select a custom agent by name." + [session agent-name] + (let [{:keys [session-id client]} session + conn (connection-io client)] + (util/wire->clj + (proto/send-request! conn "session.agent.select" {:sessionId session-id :name agent-name})))) + +(defn ^:experimental agent-deselect! + "Deselect the current custom agent." + [session] + (let [{:keys [session-id client]} session + conn (connection-io client)] + (util/wire->clj + (proto/send-request! conn "session.agent.deselect" {:sessionId session-id})))) + +(defn ^:experimental agent-reload! + "Reload all custom agents." + [session] + (let [{:keys [session-id client]} session + conn (connection-io client)] + (util/wire->clj + (proto/send-request! conn "session.agent.reload" {:sessionId session-id})))) + +;; -- Fleet ------------------------------------------------------------------ + +(defn ^:experimental fleet-start! + "Start a fleet of parallel sub-sessions. + params is a map forwarded to the session.fleet.start RPC." + [session params] + (let [{:keys [session-id client]} session + conn (connection-io client)] + (util/wire->clj + (proto/send-request! conn "session.fleet.start" + (assoc (merge {} params) :session-id session-id))))) + ;; -- UI Elicitation ---------------------------------------------------------- (defn capabilities diff --git a/src/github/copilot_sdk/specs.clj b/src/github/copilot_sdk/specs.clj index f10d13e..32ab405 100644 --- a/src/github/copilot_sdk/specs.clj +++ b/src/github/copilot_sdk/specs.clj @@ -625,9 +625,14 @@ :opt-un [::selected-model ::reasoning-effort ::already-in-use? ::remote-steerable? ::host-type ::head-commit ::base-commit])) +(s/def ::status-code integer?) +(s/def ::provider-call-id string?) +(s/def ::error-type string?) +(s/def ::stack string?) + (s/def ::session.error-data (s/keys :req-un [::error-type ::message] - :opt-un [::stack])) + :opt-un [::stack ::status-code ::provider-call-id ::url])) (s/def ::session.idle-data map?) diff --git a/test/github/copilot_sdk/integration_test.clj b/test/github/copilot_sdk/integration_test.clj index e55d00c..20c5166 100644 --- a/test/github/copilot_sdk/integration_test.clj +++ b/test/github/copilot_sdk/integration_test.clj @@ -1625,3 +1625,542 @@ (is (some? stored)) (is (fn? (:read-file stored))) (is (= {:content "hello"} ((:read-file stored) {:path "/test.txt"}))))))) + +;; ----------------------------------------------------------------------------- +;; Hooks Tests (server→client RPC) +;; ----------------------------------------------------------------------------- + +(deftest test-hooks-pre-tool-use + (testing "hooks.invoke preToolUse calls registered handler and returns result" + (let [handler-called (atom nil) + session (sdk/create-session *test-client* + {:on-permission-request sdk/approve-all + :hooks {:on-pre-tool-use + (fn [input ctx] + (reset! handler-called {:input input :ctx ctx}) + {:permission-decision "allow" + :additional-context "extra info"})}}) + session-id (sdk/session-id session) + response (mock/send-rpc-request! *mock-server* + "hooks.invoke" + {:sessionId session-id + :hookType "preToolUse" + :input {:toolName "bash" + :toolArgs {:command "echo hi"} + :timestamp 12345 + :cwd "/workspace"}})] + (is (some? @handler-called)) + ;; Input keys are converted to kebab-case by wire->clj + (is (= "bash" (get-in @handler-called [:input :tool-name]))) + (is (= {:command "echo hi"} (get-in @handler-called [:input :tool-args]))) + (is (= session-id (get-in @handler-called [:ctx :session-id]))) + ;; Response contains the handler's return value (wire-converted) + (is (= "allow" (get-in response [:result :permissionDecision])))))) + +(deftest test-hooks-post-tool-use + (testing "hooks.invoke postToolUse calls registered handler" + (let [handler-called (atom nil) + session (sdk/create-session *test-client* + {:on-permission-request sdk/approve-all + :hooks {:on-post-tool-use + (fn [input ctx] + (reset! handler-called {:input input :ctx ctx}) + nil)}}) + session-id (sdk/session-id session) + response (mock/send-rpc-request! *mock-server* + "hooks.invoke" + {:sessionId session-id + :hookType "postToolUse" + :input {:toolName "bash" + :toolArgs {} + :toolResult {:textResultForLlm "ok" + :resultType "success"} + :timestamp 12345 + :cwd "/workspace"}})] + (is (some? @handler-called)) + (is (= "bash" (get-in @handler-called [:input :tool-name]))) + ;; Handler returned nil, so result is nil + (is (nil? (:result response)))))) + +(deftest test-hooks-session-start + (testing "hooks.invoke sessionStart calls registered handler" + (let [handler-called (atom nil) + session (sdk/create-session *test-client* + {:on-permission-request sdk/approve-all + :hooks {:on-session-start + (fn [input ctx] + (reset! handler-called input) + {:additional-context "welcome"})}}) + session-id (sdk/session-id session) + response (mock/send-rpc-request! *mock-server* + "hooks.invoke" + {:sessionId session-id + :hookType "sessionStart" + :input {:source "new" + :timestamp 12345 + :cwd "/workspace"}})] + (is (some? @handler-called)) + (is (= "new" (:source @handler-called))) + (is (= "welcome" (get-in response [:result :additionalContext])))))) + +(deftest test-hooks-unknown-type-returns-nil + (testing "hooks.invoke with unknown hook type returns nil result" + (let [session (sdk/create-session *test-client* + {:on-permission-request sdk/approve-all + :hooks {:on-pre-tool-use (fn [_ _] {:permission-decision "allow"})}}) + session-id (sdk/session-id session) + response (mock/send-rpc-request! *mock-server* + "hooks.invoke" + {:sessionId session-id + :hookType "unknownHookType" + :input {:timestamp 12345 + :cwd "/workspace"}})] + (is (nil? (:result response)))))) + +(deftest test-hooks-handler-exception-returns-nil + (testing "hooks.invoke handler exception returns nil gracefully" + (let [session (sdk/create-session *test-client* + {:on-permission-request sdk/approve-all + :hooks {:on-pre-tool-use (fn [_ _] (throw (Exception. "oops")))}}) + session-id (sdk/session-id session) + response (mock/send-rpc-request! *mock-server* + "hooks.invoke" + {:sessionId session-id + :hookType "preToolUse" + :input {:toolName "bash" + :toolArgs {} + :timestamp 12345 + :cwd "/workspace"}})] + (is (nil? (:result response)))))) + +(deftest test-hooks-no-hooks-registered + (testing "hooks.invoke with no hooks registered returns nil" + (let [session (sdk/create-session *test-client* + {:on-permission-request sdk/approve-all}) + session-id (sdk/session-id session) + response (mock/send-rpc-request! *mock-server* + "hooks.invoke" + {:sessionId session-id + :hookType "preToolUse" + :input {:toolName "bash" + :toolArgs {} + :timestamp 12345 + :cwd "/workspace"}})] + (is (nil? (:result response)))))) + +;; ----------------------------------------------------------------------------- +;; User Input Handler Tests (server→client RPC) +;; ----------------------------------------------------------------------------- + +(deftest test-user-input-handler-invoked + (testing "userInput.request calls registered handler with correct shape" + (let [handler-called (atom nil) + session (sdk/create-session *test-client* + {:on-permission-request sdk/approve-all + :on-user-input-request + (fn [request ctx] + (reset! handler-called {:request request :ctx ctx}) + {:answer "option A" :was-freeform false})}) + session-id (sdk/session-id session) + response (mock/send-rpc-request! *mock-server* + "userInput.request" + {:sessionId session-id + :question "Which option?" + :choices ["option A" "option B"] + :allowFreeform true})] + (is (some? @handler-called)) + (is (= "Which option?" (get-in @handler-called [:request :question]))) + (is (= "option A" (get-in response [:result :answer]))) + (is (false? (get-in response [:result :wasFreeform])))))) + +(deftest test-user-input-no-handler-errors + (testing "userInput.request without handler returns error" + (let [session (sdk/create-session *test-client* + {:on-permission-request sdk/approve-all}) + session-id (sdk/session-id session)] + (is (thrown-with-msg? clojure.lang.ExceptionInfo + #"User input requested but no handler registered" + (mock/send-rpc-request! *mock-server* + "userInput.request" + {:sessionId session-id + :question "Which option?"})))))) + +;; ----------------------------------------------------------------------------- +;; System Message Transform Tests (server→client RPC) +;; ----------------------------------------------------------------------------- + +(deftest test-system-message-transform-callback + (testing "systemMessage.transform invokes registered transform callbacks" + (let [session (sdk/create-session *test-client* + {:on-permission-request sdk/approve-all + :system-message {:mode :customize + :sections {:identity {:action (fn [content] + (str content " EXTRA"))}}}}) + session-id (sdk/session-id session) + response (mock/send-rpc-request! *mock-server* + "systemMessage.transform" + {:sessionId session-id + :sections {:identity {:content "I am an agent."}}})] + (is (= "I am an agent. EXTRA" + (get-in response [:result :sections :identity :content])))))) + +(deftest test-system-message-transform-error-returns-original + (testing "systemMessage.transform returns original content on callback error" + (let [session (sdk/create-session *test-client* + {:on-permission-request sdk/approve-all + :system-message {:mode :customize + :sections {:identity {:action (fn [_] (throw (Exception. "fail")))}}}}) + session-id (sdk/session-id session) + response (mock/send-rpc-request! *mock-server* + "systemMessage.transform" + {:sessionId session-id + :sections {:identity {:content "original text"}}})] + (is (= "original text" + (get-in response [:result :sections :identity :content])))))) + +(deftest test-system-message-transform-no-callback-passthrough + (testing "systemMessage.transform passes through sections without callbacks" + (let [session (sdk/create-session *test-client* + {:on-permission-request sdk/approve-all + :system-message {:mode :customize + :sections {:identity {:action (fn [c] (str c "!"))}}}}) + session-id (sdk/session-id session) + response (mock/send-rpc-request! *mock-server* + "systemMessage.transform" + {:sessionId session-id + :sections {:identity {:content "hello"} + :tone {:content "be nice"}}})] + (is (= "hello!" (get-in response [:result :sections :identity :content]))) + (is (= "be nice" (get-in response [:result :sections :tone :content])))))) + +;; ----------------------------------------------------------------------------- +;; Tool Result Normalization Tests (v3 broadcast path) +;; ----------------------------------------------------------------------------- + +(deftest test-tool-result-string-passthrough + (testing "tool handler returning string is normalized to success result" + (let [requests (atom []) + rpc-latch (java.util.concurrent.CountDownLatch. 1) + _ (mock/set-request-hook! *mock-server* + (fn [method params] + (swap! requests conj {:method method :params params}) + (when (= "session.tools.handlePendingToolCall" method) + (.countDown rpc-latch)))) + session (sdk/create-session *test-client* + {:on-permission-request sdk/approve-all + :tools [{:tool-name "test-tool" + :tool-handler (fn [_args _inv] "hello world")}]}) + session-id (sdk/session-id session)] + (swap! (:state *test-client*) assoc :negotiated-protocol-version 3) + (reset! requests []) + (mock/send-session-event! *mock-server* session-id + "external_tool.requested" + {:requestId "tool-req-1" + :toolName "test-tool" + :toolCallId "tc-1" + :arguments {}}) + (is (.await rpc-latch 5 java.util.concurrent.TimeUnit/SECONDS)) + (let [rpcs (filter #(= "session.tools.handlePendingToolCall" (:method %)) @requests) + result (get-in (first rpcs) [:params :result])] + (is (= 1 (count rpcs))) + (is (map? result)) + (is (= "hello world" (:textResultForLlm result))) + (is (= "success" (:resultType result))))))) + +(deftest test-tool-result-nil-normalized + (testing "tool handler returning nil is normalized to failure result" + (let [requests (atom []) + rpc-latch (java.util.concurrent.CountDownLatch. 1) + _ (mock/set-request-hook! *mock-server* + (fn [method params] + (swap! requests conj {:method method :params params}) + (when (= "session.tools.handlePendingToolCall" method) + (.countDown rpc-latch)))) + session (sdk/create-session *test-client* + {:on-permission-request sdk/approve-all + :tools [{:tool-name "nil-tool" + :tool-handler (fn [_args _inv] nil)}]}) + session-id (sdk/session-id session)] + (swap! (:state *test-client*) assoc :negotiated-protocol-version 3) + (reset! requests []) + (mock/send-session-event! *mock-server* session-id + "external_tool.requested" + {:requestId "tool-req-2" + :toolName "nil-tool" + :toolCallId "tc-2" + :arguments {}}) + (is (.await rpc-latch 5 java.util.concurrent.TimeUnit/SECONDS)) + (let [rpcs (filter #(= "session.tools.handlePendingToolCall" (:method %)) @requests) + result (get-in (first rpcs) [:params :result])] + (is (= 1 (count rpcs))) + (is (map? result)) + (is (= "Tool returned no result" (:textResultForLlm result))) + (is (= "failure" (:resultType result))))))) + +(deftest test-tool-result-structured-object + (testing "tool handler returning structured ToolResultObject is forwarded correctly" + (let [requests (atom []) + rpc-latch (java.util.concurrent.CountDownLatch. 1) + _ (mock/set-request-hook! *mock-server* + (fn [method params] + (swap! requests conj {:method method :params params}) + (when (= "session.tools.handlePendingToolCall" method) + (.countDown rpc-latch)))) + session (sdk/create-session *test-client* + {:on-permission-request sdk/approve-all + :tools [{:tool-name "struct-tool" + :tool-handler (fn [_args _inv] + {:text-result-for-llm "all good" + :result-type "success" + :tool-telemetry {:latency-ms 42}})}]}) + session-id (sdk/session-id session)] + (swap! (:state *test-client*) assoc :negotiated-protocol-version 3) + (reset! requests []) + (mock/send-session-event! *mock-server* session-id + "external_tool.requested" + {:requestId "tool-req-3" + :toolName "struct-tool" + :toolCallId "tc-3" + :arguments {}}) + (is (.await rpc-latch 5 java.util.concurrent.TimeUnit/SECONDS)) + (let [rpcs (filter #(= "session.tools.handlePendingToolCall" (:method %)) @requests) + result (get-in (first rpcs) [:params :result])] + (is (= 1 (count rpcs))) + (is (map? result)) + (is (= "all good" (:textResultForLlm result))) + (is (= "success" (:resultType result))) + (is (= 42 (get-in result [:toolTelemetry :latencyMs]))))))) + +;; ----------------------------------------------------------------------------- +;; Session RPC Wrapper Tests (experimental session APIs) +;; ----------------------------------------------------------------------------- + +(deftest test-mode-get + (testing "mode-get calls session.mode.get RPC" + (let [requests (atom []) + _ (mock/set-request-hook! *mock-server* + (fn [method params] + (swap! requests conj {:method method :params params}))) + session (sdk/create-session *test-client* + {:on-permission-request sdk/approve-all})] + (let [result (session/mode-get session)] + (is (some? result)) + (is (= "interactive" (:mode result))) + (is (some #(= "session.mode.get" (:method %)) @requests)))))) + +(deftest test-mode-set + (testing "mode-set! calls session.mode.set RPC with mode param" + (let [requests (atom []) + _ (mock/set-request-hook! *mock-server* + (fn [method params] + (swap! requests conj {:method method :params params}))) + session (sdk/create-session *test-client* + {:on-permission-request sdk/approve-all})] + (session/mode-set! session "plan") + (let [mode-rpcs (filter #(= "session.mode.set" (:method %)) @requests)] + (is (= 1 (count mode-rpcs))) + (is (= "plan" (:mode (:params (first mode-rpcs))))))))) + +(deftest test-plan-read + (testing "plan-read calls session.plan.read RPC and returns normalized shape" + (let [requests (atom []) + _ (mock/set-request-hook! *mock-server* + (fn [method params] + (swap! requests conj {:method method :params params}))) + session (sdk/create-session *test-client* + {:on-permission-request sdk/approve-all})] + (let [result (session/plan-read session)] + (is (some? result)) + (is (some #(= "session.plan.read" (:method %)) @requests)) + ;; Mock returns {:exists false :content nil :filePath nil} + ;; plan-read renames :exists → :exists? and wire->clj converts :filePath → :file-path + (is (contains? result :exists?) ":exists key should be renamed to :exists?") + (is (false? (:exists? result))) + (is (nil? (:content result))))))) + +(deftest test-plan-update + (testing "plan-update! calls session.plan.update RPC with content" + (let [requests (atom []) + _ (mock/set-request-hook! *mock-server* + (fn [method params] + (swap! requests conj {:method method :params params}))) + session (sdk/create-session *test-client* + {:on-permission-request sdk/approve-all})] + (session/plan-update! session "# My Plan\n\nStep 1: ...") + (let [plan-rpcs (filter #(= "session.plan.update" (:method %)) @requests)] + (is (= 1 (count plan-rpcs))) + (is (= "# My Plan\n\nStep 1: ..." (:content (:params (first plan-rpcs))))))))) + +(deftest test-plan-delete + (testing "plan-delete! calls session.plan.delete RPC" + (let [requests (atom []) + _ (mock/set-request-hook! *mock-server* + (fn [method params] + (swap! requests conj {:method method :params params}))) + session (sdk/create-session *test-client* + {:on-permission-request sdk/approve-all})] + (session/plan-delete! session) + (is (some #(= "session.plan.delete" (:method %)) @requests))))) + +(deftest test-workspace-list-files + (testing "workspace-list-files calls session.workspace.listFiles RPC" + (let [requests (atom []) + _ (mock/set-request-hook! *mock-server* + (fn [method params] + (swap! requests conj {:method method :params params}))) + session (sdk/create-session *test-client* + {:on-permission-request sdk/approve-all})] + (let [result (session/workspace-list-files session)] + (is (some? result)) + (is (some #(= "session.workspace.listFiles" (:method %)) @requests)))))) + +(deftest test-workspace-read-file + (testing "workspace-read-file calls session.workspace.readFile RPC with path" + (let [requests (atom []) + _ (mock/set-request-hook! *mock-server* + (fn [method params] + (swap! requests conj {:method method :params params}))) + session (sdk/create-session *test-client* + {:on-permission-request sdk/approve-all})] + (session/workspace-read-file session "notes.md") + (let [rpcs (filter #(= "session.workspace.readFile" (:method %)) @requests)] + (is (= 1 (count rpcs))) + (is (= "notes.md" (:path (:params (first rpcs))))))))) + +(deftest test-workspace-create-file + (testing "workspace-create-file! calls session.workspace.createFile RPC" + (let [requests (atom []) + _ (mock/set-request-hook! *mock-server* + (fn [method params] + (swap! requests conj {:method method :params params}))) + session (sdk/create-session *test-client* + {:on-permission-request sdk/approve-all})] + (session/workspace-create-file! session "test.txt" "content here") + (let [rpcs (filter #(= "session.workspace.createFile" (:method %)) @requests)] + (is (= 1 (count rpcs))) + (is (= "test.txt" (:path (:params (first rpcs))))) + (is (= "content here" (:content (:params (first rpcs))))))))) + +(deftest test-agent-list + (testing "agent-list calls session.agent.list RPC" + (let [requests (atom []) + _ (mock/set-request-hook! *mock-server* + (fn [method params] + (swap! requests conj {:method method :params params}))) + session (sdk/create-session *test-client* + {:on-permission-request sdk/approve-all})] + (let [result (session/agent-list session)] + (is (some? result)) + (is (some #(= "session.agent.list" (:method %)) @requests)))))) + +(deftest test-agent-select + (testing "agent-select! calls session.agent.select RPC with agent name" + (let [requests (atom []) + _ (mock/set-request-hook! *mock-server* + (fn [method params] + (swap! requests conj {:method method :params params}))) + session (sdk/create-session *test-client* + {:on-permission-request sdk/approve-all})] + (session/agent-select! session "researcher") + (let [rpcs (filter #(= "session.agent.select" (:method %)) @requests)] + (is (= 1 (count rpcs))) + (is (= "researcher" (:name (:params (first rpcs))))))))) + +(deftest test-agent-deselect + (testing "agent-deselect! calls session.agent.deselect RPC" + (let [requests (atom []) + _ (mock/set-request-hook! *mock-server* + (fn [method params] + (swap! requests conj {:method method :params params}))) + session (sdk/create-session *test-client* + {:on-permission-request sdk/approve-all})] + (session/agent-deselect! session) + (is (some #(= "session.agent.deselect" (:method %)) @requests))))) + +(deftest test-fleet-start + (testing "fleet-start! calls session.fleet.start RPC with session-id forced" + (let [requests (atom []) + _ (mock/set-request-hook! *mock-server* + (fn [method params] + (swap! requests conj {:method method :params params}))) + session (sdk/create-session *test-client* + {:on-permission-request sdk/approve-all}) + session-id (sdk/session-id session)] + ;; Pass params that attempt to override session-id + (session/fleet-start! session {:prompt "do stuff" :session-id "evil-override"}) + (let [rpcs (filter #(= "session.fleet.start" (:method %)) @requests)] + (is (= 1 (count rpcs))) + ;; Session-id must be the real one, not the override + (is (= session-id (:sessionId (:params (first rpcs))))) + (is (= "do stuff" (:prompt (:params (first rpcs))))))))) + +(deftest test-mcp-config-list + (testing "mcp-config-list calls mcp.config.list RPC" + (let [requests (atom []) + _ (mock/set-request-hook! *mock-server* + (fn [method params] + (swap! requests conj {:method method :params params}))) + result (client/mcp-config-list *test-client*)] + (is (some? result)) + (is (some #(= "mcp.config.list" (:method %)) @requests))))) + +(deftest test-mcp-config-add + (testing "mcp-config-add! calls mcp.config.add RPC with params" + (let [requests (atom []) + _ (mock/set-request-hook! *mock-server* + (fn [method params] + (swap! requests conj {:method method :params params}))) + result (client/mcp-config-add! *test-client* + {:name "my-server" :command "npx" :args ["-y" "server"]})] + (is (some? result)) + (let [rpcs (filter #(= "mcp.config.add" (:method %)) @requests)] + (is (= 1 (count rpcs))) + (is (= "my-server" (:name (:params (first rpcs))))))))) + +(deftest test-mcp-config-update + (testing "mcp-config-update! calls mcp.config.update RPC with params" + (let [requests (atom []) + _ (mock/set-request-hook! *mock-server* + (fn [method params] + (swap! requests conj {:method method :params params}))) + result (client/mcp-config-update! *test-client* + {:name "my-server" :tools ["read_file"]})] + (is (some? result)) + (let [rpcs (filter #(= "mcp.config.update" (:method %)) @requests)] + (is (= 1 (count rpcs))) + (is (= "my-server" (:name (:params (first rpcs))))))))) + +(deftest test-mcp-config-remove + (testing "mcp-config-remove! calls mcp.config.remove RPC with params" + (let [requests (atom []) + _ (mock/set-request-hook! *mock-server* + (fn [method params] + (swap! requests conj {:method method :params params}))) + result (client/mcp-config-remove! *test-client* {:name "my-server"})] + (is (some? result)) + (let [rpcs (filter #(= "mcp.config.remove" (:method %)) @requests)] + (is (= 1 (count rpcs))) + (is (= "my-server" (:name (:params (first rpcs))))))))) + +(deftest test-agent-get-current + (testing "agent-get-current calls session.agent.getCurrent RPC" + (let [requests (atom []) + _ (mock/set-request-hook! *mock-server* + (fn [method params] + (swap! requests conj {:method method :params params}))) + session (sdk/create-session *test-client* + {:on-permission-request sdk/approve-all}) + result (session/agent-get-current session)] + (is (some? result)) + (is (some #(= "session.agent.getCurrent" (:method %)) @requests))))) + +(deftest test-agent-reload + (testing "agent-reload! calls session.agent.reload RPC" + (let [requests (atom []) + _ (mock/set-request-hook! *mock-server* + (fn [method params] + (swap! requests conj {:method method :params params}))) + session (sdk/create-session *test-client* + {:on-permission-request sdk/approve-all})] + (session/agent-reload! session) + (is (some #(= "session.agent.reload" (:method %)) @requests))))) diff --git a/test/github/copilot_sdk/mock_server.clj b/test/github/copilot_sdk/mock_server.clj index 85e91bb..a594e64 100644 --- a/test/github/copilot_sdk/mock_server.clj +++ b/test/github/copilot_sdk/mock_server.clj @@ -64,7 +64,8 @@ message-id ; AtomicLong for generating IDs ;; Hooks for testing on-request ; atom fn - called for each request - pending-events]) ; atom - events to send on next opportunity + pending-events ; atom - events to send on next opportunity + pending-responses]) ; atom {id -> chan} - responses to server→client RPCs (defn- generate-id [^AtomicLong counter] (str "evt-" (.incrementAndGet counter))) @@ -315,6 +316,43 @@ "session.log" (handle-session-log server params) "session.permissions.handlePendingPermissionRequest" {:ok true} "session.commands.handlePendingCommand" {:ok true} + "session.tools.handlePendingToolCall" {:ok true} + "session.ui.handlePendingElicitation" {:ok true} + "session.mode.get" {:mode "interactive"} + "session.mode.set" {:mode (get params :mode "interactive")} + "session.plan.read" {:exists false :content nil :filePath nil} + "session.plan.update" {:success true} + "session.plan.delete" {:success true} + "session.workspace.listFiles" {:files []} + "session.workspace.readFile" {:content ""} + "session.workspace.createFile" {:success true} + "session.agent.list" {:agents []} + "session.agent.getCurrent" {:name nil} + "session.agent.select" {:success true} + "session.agent.deselect" {:success true} + "session.agent.reload" {:success true} + "session.fleet.start" {:success true} + "session.skills.list" {:skills []} + "session.skills.enable" {:success true} + "session.skills.disable" {:success true} + "session.skills.reload" {:success true} + "session.mcp.list" {:servers []} + "session.mcp.enable" {:success true} + "session.mcp.disable" {:success true} + "session.mcp.reload" {:success true} + "session.extensions.list" {:extensions []} + "session.extensions.enable" {:success true} + "session.extensions.disable" {:success true} + "session.extensions.reload" {:success true} + "session.plugins.list" {:plugins []} + "session.compaction.compact" {:success true} + "session.shell.exec" {:exitCode 0 :stdout "" :stderr ""} + "session.shell.kill" {:success true} + "session.ui.elicitation" {:action "accept" :content {}} + "mcp.config.list" {:servers []} + "mcp.config.add" {:success true} + "mcp.config.update" {:success true} + "mcp.config.remove" {:success true} (throw (ex-info "Method not found" {:code -32601 :method method}))) ;; Merge hook-provided data into result only when hook returns ::merge-response ;; This prevents accidental response mutation from spy hooks (e.g. swap! return values) @@ -332,16 +370,28 @@ (try (while @(:running? server) (if-let [msg (read-message (:reader server))] - (try - (let [response (handle-request server msg)] - (write-message (:writer server) response)) - (catch Exception e - (let [error-data (ex-data e)] - (write-message (:writer server) - {:jsonrpc "2.0" - :id (:id msg) - :error {:code (or (:code error-data) -32603) - :message (.getMessage e)}})))) + (if (:method msg) + (if (contains? msg :id) + ;; It's a request (has :method and :id) — handle it and respond + (try + (let [response (handle-request server msg)] + (write-message (:writer server) response)) + (catch Exception e + (let [error-data (ex-data e)] + (write-message (:writer server) + {:jsonrpc "2.0" + :id (:id msg) + :error {:code (or (:code error-data) -32603) + :message (.getMessage e)}})))) + ;; It's a notification (has :method but no :id) — process silently + (try + (handle-request server msg) + (catch Exception _ nil))) + ;; It's a response to a server→client RPC — deliver to pending promise + (when-let [id (:id msg)] + (when-let [response-ch (get @(:pending-responses server) id)] + (put! response-ch msg) + (swap! (:pending-responses server) dissoc id)))) ;; EOF or closed - exit loop (reset! (:running? server) false))) (catch Exception e @@ -370,7 +420,8 @@ :sessions (atom {}) :message-id (AtomicLong. 0) :on-request (atom nil) - :pending-events (atom [])}))) + :pending-events (atom []) + :pending-responses (atom {})}))) (defn start-mock-server! "Start the mock server in a background thread." @@ -463,3 +514,31 @@ "Set context on a mock session (for testing list-sessions with context)." [server session-id context] (swap! (:sessions server) assoc-in [session-id :context] context)) + +(defn send-rpc-request! + "Send a JSON-RPC request to the client and wait for the response. + Simulates server→client RPCs like hooks.invoke, userInput.request, + systemMessage.transform. Blocks until the client responds or timeout. + Must NOT be called from the mock server's server-loop thread. + Returns the full JSON-RPC response map on success. + Throws ex-info on timeout or when the client responds with :error." + [server method params & {:keys [timeout-ms] :or {timeout-ms 5000}}] + (let [id (generate-id (:message-id server)) + response-ch (chan 1)] + (swap! (:pending-responses server) assoc id response-ch) + (write-message (:writer server) + {:jsonrpc "2.0" + :id id + :method method + :params params}) + (let [[result _] (async/alts!! [response-ch (async/timeout timeout-ms)])] + (swap! (:pending-responses server) dissoc id) + (close! response-ch) + (if (nil? result) + (throw (ex-info "Timed out waiting for RPC response" + {:method method :timeout-ms timeout-ms})) + (if (:error result) + (throw (ex-info (get-in result [:error :message] "RPC error") + {:code (get-in result [:error :code]) + :data (get-in result [:error :data])})) + result))))) diff --git a/test/github/copilot_sdk_test.clj b/test/github/copilot_sdk_test.clj index a529519..45e5edc 100644 --- a/test/github/copilot_sdk_test.clj +++ b/test/github/copilot_sdk_test.clj @@ -47,6 +47,43 @@ (testing "invalid states" (is (not (s/valid? ::specs/connection-state :invalid))))) +(deftest session-error-data-spec-test + (testing "minimal valid session.error data" + (is (s/valid? ::specs/session.error-data + {:error-type "authentication" :message "Auth failed"}))) + + (testing "with all optional fields" + (is (s/valid? ::specs/session.error-data + {:error-type "quota" + :message "Rate limit exceeded" + :stack "at foo.bar (line 42)" + :status-code 429 + :provider-call-id "abc-123-def" + :url "https://example.com/billing"}))) + + (testing "with subset of optional fields" + (is (s/valid? ::specs/session.error-data + {:error-type "query" + :message "Context too large" + :status-code 400}))) + + (testing "invalid: missing required fields" + (is (not (s/valid? ::specs/session.error-data {}))) + (is (not (s/valid? ::specs/session.error-data {:error-type "auth"}))) + (is (not (s/valid? ::specs/session.error-data {:message "fail"})))) + + (testing "invalid: wrong types for optional fields" + (is (not (s/valid? ::specs/session.error-data + {:error-type "auth" :message "fail" :status-code "not-a-number"}))) + (is (not (s/valid? ::specs/session.error-data + {:error-type "auth" :message "fail" :provider-call-id 123})))) + + (testing "invalid: wrong types for required and pre-existing fields" + (is (not (s/valid? ::specs/session.error-data + {:error-type 42 :message "fail"}))) + (is (not (s/valid? ::specs/session.error-data + {:error-type "auth" :message "fail" :stack 99}))))) + ;; ============================================================================= ;; Client Tests ;; =============================================================================