diff --git a/.github/workflows/dotnet-sdk-tests.yml b/.github/workflows/dotnet-sdk-tests.yml index 37cdc07f7..fa2e2dc75 100644 --- a/.github/workflows/dotnet-sdk-tests.yml +++ b/.github/workflows/dotnet-sdk-tests.yml @@ -12,7 +12,7 @@ permissions: jobs: test: - name: ".NET SDK Tests (${{ matrix.os }}, ${{ matrix.transport }}, ${{ matrix.backend }})" + name: ".NET SDK Tests (${{ matrix.os }}, ${{ matrix.transport }}, ${{ matrix.backend }}, ${{ matrix.shard }})" if: github.event.repository.fork == false env: POWERSHELL_UPDATECHECK: Off @@ -24,22 +24,41 @@ jobs: os: [ubuntu-latest, macos-latest, windows-latest] transport: ["default", "inprocess"] backend: [capi] + shard: [full] # TODO: Re-enable after fixing in-process sqlite file locking on shutdown on Windows. exclude: - os: windows-latest transport: "inprocess" + - os: windows-latest + transport: default + shard: full include: + # Keep xUnit serial within each process, but split the slow Windows + # default-transport suite across two isolated test hosts. Keep both + # target frameworks in each shard: separate framework jobs did not + # shorten the critical path and doubled the Windows job count. + - os: windows-latest + transport: default + backend: capi + shard: "1" + - os: windows-latest + transport: default + backend: capi + shard: "2" - os: ubuntu-latest transport: inprocess backend: anthropic-messages + shard: full test-filter: "FullyQualifiedName~GitHub.Copilot.Test.E2E&E2EBackend!=SelfConfiguredBackend&E2EBackend!=CapiOnly" - os: ubuntu-latest transport: inprocess backend: openai-responses + shard: full test-filter: "FullyQualifiedName~GitHub.Copilot.Test.E2E&E2EBackend!=SelfConfiguredBackend&E2EBackend!=CapiOnly" - os: ubuntu-latest transport: inprocess backend: openai-completions + shard: full test-filter: "FullyQualifiedName~GitHub.Copilot.Test.E2E&E2EBackend!=SelfConfiguredBackend&E2EBackend!=CapiOnly" runs-on: ${{ matrix.os }} defaults: @@ -92,9 +111,30 @@ jobs: - name: Run .NET SDK tests env: COPILOT_HMAC_KEY: ${{ secrets.COPILOT_DEVELOPER_CLI_INTEGRATION_HMAC_KEY }} + DOTNET_TEST_SHARD: ${{ matrix.shard }} run: | args=(--no-build -v n) - if [[ -n "$DOTNET_TEST_FILTER" ]]; then - args+=(--filter "$DOTNET_TEST_FILTER") + + filter="$DOTNET_TEST_FILTER" + if [[ "$DOTNET_TEST_SHARD" != "full" ]]; then + if [[ "$DOTNET_TEST_SHARD" == "1" ]]; then + initials=(A C D H I J K L N Q S U W Y) + shard_filter="FullyQualifiedName~GitHub.Copilot.Test.ConnectionToken" + else + initials=(B E F G M O P R T V X Z) + shard_filter="" + fi + + for namespace in E2E Unit; do + for initial in "${initials[@]}"; do + clause="FullyQualifiedName~GitHub.Copilot.Test.${namespace}.${initial}" + shard_filter="${shard_filter:+${shard_filter}|}${clause}" + done + done + filter="${filter:+(${filter})&}(${shard_filter})" + fi + + if [[ -n "$filter" ]]; then + args+=(--filter "$filter") fi dotnet test "${args[@]}" diff --git a/.github/workflows/java-publish-maven.yml b/.github/workflows/java-publish-maven.yml index 944a0ee38..e293d9127 100644 --- a/.github/workflows/java-publish-maven.yml +++ b/.github/workflows/java-publish-maven.yml @@ -199,21 +199,9 @@ jobs: working-directory: ./java run: | VERSION="${{ steps.versions.outputs.release_version }}" - - # Update release version in README.md (supports any version qualifier like -java.N, -java-preview.N, -beta-java.N) - sed -i "s|[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\(-[a-z][a-z0-9-]*\.[0-9][0-9]*\)*|${VERSION}|g" README.md - sed -i "s|copilot-sdk-java:[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\(-[a-z][a-z0-9-]*\.[0-9][0-9]*\)*|copilot-sdk-java:${VERSION}|g" README.md - - # Update snapshot versions in README.md (must run AFTER release version seds - # because the release copilot-sdk-java: pattern partially matches inside snapshot - # strings — the snapshot-specific seds override with the correct DEV_VERSION) DEV_VERSION="${{ steps.versions.outputs.dev_version }}" - sed -i "s|[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\(-[a-z][a-z0-9-]*\.[0-9][0-9]*\)*-SNAPSHOT|${DEV_VERSION}|g" README.md - sed -i "s|copilot-sdk-java:[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\(-[a-z][a-z0-9-]*\.[0-9][0-9]*\)*-SNAPSHOT|copilot-sdk-java:${DEV_VERSION}|g" README.md - - # Update version in jbang-example.java - sed -i "s|copilot-sdk-java:[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\(-[a-z][a-z0-9-]*\.[0-9][0-9]*\)*|copilot-sdk-java:${VERSION}|g" jbang-example.java - sed -i 's|copilot-sdk-java:${project\.version}|copilot-sdk-java:'"${VERSION}"'|g' jbang-example.java + ./scripts/test-update-documentation-versions.sh + ./scripts/update-documentation-versions.sh "$VERSION" "$DEV_VERSION" README.md jbang-example.java # Commit the documentation changes before release:prepare (requires clean working directory) git add README.md jbang-example.java diff --git a/.github/workflows/java-sdk-tests.yml b/.github/workflows/java-sdk-tests.yml index c92b5291a..20083749e 100644 --- a/.github/workflows/java-sdk-tests.yml +++ b/.github/workflows/java-sdk-tests.yml @@ -45,6 +45,10 @@ jobs: with: node-version: 22 + - name: Test documentation version updater + if: matrix.test-jdk == '25' + run: ./scripts/test-update-documentation-versions.sh + - name: Build SDK and set up test harness run: mvn test-compile jar:jar diff --git a/.github/workflows/python-sdk-tests.yml b/.github/workflows/python-sdk-tests.yml index 8c4d5806d..1ea973975 100644 --- a/.github/workflows/python-sdk-tests.yml +++ b/.github/workflows/python-sdk-tests.yml @@ -79,4 +79,6 @@ jobs: - name: Run Python SDK tests env: COPILOT_HMAC_KEY: ${{ secrets.COPILOT_DEVELOPER_CLI_INTEGRATION_HMAC_KEY }} - run: uv run pytest -v -s + # Keep each module's shared E2E client and proxy on one process while + # running independent modules concurrently in isolated workers. + run: uv run pytest -v -s -n 2 --dist=loadfile diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f60625ec1..5135e596d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -33,63 +33,26 @@ We are generally **not** looking for: - Additional documentation - **SDKs for other languages** — if you want to create a Copilot SDK for another language, we'd love to hear from you and may offer to link to your SDK from our repo. However we do not plan to add further language-specific SDKs to this repo in the short term, since we need to retain our maintenance capacity for moving forwards quickly with the existing language set. For other languages, please consider running your own external project. -## Prerequisites for Running and Testing Code +## Developing an SDK -This is a multi-language SDK repository. Install the tools for the SDK(s) you plan to work on: +Setup, build, and test instructions are maintained with each SDK: -### All SDKs - -1. The end-to-end tests across all languages use a shared test harness written in Node.js. Before running tests in any language, `cd test/harness && npm ci`. - -### Node.js/TypeScript SDK - -1. Install [Node.js](https://nodejs.org/) (v18+) -1. Install dependencies: `cd nodejs && npm ci` - -### Python SDK - -1. Install [Python 3.8+](https://www.python.org/downloads/) -1. Install [uv](https://github.com/astral-sh/uv) -1. Install dependencies: `cd python && uv pip install -e . --group dev` - -### Go SDK - -1. Install [Go 1.24+](https://go.dev/doc/install) -1. Install [golangci-lint](https://golangci-lint.run/welcome/install/#local-installation) -1. Install dependencies: `cd go && go mod download` - -### .NET SDK - -1. Install [.NET SDK 10+](https://dotnet.microsoft.com/download) -1. Install .NET dependencies: `cd dotnet && dotnet restore` +- [Node.js/TypeScript](nodejs/README.md#development) +- [Python](python/README.md#development) +- [Go](go/README.md#development) +- [.NET](dotnet/README.md#development) +- [Rust](rust/README.md#development) +- [Java](java/README.md#development-setup) ## Submitting a Pull Request 1. Fork and clone the repository -1. Install dependencies for the SDK(s) you're modifying (see above) -1. Make sure the tests pass on your machine (see commands below) -1. Make sure linter passes on your machine (see commands below) +1. Follow the development instructions for the SDK(s) you're modifying 1. Create a new branch: `git checkout -b my-branch-name` -1. Make your change, add tests, and make sure the tests and linter still pass +1. Make your change, add tests, and run the documented checks 1. Push to your fork and [submit a pull request][pr] 1. Pat yourself on the back and wait for your pull request to be reviewed and merged. -### Running Tests and Linters - -```bash -# Node.js -cd nodejs && npm test && npm run lint - -# Python -cd python && uv run pytest && uv run ruff check . - -# Go -cd go && go test ./... && golangci-lint run ./... - -# .NET -cd dotnet && dotnet test test/GitHub.Copilot.SDK.Test.csproj -``` - Here are a few things you can do that will increase the likelihood of your pull request being accepted: - Write tests. diff --git a/README.md b/README.md index 5ad2b7127..b2ef69d05 100644 --- a/README.md +++ b/README.md @@ -21,9 +21,9 @@ The GitHub Copilot SDK exposes the same engine behind Copilot CLI: a production- | ------------------------ | ----------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------ | | **Node.js / TypeScript** | [`nodejs/`](./nodejs/) | [Cookbook](https://github.com/github/awesome-copilot/blob/main/cookbook/copilot-sdk/nodejs/README.md) | `npm install @github/copilot-sdk` | | | **Python** | [`python/`](./python/) | [Cookbook](https://github.com/github/awesome-copilot/blob/main/cookbook/copilot-sdk/python/README.md) | `pip install github-copilot-sdk` | | -| **Go** | [`go/`](./go/) | [Cookbook](https://github.com/github/awesome-copilot/blob/main/cookbook/copilot-sdk/go/README.md) | `go get github.com/github/copilot-sdk/go` | [API docs](https://pkg.go.dev/github.com/github/copilot-sdk/go) | +| **Go** | [`go/`](./go/) | [Cookbook](https://github.com/github/awesome-copilot/blob/main/cookbook/copilot-sdk/go/README.md) | `go get github.com/github/copilot-sdk/go` | [API docs](https://pkg.go.dev/github.com/github/copilot-sdk/go#readme-api-reference) | | **.NET** | [`dotnet/`](./dotnet/) | [Cookbook](https://github.com/github/awesome-copilot/blob/main/cookbook/copilot-sdk/dotnet/README.md) | `dotnet add package GitHub.Copilot.SDK` | | -| **Rust** | [`rust/`](./rust/) | — | `cargo add github-copilot-sdk` | [API docs](https://docs.rs/github-copilot-sdk/latest/github_copilot_sdk/) | +| **Rust** | [`rust/`](./rust/) | — | `cargo add github-copilot-sdk` | [API docs](https://docs.rs/github-copilot-sdk/latest/github_copilot_sdk/#api-reference) | | **Java** | [`java/`](./java/) | [Cookbook](https://github.com/github/awesome-copilot/blob/main/cookbook/copilot-sdk/java/README.md) | Maven coordinates
`com.github:copilot-sdk-java`
See instructions for [Maven](./java/README.md#maven) and [Gradle](./java/README.md#gradle) | [API docs](https://javadoc.io/doc/com.github/copilot-sdk-java/latest/) | See the individual SDK READMEs for installation, usage examples, and API reference. diff --git a/docs/README.md b/docs/README.md index ebb287d98..3be019f14 100644 --- a/docs/README.md +++ b/docs/README.md @@ -63,6 +63,7 @@ Detailed API reference for each session hook. * [Pre-Tool Use](./hooks/pre-tool-use.md): approve, deny, or modify tool calls * [Post-Tool Use](./hooks/post-tool-use.md): transform tool results * [User Prompt Submitted](./hooks/user-prompt-submitted.md): modify or filter user messages +* [User Prompt Transformed](./hooks/user-prompt-transformed.md): inspect or replace model-facing prompts * [Session Lifecycle](./hooks/session-lifecycle.md): session start and end * [Error Handling](./hooks/error-handling.md): custom error handling diff --git a/docs/features/README.md b/docs/features/README.md index ef9ae996d..9f3b937c9 100644 --- a/docs/features/README.md +++ b/docs/features/README.md @@ -16,6 +16,7 @@ These guides cover the capabilities you can add to your Copilot SDK application. | [Skills](./skills.md) | Load reusable prompt modules from directories | | [Plugin Directories](./plugin-directories.md) | Bundle skills, hooks, MCP servers, and agents as a single loadable plugin | | [Session limits](./session-limits.md) | Set an AI Credits budget for a session and observe budget events | +| [Citations](./citations.md) | Link assistant responses back to their supporting sources | | [Image Input](./image-input.md) | Send images to sessions as attachments | | [Streaming Events](./streaming-events.md) | Subscribe to real-time session events (40+ event types) | | [Usage and Billing](./usage-and-billing.md) | Read token counts, context-window utilization, AI credit cost, and account quota | diff --git a/docs/features/citations.md b/docs/features/citations.md new file mode 100644 index 000000000..b68ae292c --- /dev/null +++ b/docs/features/citations.md @@ -0,0 +1,443 @@ +# Citations + +Citations link spans of an assistant response back to the sources that support them. Turn on `enableCitations` when you create or resume a session, then read the `citations` payload on `assistant.message` events to render footnotes, source lists, or inline links. + +> [!WARNING] +> Citations are experimental. The option name, event payload, and provider coverage can change in a future release. + +## How citations work + +Citations are produced by the model provider, not by the SDK. The flow has three parts: + +1. Your application supplies citable material, such as a document attachment or a tool result that carries source content. +1. The runtime marks that material as citable on the wire when `enableCitations` is on. For Anthropic models, file attachments are sent as `document` blocks with citations enabled. +1. The model returns citation metadata, and the runtime normalizes it into a provider-agnostic `citations` object on the final `assistant.message` event. + +Provider support is limited. The `provider` field on each source records where the citation came from: + +| Provider value | Meaning | +|---|---| +| `anthropic` | Citation produced by an Anthropic (Claude) model response | +| `openai` | Citation produced by an OpenAI model response | +| `client` | Citation synthesized by the runtime from tool output | + +> [!NOTE] +> Turning on `enableCitations` does not guarantee that a response contains citations. Models emit them only when the response is grounded in citable source material. Always treat the `citations` field as optional. + +## Enable citations on a session + +Set the option on session create, and set it again on resume if you want citations after a restart. + +
+TypeScript + + + +```typescript +const session = await client.createSession({ + onPermissionRequest: approveAll, + enableCitations: true, +}); + +const resumed = await client.resumeSession(session.sessionId, { + onPermissionRequest: approveAll, + enableCitations: true, +}); +``` + +
+
+Python + + + +```python +session = await client.create_session( + on_permission_request=PermissionHandler.approve_all, + enable_citations=True, +) + +resumed = await client.resume_session( + session.session_id, + on_permission_request=PermissionHandler.approve_all, + enable_citations=True, +) +``` + +
+
+Go + + + +```go +session, err := client.CreateSession(ctx, &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + EnableCitations: copilot.Bool(true), +}) + +resumed, err := client.ResumeSession(ctx, session.SessionID, &copilot.ResumeSessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + EnableCitations: copilot.Bool(true), +}) +``` + +
+
+.NET + + + +```csharp +var session = await client.CreateSessionAsync(new SessionConfig +{ + OnPermissionRequest = PermissionHandler.ApproveAll, + EnableCitations = true, +}); + +var resumed = await client.ResumeSessionAsync(session.SessionId, new ResumeSessionConfig +{ + OnPermissionRequest = PermissionHandler.ApproveAll, + EnableCitations = true, +}); +``` + +
+
+Java + + + +```java +CopilotSession session = client + .createSession(new SessionConfig() + .setOnPermissionRequest(PermissionHandler.APPROVE_ALL) + .setEnableCitations(true)) + .get(); + +CopilotSession resumed = client + .resumeSession(session.getSessionId(), new ResumeSessionConfig() + .setOnPermissionRequest(PermissionHandler.APPROVE_ALL) + .setEnableCitations(true)) + .get(); +``` + +
+
+Rust + + + +```rust +let session = client + .create_session( + SessionConfig::new() + .approve_all_permissions() + .with_enable_citations(true), + ) + .await?; + +let resumed = client + .resume_session( + ResumeSessionConfig::new(session.id().clone()) + .approve_all_permissions() + .with_enable_citations(true), + ) + .await?; +``` + +
+ +## Read citations from assistant messages + +Citations arrive on the final `assistant.message` event, not on `assistant.message_delta` events. Wait for the final message before you render source markers. + +
+TypeScript + + + +```typescript +session.on((event) => { + if (event.type !== "assistant.message" || !event.data.citations) { + return; + } + + const { sources, spans } = event.data.citations; + const sourceById = new Map(sources.map((source) => [source.id, source])); + + for (const span of spans) { + const quoted = event.data.content.slice(span.startIndex, span.endIndex); + for (const reference of span.references) { + const source = sourceById.get(reference.sourceId); + const label = source?.title ?? source?.url ?? source?.path ?? source?.id; + console.log(`"${quoted}" — ${label}`); + } + } +}); +``` + +
+
+Python + + + +```python +from copilot.session_events import SessionEventType + +def utf16_slice(text: str, start: int, end: int) -> str: + """Slice by UTF-16 code units, which is how span offsets are measured.""" + units = text.encode("utf-16-le") + return units[start * 2 : end * 2].decode("utf-16-le") + +def handle(event): + if event.type != SessionEventType.ASSISTANT_MESSAGE or not event.data.citations: + return + + sources = {source.id: source for source in event.data.citations.sources} + + for span in event.data.citations.spans: + quoted = utf16_slice(event.data.content, span.start_index, span.end_index) + for reference in span.references: + source = sources[reference.source_id] + label = source.title or source.url or source.path or source.id + print(f'"{quoted}" — {label}') + +session.on(handle) +``` + +
+
+Go + + + +```go +// import "unicode/utf16" + +session.On(func(event copilot.SessionEvent) { + d, ok := event.Data.(*copilot.AssistantMessageData) + if !ok || d.Citations == nil { + return + } + + sources := map[string]copilot.CitationSource{} + for _, source := range d.Citations.Sources { + sources[source.ID] = source + } + + // Span offsets are UTF-16 code units, so index the UTF-16 view of the content. + units := utf16.Encode([]rune(d.Content)) + + for _, span := range d.Citations.Spans { + quoted := string(utf16.Decode(units[span.StartIndex:span.EndIndex])) + for _, reference := range span.References { + source := sources[reference.SourceID] + label := source.ID + switch { + case source.Title != nil: + label = *source.Title + case source.URL != nil: + label = *source.URL + case source.Path != nil: + label = *source.Path + } + fmt.Printf("%q — %s\n", quoted, label) + } + } +}) +``` + +
+
+.NET + + + +```csharp +session.On(evt => +{ + if (evt is not AssistantMessageEvent message || message.Data.Citations is null) + { + return; + } + + var sources = message.Data.Citations.Sources.ToDictionary(source => source.Id); + + foreach (var span in message.Data.Citations.Spans) + { + var quoted = message.Data.Content[(int)span.StartIndex..(int)span.EndIndex]; + foreach (var reference in span.References) + { + var source = sources[reference.SourceId]; + var label = source.Title ?? source.Url ?? source.Path ?? source.Id; + Console.WriteLine($"\"{quoted}\" — {label}"); + } + } +}); +``` + +
+
+Java + + + +```java +session.on(AssistantMessageEvent.class, event -> { + Citations citations = event.getData().citations(); + if (citations == null) { + return; + } + + Map sources = citations.sources().stream() + .collect(Collectors.toMap(CitationSource::id, source -> source)); + + for (CitationSpan span : citations.spans()) { + String quoted = event.getData().content() + .substring(span.startIndex().intValue(), span.endIndex().intValue()); + for (CitationReference reference : span.references()) { + CitationSource source = sources.get(reference.sourceId()); + String label = source.title() != null ? source.title() + : source.url() != null ? source.url() + : source.path() != null ? source.path() + : source.id(); + System.out.printf("\"%s\" — %s%n", quoted, label); + } + } +}); +``` + +
+
+Rust + + + +```rust +use github_copilot_sdk::session_events::AssistantMessageData; +use std::collections::HashMap; + +let mut events = session.subscribe(); + +while let Ok(event) = events.recv().await { + if event.event_type != "assistant.message" { + continue; + } + + let Some(data) = event.typed_data::() else { + continue; + }; + let Some(citations) = data.citations.as_ref() else { + continue; + }; + + let sources: HashMap<&str, _> = citations + .sources + .iter() + .map(|source| (source.id.as_str(), source)) + .collect(); + + // Span offsets are UTF-16 code units, so index the UTF-16 view of the content. + let units: Vec = data.content.encode_utf16().collect(); + + for span in &citations.spans { + let quoted = String::from_utf16_lossy( + &units[span.start_index as usize..span.end_index as usize], + ); + for reference in &span.references { + let Some(source) = sources.get(reference.source_id.as_str()) else { + continue; + }; + let label = source + .title + .as_deref() + .or(source.url.as_deref()) + .or(source.path.as_deref()) + .unwrap_or(source.id.as_str()); + println!("\"{quoted}\" — {label}"); + } + } +} +``` + +
+ +## Citation payload reference + +The `citations` object separates deduplicated sources from the spans that reference them, so a source cited five times appears once in `sources`. + +| Type | Field | Description | +|---|---|---| +| `Citations` | `sources` | Deduplicated set of sources referenced by the citation spans | +| `Citations` | `spans` | Spans of generated text annotated with their supporting sources | +| `CitationSource` | `id` | Stable, turn-scoped identifier referenced by `CitationReference.sourceId` | +| `CitationSource` | `provider` | System that produced the citation: `anthropic`, `openai`, or `client` | +| `CitationSource` | `title?` | Human-readable title of the source | +| `CitationSource` | `url?` | URL of the source, when it is a web resource | +| `CitationSource` | `path?` | File path relative to the agent workspace root, when the source is a file | +| `CitationSpan` | `startIndex` | Start offset in the final message content (UTF-16 code units, zero-based, inclusive) | +| `CitationSpan` | `endIndex` | End offset in the final message content (UTF-16 code units, zero-based, exclusive) | +| `CitationSpan` | `references` | The sources that support this span | +| `CitationReference` | `sourceId` | Identifier of the `CitationSource` this reference points to | +| `CitationReference` | `citedText?` | Exact text from the source that supports the span, when the model provides it | +| `CitationReference` | `location?` | Location within the source that supports the span | +| `CitationReference` | `providerMetadata?` | Provider-native correlation data, passed through opaquely | + +> [!TIP] +> Span offsets are measured in UTF-16 code units against the final `content` string. TypeScript, Java, and .NET strings are already UTF-16, so you can slice them directly. Python strings are indexed by Unicode code point and Go and Rust strings are UTF-8, so convert the content to UTF-16 code units before slicing, as the examples above do. + +### Citation locations + +`CitationReference.location` is a discriminated union keyed on `type`: + +| Location type | Fields | Use | +|---|---|---| +| `char` | `startIndex`, `endIndex` | Character range within the source text | +| `page` | `startPage`, `endPage` | Page range within a paginated document | +| `block` | `startBlock`, `endBlock` | Content-block range within a structured document | + +## Provide citable sources + +Citations need source material the model can attribute. There are two ways to supply it. + +### Attach documents to a message + +When citations are enabled and the session uses an Anthropic provider, file attachments are sent as `document` blocks with citations turned on, so the model can cite passages from them. + + + +```typescript +await session.sendAndWait({ + prompt: "Summarize the attached PDF and cite the passages you used.", + attachments: [ + { + type: "blob", + data: pdfBase64, + displayName: "quarterly-report.pdf", + mimeType: "application/pdf", + }, + ], +}); +``` + +See [Image input](./image-input.md) for the attachment API and the `file` and `blob` attachment shapes. + +### Return citable sources from a tool + +Tool results carry an experimental `citableSources` array. Each entry supplies `content` that the model can cite, along with an `id` and optional `title`, `url`, and `path`. These sources are persisted with the tool result, so they survive session resume, and citations built from them are tagged with the `client` provider. + +## Limitations + +* Citations are experimental in every SDK and are not covered by compatibility guarantees. +* Coverage depends on the model provider. A session configured for a provider without citation support emits no `citations` payload. +* Citations are only present on the final `assistant.message` event, so streaming consumers cannot render them mid-response. +* Public code and IP-duplication citations are not part of this surface. + +## Further reading + +* [Streaming events](./streaming-events.md): subscribe to session events and narrow event types +* [Image input](./image-input.md): attach files and in-memory blobs to a message +* [Session persistence](./session-persistence.md): resume sessions and re-apply session options +* [Compatibility](../troubleshooting/compatibility.md): SDK and CLI feature matrix diff --git a/docs/features/hooks.md b/docs/features/hooks.md index feee55546..6a7833990 100644 --- a/docs/features/hooks.md +++ b/docs/features/hooks.md @@ -9,20 +9,22 @@ A hook is a callback you register once when creating a session. The SDK invokes ```mermaid flowchart LR A[Session starts] -->|onSessionStart| B[User sends prompt] - B -->|onUserPromptSubmitted| C[Agent picks a tool] - C -->|onPreToolUse| D[Tool executes] - D -->|onPostToolUse| E{More work?} - E -->|yes| C - E -->|no| F[Session ends] - F -->|onSessionEnd| G((Done)) - C -.->|error| H[onErrorOccurred] - D -.->|error| H + B -->|onUserPromptSubmitted| C[Runtime transforms prompt] + C -->|onUserPromptTransformed| D[Agent picks a tool] + D -->|onPreToolUse| E[Tool executes] + E -->|onPostToolUse| F{More work?} + F -->|yes| D + F -->|no| G[Session ends] + G -->|onSessionEnd| H((Done)) + D -.->|error| I[onErrorOccurred] + E -.->|error| I ``` | Hook | When it fires | What you can do | | ------------------------------------------------------------------- | ----------------------------------- | ------------------------------------------ | | [`onSessionStart`](../hooks/session-lifecycle.md#session-start) | Session begins (new or resumed) | Inject context, load preferences | | [`onUserPromptSubmitted`](../hooks/user-prompt-submitted.md) | User sends a message | Rewrite prompts, add context, filter input | +| [`onUserPromptTransformed`](../hooks/user-prompt-transformed.md) | Runtime builds the model prompt | Inspect or replace model-facing content | | [`onPreToolUse`](../hooks/pre-tool-use.md) | Before a tool executes | Allow / deny / modify the call | | [`onPostToolUse`](../hooks/post-tool-use.md) | After a tool returns (success only) | Transform results, redact secrets, audit | | [`onPostToolUseFailure`](../hooks/post-tool-use.md#failure-variant) | After a tool returns a failure | Inject retry guidance, log failures | @@ -1055,6 +1057,7 @@ For full type definitions, input/output field tables, and additional examples fo * [Pre-Tool Use](../hooks/pre-tool-use.md) * [Post-Tool Use](../hooks/post-tool-use.md) * [User Prompt Submitted](../hooks/user-prompt-submitted.md) +* [User Prompt Transformed](../hooks/user-prompt-transformed.md) * [Session Lifecycle](../hooks/session-lifecycle.md) * [Error Handling](../hooks/error-handling.md) diff --git a/docs/features/mcp.md b/docs/features/mcp.md index 6f715bd2e..caac63327 100644 --- a/docs/features/mcp.md +++ b/docs/features/mcp.md @@ -154,6 +154,35 @@ await using var session = await client.CreateSessionAsync(new SessionConfig }); ``` +## Disabling configured servers per session + +Set `disabledMcpServers` to exact MCP server names that must not run in a session. +The setting is scoped to the individual create or resume request; it does not +modify global MCP settings or the server configuration. + +```typescript +const session = await client.createSession({ + mcpServers: { + filesystem: { type: "local", command: "npx", args: ["-y", "@modelcontextprotocol/server-filesystem", "."] }, + github: { type: "http", url: "https://api.githubcopilot.com/mcp/" }, + }, + disabledMcpServers: ["github"], +}); +``` + +| SDK | Configuration property | +| --- | --- | +| Node.js | `disabledMcpServers` | +| Python | `disabled_mcp_servers` | +| Go | `DisabledMCPServers` | +| .NET | `DisabledMcpServers` | +| Java | `setDisabledMcpServers(...)` | +| Rust | `with_disabled_mcp_servers(...)` | + +On session creation and a **cold** resume, disabled servers are not started and +the runtime does not initiate their authentication. A resident resume cannot +undo a server that the runtime has already spawned. Names are matched exactly. + ## Tool configuration You can control which tools are available to an MCP server using the `tools` field. diff --git a/docs/features/streaming-events.md b/docs/features/streaming-events.md index d5201a168..10f111d9f 100644 --- a/docs/features/streaming-events.md +++ b/docs/features/streaming-events.md @@ -548,7 +548,7 @@ Ephemeral. The agent has finished all processing and is ready for the next messa | Data Field | Type | Required | Description | |------------|------|----------|-------------| -| `backgroundTasks` | `BackgroundTasks` | | Background agents/shells still running when the agent became idle | +| `aborted` | `boolean` | | True when the preceding turn was cancelled via abort signal | ### `session.error` @@ -958,7 +958,7 @@ This table lists key `data` payload fields. Common envelope fields are documente | `tool.execution_partial_result` | ✅ | Tool | `toolCallId`, `partialOutput` | | `tool.execution_progress` | ✅ | Tool | `toolCallId`, `progressMessage` | | `tool.execution_complete` | | Tool | `toolCallId`, `success`, `result?`, `error?` | -| `session.idle` | ✅ | Session | `backgroundTasks?` | +| `session.idle` | ✅ | Session | `aborted?` | | `session.error` | | Session | `errorType`, `message`, `statusCode?` | | `session.compaction_start` | | Session | *(empty)* | | `session.compaction_complete` | | Session | `success`, `preCompactionTokens?`, `summaryContent?` | diff --git a/docs/hooks/README.md b/docs/hooks/README.md index 517be9614..a6c7e1aa6 100644 --- a/docs/hooks/README.md +++ b/docs/hooks/README.md @@ -6,5 +6,6 @@ Detailed API reference for each session hook in the GitHub Copilot SDK. * [Pre-tool use](./pre-tool-use.md): approve, deny, or modify tool calls * [Post-tool use](./post-tool-use.md): transform tool results * [User prompt submitted](./user-prompt-submitted.md): modify or filter user messages +* [User prompt transformed](./user-prompt-transformed.md): inspect or replace model-facing prompts * [Session lifecycle](./session-lifecycle.md): session start and end * [Error handling](./error-handling.md): custom error handling diff --git a/docs/hooks/hooks-overview.md b/docs/hooks/hooks-overview.md index 6de4c3e72..8d5583e99 100644 --- a/docs/hooks/hooks-overview.md +++ b/docs/hooks/hooks-overview.md @@ -16,6 +16,7 @@ Hooks allow you to intercept and customize the behavior of Copilot sessions at k | [`onPostToolUse`](./post-tool-use.md) | After a tool executes (success only) | Result transformation, logging | | [`onPostToolUseFailure`](./post-tool-use.md#failure-variant) | After a tool execution whose result was a failure | Inject retry guidance, log failures | | [`onUserPromptSubmitted`](./user-prompt-submitted.md) | When user sends a message | Prompt modification, filtering | +| [`onUserPromptTransformed`](./user-prompt-transformed.md) | After runtime prompt transformation | Inspect or replace model-facing content | | [`onSessionStart`](./session-lifecycle.md#session-start) | Session begins | Add context, configure session | | [`onSessionEnd`](./session-lifecycle.md#session-end) | Session ends | Cleanup, analytics | | [`onErrorOccurred`](./error-handling.md) | Error happens | Custom error handling | @@ -263,6 +264,7 @@ const session = await client.createSession({ * **[Pre-Tool Use Hook](./pre-tool-use.md)** - Control tool execution permissions * **[Post-Tool Use Hook](./post-tool-use.md)** - Transform tool results * **[User Prompt Submitted Hook](./user-prompt-submitted.md)** - Modify user prompts +* **[User Prompt Transformed Hook](./user-prompt-transformed.md)** - Replace model-facing prompts * **[Session Lifecycle Hooks](./session-lifecycle.md)** - Session start and end * **[Agent Stop Hook](./session-lifecycle.md#agent-stop)** - Validate completion before the agent stops * **[Error Handling Hook](./error-handling.md)** - Custom error handling diff --git a/docs/hooks/user-prompt-transformed.md b/docs/hooks/user-prompt-transformed.md new file mode 100644 index 000000000..f7791d78b --- /dev/null +++ b/docs/hooks/user-prompt-transformed.md @@ -0,0 +1,129 @@ +# User prompt transformed hook + +The `userPromptTransformed` hook runs after the runtime adds generated context to a submitted prompt, but before the resulting content is persisted to session history or sent to the model. + +Use it when you need to inspect or replace the exact model-facing prompt. The `prompt` input contains the user prompt after any `userPromptSubmitted` hooks have run, while `transformedPrompt` also contains runtime-generated context such as ``. + +## Input and output + +| Input field | Type | Description | +| --- | --- | --- | +| `sessionId` | string | Runtime session ID | +| `timestamp` | date/time | Time the hook was invoked | +| `cwd` / `workingDirectory` | string | Current working directory | +| `prompt` | string | Prompt after `userPromptSubmitted` hooks | +| `transformedPrompt` | string | Model-facing prompt after runtime transformations | + +Return no value to leave the transformed prompt unchanged. Return `modifiedTransformedPrompt` to replace the content that is stored in session history and sent to the model. + +## Examples + +
+TypeScript + + +```typescript +const session = await client.createSession({ + hooks: { + onUserPromptTransformed: async (input) => ({ + modifiedTransformedPrompt: redact(input.transformedPrompt), + }), + }, +}); +``` + +
+ +
+Python + + +```python +session = await client.create_session( + hooks={ + "on_user_prompt_transformed": lambda input_data, invocation: { + "modifiedTransformedPrompt": redact(input_data["transformedPrompt"]) + } + } +) +``` + +
+ +
+Go + + +```go +session, err := client.CreateSession(ctx, &copilot.SessionConfig{ + Hooks: &copilot.SessionHooks{ + OnUserPromptTransformed: func(input copilot.UserPromptTransformedHookInput, invocation copilot.HookInvocation) (*copilot.UserPromptTransformedHookOutput, error) { + return &copilot.UserPromptTransformedHookOutput{ + ModifiedTransformedPrompt: copilot.String(redact(input.TransformedPrompt)), + }, nil + }, + }, +}) +``` + +
+ +
+.NET + + +```csharp +var session = await client.CreateSessionAsync(new SessionConfig +{ + Hooks = new SessionHooks + { + OnUserPromptTransformed = (input, invocation) => + Task.FromResult(new() + { + ModifiedTransformedPrompt = Redact(input.TransformedPrompt), + }), + }, +}); +``` + +
+ +
+Java + + +```java +var hooks = new SessionHooks().setOnUserPromptTransformed((input, invocation) -> + CompletableFuture.completedFuture( + new UserPromptTransformedHookOutput(redact(input.transformedPrompt())))); + +var session = client.createSession(new SessionConfig().setHooks(hooks)).get(); +``` + +
+ +
+Rust + +```rust +#[async_trait] +impl SessionHooks for MyHooks { + async fn on_user_prompt_transformed( + &self, + input: UserPromptTransformedInput, + _ctx: HookContext, + ) -> Option { + Some(UserPromptTransformedOutput { + modified_transformed_prompt: Some(redact(&input.transformed_prompt)), + }) + } +} + +let session = client + .create_session(SessionConfig::default().with_hooks(Arc::new(MyHooks))) + .await?; +``` + +
+ +The replacement is persisted as the user message content, so resumed sessions replay the modified content unchanged. diff --git a/dotnet/README.md b/dotnet/README.md index 1971c2107..6efd6e094 100644 --- a/dotnet/README.md +++ b/dotnet/README.md @@ -84,7 +84,7 @@ new CopilotClient(CopilotClientOptions? options = null) - `Connection` - How to connect to the Copilot runtime. Defaults to `null` (equivalent to `RuntimeConnection.ForStdio()` with the bundled runtime). See "RuntimeConnection" below. - `LogLevel` - Runtime log level. Accepts well-known values `CopilotLogLevel.None`, `Error`, `Warning`, `Info`, `Debug`, `All`. Defaults to null (the runtime's own default). -- `WorkingDirectory` - Working directory for the runtime process. +- `WorkingDirectory` - Working directory for the runtime process. When not set, the spawned runtime inherits the calling application's current working directory. - `BaseDirectory` - Base directory for Copilot data (session state, config, etc.). Sets `COPILOT_HOME` on the spawned runtime process. When not set, the runtime defaults to `~/.copilot`. Useful in restricted environments where only specific directories are writable. Ignored when connecting via `RuntimeConnection.ForUri(...)`. - `EnableRemoteSessions` - Enables remote-session features. - `Environment` - Environment variables to pass to the runtime process. @@ -123,7 +123,7 @@ Create a new conversation session. - `SessionId` - Custom session ID - `Model` - Model to use ("gpt-5", "claude-sonnet-4.5", etc.) -- `ReasoningEffort` - Reasoning effort level for models that support it ("low", "medium", "high", "xhigh"). Use `ListModelsAsync()` to check which models support this option. +- `ReasoningEffort` - Reasoning effort level for models that support it ("low", "medium", "high", "xhigh", "max"). Use `ListModelsAsync()` to check which models support this option. - `Tools` - Custom tool declarations exposed to the CLI. Declarations without an invocable `AIFunction` are left pending for manual resolution. - `SystemMessage` - System message customization - `AvailableTools` - List of tool names to allow @@ -131,6 +131,7 @@ Create a new conversation session. - `Provider` - Custom API provider configuration (BYOK) - `Streaming` - Enable streaming of response chunks (default: false) - `InfiniteSessions` - Configure automatic context compaction (see below) +- `WorkingDirectory` - Working directory for the session. When not set, the runtime uses its own process working directory. - `EnableSessionStore` - Enables the cross-session store for search and retrieval across sessions. When unset in `CopilotClientMode.CopilotCli`, the runtime default applies (enabled). In `CopilotClientMode.Empty`, defaults to disabled. - `OnPermissionRequest` - Optional handler called before each tool execution to approve or deny it. When omitted, permission requests are emitted as events and left pending for manual resolution. `PermissionHandler.ApproveAll` approves requests when managed settings are disabled and throws when `EnableManagedSettings` is true. Custom handlers can inspect `ManagedApprovalRequired` for human-facing confirmation logic. See [Permission Handling](#permission-handling) section. - `OnUserInputRequest` - Handler for user input requests from the agent (enables ask_user tool). See [User Input Requests](#user-input-requests) section. @@ -296,9 +297,9 @@ The SDK supports image attachments via the `Attachments` parameter. You can atta await session.SendAsync(new MessageOptions { Prompt = "What's in this image?", - Attachments = new List + Attachments = new List { - new UserMessageDataAttachmentsItemFile + new AttachmentFile { Path = "/path/to/image.jpg", DisplayName = "image.jpg", @@ -310,9 +311,9 @@ await session.SendAsync(new MessageOptions await session.SendAsync(new MessageOptions { Prompt = "What's in this image?", - Attachments = new List + Attachments = new List { - new UserMessageDataAttachmentsItemBlob + new AttachmentBlob { Data = base64ImageData, MimeType = "image/png", @@ -721,13 +722,12 @@ await session2.SendAsync(new MessageOptions { Prompt = "Hello from session 2" }) await session.SendAsync(new MessageOptions { Prompt = "Analyze this file", - Attachments = new List + Attachments = new List { - new UserMessageDataAttachmentsItem + new AttachmentFile { - Type = UserMessageDataAttachmentsItemType.File, Path = "/path/to/file.cs", - DisplayName = "My File" + DisplayName = "My File", } } }); @@ -1038,6 +1038,25 @@ catch (Exception ex) } ``` +## Development + +Development requires [.NET SDK 10+](https://dotnet.microsoft.com/download) and a supported [Node.js version](../nodejs/README.md#prerequisites). From the repository root: + +```bash +cd nodejs +npm ci +``` + +```bash +cd test/harness +npm ci +``` + +```bash +cd dotnet +dotnet test +``` + ## License MIT diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index b1199dac8..03691fc63 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -1097,6 +1097,7 @@ public async Task CreateSessionAsync(SessionConfig config, Cance config.Hooks.OnPostToolUse != null || config.Hooks.OnPostToolUseFailure != null || config.Hooks.OnUserPromptSubmitted != null || + config.Hooks.OnUserPromptTransformed != null || config.Hooks.OnSessionStart != null || config.Hooks.OnSessionEnd != null || config.Hooks.OnErrorOccurred != null || @@ -1188,6 +1189,7 @@ public async Task CreateSessionAsync(SessionConfig config, Cance Cloud: config.Cloud, InstructionDirectories: config.InstructionDirectories, PluginDirectories: config.PluginDirectories, + DisabledMcpServers: config.DisabledMcpServers, LargeOutput: config.LargeOutput, ToolSearch: config.ToolSearch, Memory: config.Memory, @@ -1327,6 +1329,7 @@ public async Task ResumeSessionAsync(string sessionId, ResumeSes config.Hooks.OnPostToolUse != null || config.Hooks.OnPostToolUseFailure != null || config.Hooks.OnUserPromptSubmitted != null || + config.Hooks.OnUserPromptTransformed != null || config.Hooks.OnSessionStart != null || config.Hooks.OnSessionEnd != null || config.Hooks.OnErrorOccurred != null || @@ -1405,6 +1408,7 @@ public async Task ResumeSessionAsync(string sessionId, ResumeSes ContinuePendingWork: config.ContinuePendingWork, InstructionDirectories: config.InstructionDirectories, PluginDirectories: config.PluginDirectories, + DisabledMcpServers: config.DisabledMcpServers, LargeOutput: config.LargeOutput, ToolSearch: config.ToolSearch, Memory: config.Memory, @@ -2761,6 +2765,7 @@ internal record CreateSessionRequest( CloudSessionOptions? Cloud = null, IList? InstructionDirectories = null, IList? PluginDirectories = null, + [property: JsonPropertyName("disabledMcpServers")] IList? DisabledMcpServers = null, LargeToolOutputConfig? LargeOutput = null, ToolSearchConfig? ToolSearch = null, MemoryConfiguration? Memory = null, @@ -2870,6 +2875,7 @@ internal record ResumeSessionRequest( bool? ContinuePendingWork = null, IList? InstructionDirectories = null, IList? PluginDirectories = null, + [property: JsonPropertyName("disabledMcpServers")] IList? DisabledMcpServers = null, LargeToolOutputConfig? LargeOutput = null, ToolSearchConfig? ToolSearch = null, MemoryConfiguration? Memory = null, diff --git a/dotnet/src/Session.cs b/dotnet/src/Session.cs index d2f6cedbf..7c34ded16 100644 --- a/dotnet/src/Session.cs +++ b/dotnet/src/Session.cs @@ -1,4 +1,4 @@ -/*--------------------------------------------------------------------------------------------- +/*--------------------------------------------------------------------------------------------- * Copyright (c) Microsoft Corporation. All rights reserved. *--------------------------------------------------------------------------------------------*/ @@ -263,7 +263,7 @@ public Task SendAsync(string prompt, CancellationToken cancellationToken /// Prompt = "Explain this code", /// Attachments = new List<Attachment> /// { - /// new() { Type = "file", Path = "./Program.cs" } + /// new AttachmentFile { Path = "./Program.cs", DisplayName = "Program.cs" } /// } /// }); /// @@ -1613,6 +1613,11 @@ internal void RegisterHooks(SessionHooks hooks) JsonSerializer.Deserialize(input.GetRawText(), SessionJsonContext.Default.UserPromptSubmittedHookInput)!, invocation) : null, + "userPromptTransformed" => hooks.OnUserPromptTransformed != null + ? await hooks.OnUserPromptTransformed( + JsonSerializer.Deserialize(input.GetRawText(), SessionJsonContext.Default.UserPromptTransformedHookInput)!, + invocation) + : null, "sessionStart" => hooks.OnSessionStart != null ? await hooks.OnSessionStart( JsonSerializer.Deserialize(input.GetRawText(), SessionJsonContext.Default.SessionStartHookInput)!, @@ -1790,7 +1795,7 @@ public async Task AbortAsync(CancellationToken cancellationToken = default) /// The new model takes effect for the next message. Conversation history is preserved. /// /// Model ID to switch to (e.g., "gpt-5.4"). - /// Reasoning effort level (e.g., "low", "medium", "high", "xhigh"). + /// Reasoning effort level (e.g., "low", "medium", "high", "xhigh", "max"). /// Per-property overrides for model capabilities, deep-merged over runtime defaults. /// Optional cancellation token. /// @@ -2033,5 +2038,7 @@ internal void ThrowIfDisposed() [JsonSerializable(typeof(Attachment))] [JsonSerializable(typeof(UserPromptSubmittedHookInput))] [JsonSerializable(typeof(UserPromptSubmittedHookOutput))] + [JsonSerializable(typeof(UserPromptTransformedHookInput))] + [JsonSerializable(typeof(UserPromptTransformedHookOutput))] internal partial class SessionJsonContext : JsonSerializerContext; } diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs index e1ba0355f..7489fbb4e 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -1656,6 +1656,55 @@ public sealed class UserPromptSubmittedHookOutput public bool? SuppressOutput { get; set; } } +/// +/// Input for a user-prompt-transformed hook. +/// +public sealed class UserPromptTransformedHookInput +{ + /// + /// The runtime session ID of the session that triggered the hook. + /// + [JsonPropertyName("sessionId")] + public string SessionId { get; set; } = string.Empty; + + /// + /// Unix timestamp in milliseconds when the prompt was transformed. + /// + [JsonPropertyName("timestamp")] + [JsonConverter(typeof(UnixMillisecondsDateTimeOffsetConverter))] + public DateTimeOffset Timestamp { get; set; } + + /// + /// Current working directory of the session. + /// + [JsonPropertyName("cwd")] + public string WorkingDirectory { get; set; } = string.Empty; + + /// + /// The user prompt after any user-prompt-submitted hooks have run. + /// + [JsonPropertyName("prompt")] + public string Prompt { get; set; } = string.Empty; + + /// + /// The model-facing prompt after runtime transformations. + /// + [JsonPropertyName("transformedPrompt")] + public string TransformedPrompt { get; set; } = string.Empty; +} + +/// +/// Output for a user-prompt-transformed hook. +/// +public sealed class UserPromptTransformedHookOutput +{ + /// + /// Replacement model-facing prompt to persist and send to the model. + /// + [JsonPropertyName("modifiedTransformedPrompt")] + public string? ModifiedTransformedPrompt { get; set; } +} + /// /// Input for a session-start hook. /// @@ -1967,6 +2016,11 @@ public sealed class SessionHooks /// public Func>? OnUserPromptSubmitted { get; set; } + /// + /// Handler called after the runtime transforms a submitted prompt and before it is stored. + /// + public Func>? OnUserPromptTransformed { get; set; } + /// /// Handler called when a session starts. /// @@ -3016,6 +3070,7 @@ protected SessionConfigBase(SessionConfigBase? other) DefaultAgent = other.DefaultAgent; Agent = other.Agent; DisabledSkills = other.DisabledSkills is not null ? [.. other.DisabledSkills] : null; + DisabledMcpServers = other.DisabledMcpServers is not null ? [.. other.DisabledMcpServers] : null; EnableCitations = other.EnableCitations; EnableConfigDiscovery = other.EnableConfigDiscovery; SkipEmbeddingRetrieval = other.SkipEmbeddingRetrieval; @@ -3110,7 +3165,7 @@ protected SessionConfigBase(SessionConfigBase? other) /// /// Reasoning effort level for models that support it. - /// Valid values: "low", "medium", "high", "xhigh". + /// Valid values: "low", "medium", "high", "xhigh", "max". /// Only applies to models where capabilities.supports.reasoningEffort is true. /// public string? ReasoningEffort { get; set; } @@ -3446,6 +3501,13 @@ protected SessionConfigBase(SessionConfigBase? other) /// List of skill names to disable. public IList? DisabledSkills { get; set; } + /// + /// Exact MCP server names to disable for this session. Disabled servers are not + /// started or authenticated on create or cold resume; a resident resume cannot + /// stop servers that are already running. + /// + public IList? DisabledMcpServers { get; set; } + /// /// Infinite session configuration for persistent workspaces and automatic compaction. /// When enabled (default), sessions automatically manage context limits and persist state. diff --git a/dotnet/test/E2E/HookLifecycleAndOutputE2ETests.cs b/dotnet/test/E2E/HookLifecycleAndOutputE2ETests.cs index cd94a2ebd..decdb3190 100644 --- a/dotnet/test/E2E/HookLifecycleAndOutputE2ETests.cs +++ b/dotnet/test/E2E/HookLifecycleAndOutputE2ETests.cs @@ -11,8 +11,9 @@ namespace GitHub.Copilot.Test.E2E; /// /// E2E coverage for every handler exposed on : /// OnPreToolUse, OnPostToolUse, OnPostToolUseFailure, OnUserPromptSubmitted, -/// OnSessionStart, OnSessionEnd, OnErrorOccurred, OnAgentStop. Output-shape behavior -/// (modifiedPrompt / additionalContext / errorHandling / modifiedArgs / +/// OnUserPromptTransformed, OnSessionStart, OnSessionEnd, OnErrorOccurred, +/// OnAgentStop. Output-shape behavior (modifiedPrompt / modifiedTransformedPrompt / +/// additionalContext / errorHandling / modifiedArgs / /// modifiedResult / sessionSummary) is asserted alongside hook invocation. If a /// new handler is added to SessionHooks, add a corresponding test here. /// @@ -163,6 +164,37 @@ public async Task Should_Invoke_UserPromptSubmitted_Hook_And_Modify_Prompt() Assert.Contains("HOOKED_PROMPT", response?.Data.Content ?? string.Empty); } + [Fact] + public async Task Should_Invoke_UserPromptTransformed_Hook_And_Modify_Transformed_Prompt() + { + var inputs = new List(); + var session = await CreateSessionAsync(new SessionConfig + { + Hooks = new SessionHooks + { + OnUserPromptTransformed = (input, invocation) => + { + inputs.Add(input); + Assert.False(string.IsNullOrWhiteSpace(invocation.SessionId)); + return Task.FromResult(new UserPromptTransformedHookOutput + { + ModifiedTransformedPrompt = "Reply with exactly: HOOKED_TRANSFORMED_PROMPT", + }); + }, + }, + }); + + var response = await session.SendAndWaitAsync(new MessageOptions { Prompt = "Answer the request above." }); + + Assert.NotEmpty(inputs); + Assert.Contains("Answer the request above.", inputs[0].Prompt); + Assert.Contains("Answer the request above.", inputs[0].TransformedPrompt); + Assert.Contains("", inputs[0].TransformedPrompt); + Assert.True(inputs[0].Timestamp > DateTimeOffset.UnixEpoch); + Assert.False(string.IsNullOrEmpty(inputs[0].WorkingDirectory)); + Assert.Contains("HOOKED_TRANSFORMED_PROMPT", response?.Data.Content ?? string.Empty); + } + [Fact] public async Task Should_Invoke_SessionStart_Hook() { diff --git a/dotnet/test/Unit/CloneTests.cs b/dotnet/test/Unit/CloneTests.cs index ad8b0644e..88c653283 100644 --- a/dotnet/test/Unit/CloneTests.cs +++ b/dotnet/test/Unit/CloneTests.cs @@ -100,6 +100,7 @@ public void SessionConfig_Clone_CopiesAllProperties() SkillDirectories = ["/skills"], InstructionDirectories = ["/instructions"], DisabledSkills = ["skill1"], + DisabledMcpServers = ["server1"], PluginDirectories = ["/plugins"], LargeOutput = new LargeToolOutputConfig { Enabled = true, MaxSizeBytes = 2048, OutputDirectory = "/tmp/out" }, Memory = new MemoryConfiguration { Enabled = true }, @@ -140,6 +141,7 @@ public void SessionConfig_Clone_CopiesAllProperties() Assert.Equal(original.SkillDirectories, clone.SkillDirectories); Assert.Equal(original.InstructionDirectories, clone.InstructionDirectories); Assert.Equal(original.DisabledSkills, clone.DisabledSkills); + Assert.Equal(original.DisabledMcpServers, clone.DisabledMcpServers); Assert.Equal(original.PluginDirectories, clone.PluginDirectories); Assert.Same(original.LargeOutput, clone.LargeOutput); Assert.Same(original.Memory, clone.Memory); @@ -162,6 +164,7 @@ public void SessionConfig_Clone_CollectionsAreIndependent() SkillDirectories = ["/skills"], InstructionDirectories = ["/instructions"], DisabledSkills = ["skill1"], + DisabledMcpServers = ["server1"], }; var clone = original.Clone(); @@ -176,6 +179,7 @@ public void SessionConfig_Clone_CollectionsAreIndependent() clone.SkillDirectories!.Add("/more"); clone.InstructionDirectories!.Add("/more-instructions"); clone.DisabledSkills!.Add("skill99"); + clone.DisabledMcpServers!.Add("server99"); // Original is unaffected Assert.Single(original.AvailableTools!); @@ -187,6 +191,7 @@ public void SessionConfig_Clone_CollectionsAreIndependent() Assert.Single(original.SkillDirectories!); Assert.Single(original.InstructionDirectories!); Assert.Single(original.DisabledSkills!); + Assert.Single(original.DisabledMcpServers!); } [Fact] @@ -214,6 +219,7 @@ public void ResumeSessionConfig_Clone_CollectionsAreIndependent() SkillDirectories = ["/skills"], InstructionDirectories = ["/instructions"], DisabledSkills = ["skill1"], + DisabledMcpServers = ["server1"], }; var clone = original.Clone(); @@ -228,6 +234,7 @@ public void ResumeSessionConfig_Clone_CollectionsAreIndependent() clone.SkillDirectories!.Add("/more"); clone.InstructionDirectories!.Add("/more-instructions"); clone.DisabledSkills!.Add("skill99"); + clone.DisabledMcpServers!.Add("server99"); // Original is unaffected Assert.Single(original.AvailableTools!); @@ -239,6 +246,7 @@ public void ResumeSessionConfig_Clone_CollectionsAreIndependent() Assert.Single(original.SkillDirectories!); Assert.Single(original.InstructionDirectories!); Assert.Single(original.DisabledSkills!); + Assert.Single(original.DisabledMcpServers!); } [Fact] @@ -299,6 +307,7 @@ public void Clone_WithNullCollections_ReturnsNullCollections() Assert.Null(clone.SkillDirectories); Assert.Null(clone.InstructionDirectories); Assert.Null(clone.DisabledSkills); + Assert.Null(clone.DisabledMcpServers); Assert.Null(clone.Tools); Assert.Null(clone.DefaultAgent); Assert.True(clone.IncludeSubAgentStreamingEvents); diff --git a/dotnet/test/Unit/SerializationTests.cs b/dotnet/test/Unit/SerializationTests.cs index acad44f19..9bccf3d77 100644 --- a/dotnet/test/Unit/SerializationTests.cs +++ b/dotnet/test/Unit/SerializationTests.cs @@ -411,12 +411,14 @@ public void SessionRequests_CanSerializePluginDirectoriesAndLargeOutput_WithSdkO createRequestType, ("SessionId", "session-id"), ("PluginDirectories", pluginDirs), + ("DisabledMcpServers", new List { "local-files", "remote-github" }), ("LargeOutput", largeOutput)); var createJson = JsonSerializer.Serialize(createRequest, createRequestType, options); using var createDocument = JsonDocument.Parse(createJson); var createRoot = createDocument.RootElement; Assert.Equal("/tmp/plugins/a", createRoot.GetProperty("pluginDirectories")[0].GetString()); + Assert.Equal("local-files", createRoot.GetProperty("disabledMcpServers")[0].GetString()); Assert.Equal("/tmp/plugins/b", createRoot.GetProperty("pluginDirectories")[1].GetString()); var createLargeOutput = createRoot.GetProperty("largeOutput"); Assert.True(createLargeOutput.GetProperty("enabled").GetBoolean()); @@ -428,12 +430,14 @@ public void SessionRequests_CanSerializePluginDirectoriesAndLargeOutput_WithSdkO resumeRequestType, ("SessionId", "session-id"), ("PluginDirectories", pluginDirs), + ("DisabledMcpServers", new List { "local-files", "remote-github" }), ("LargeOutput", largeOutput)); var resumeJson = JsonSerializer.Serialize(resumeRequest, resumeRequestType, options); using var resumeDocument = JsonDocument.Parse(resumeJson); var resumeRoot = resumeDocument.RootElement; Assert.Equal("/tmp/plugins/a", resumeRoot.GetProperty("pluginDirectories")[0].GetString()); + Assert.Equal("local-files", resumeRoot.GetProperty("disabledMcpServers")[0].GetString()); var resumeLargeOutput = resumeRoot.GetProperty("largeOutput"); Assert.True(resumeLargeOutput.GetProperty("enabled").GetBoolean()); Assert.Equal(1024, resumeLargeOutput.GetProperty("maxSizeBytes").GetInt64()); diff --git a/go/README.md b/go/README.md index bc360253f..d8588699c 100644 --- a/go/README.md +++ b/go/README.md @@ -198,7 +198,7 @@ Event types: `SessionLifecycleCreated`, `SessionLifecycleDeleted`, `SessionLifec When `Path` is empty for stdio/tcp, the SDK uses the bundled CLI (or `COPILOT_CLI_PATH` env var). `StdioConnection` and `TCPConnection` accept an optional connection-level `Env`. Set environment variables via **either** the client-level `Env` option or the connection's `Env`, not both (setting both panics); prefer the connection-level `Env`. -- `WorkingDirectory` (string): Working directory for the runtime process +- `WorkingDirectory` (string): Working directory for the runtime process (default: current process working directory) - `BaseDirectory` (string): Base directory for Copilot data (session state, config, etc.). Sets `COPILOT_HOME` on the spawned runtime. When empty, the runtime defaults to `~/.copilot`. Ignored with `URIConnection`. This does **not** affect where the Go SDK extracts the embedded CLI binary; use `embeddedcli.Config.Dir` for the extraction/cache location. - `LogLevel` (string): Log level. When empty (default), the runtime uses its own default level (the SDK does not pass `--log-level`). - `Env` ([]string): Environment variables for the runtime process (default: inherits from current process) @@ -210,7 +210,7 @@ Event types: `SessionLifecycleCreated`, `SessionLifecycleDeleted`, `SessionLifec **SessionConfig:** - `Model` (string): Model to use ("gpt-5", "claude-sonnet-4.5", etc.). **Required when using custom provider.** -- `ReasoningEffort` (string): Reasoning effort level for models that support it ("low", "medium", "high", "xhigh"). Use `ListModels()` to check which models support this option. +- `ReasoningEffort` (string): Reasoning effort level for models that support it ("low", "medium", "high", "xhigh", "max"). Use `ListModels()` to check which models support this option. - `SessionID` (string): Custom session ID - `Tools` ([]Tool): Custom tools exposed to the CLI - `SystemMessage` (\*SystemMessageConfig): System message configuration. Supports three modes: @@ -220,6 +220,7 @@ Event types: `SessionLifecycleCreated`, `SessionLifecycleDeleted`, `SessionLifec - `Provider` (\*ProviderConfig): Custom API provider configuration (BYOK). See [Custom Providers](#custom-providers) section. - `Streaming` (*bool): Enable streaming delta events (nil = runtime default) - `InfiniteSessions` (\*InfiniteSessionConfig): Automatic context compaction configuration +- `WorkingDirectory` (string): Working directory for the session (default: runtime process working directory) - `EnableSessionStore` (\*bool): Enables the cross-session store for search and retrieval across sessions. When unset in `ModeCopilotCli`, the runtime default applies (enabled). In `ModeEmpty`, defaults to disabled. - `OnPermissionRequest` (PermissionHandlerFunc): Optional handler called before each tool execution to approve or deny it. When nil, permission requests are emitted as events and left pending for manual resolution. `copilot.PermissionHandler.ApproveAll` approves requests when managed settings are disabled and returns an error when `EnableManagedSettings` is true. Custom handlers can inspect `RequiresManagedApproval()` for human-facing confirmation logic. See [Permission Handling](#permission-handling) section. - `OnUserInputRequest` (UserInputHandler): Handler for user input requests from the agent (enables ask_user tool). See [User Input Requests](#user-input-requests) section. @@ -977,6 +978,25 @@ Communicates with CLI via TCP socket. Useful for distributed scenarios. - `COPILOT_CLI_PATH` - Path to the Copilot CLI executable +## Development + +Tests require a supported [Node.js version](../nodejs/README.md#prerequisites). From the repository root: + +```bash +cd nodejs +npm ci +``` + +```bash +cd test/harness +npm ci +``` + +```bash +cd go +./test.sh +``` + ## License MIT diff --git a/go/client.go b/go/client.go index f867b7db2..d2c43c26b 100644 --- a/go/client.go +++ b/go/client.go @@ -814,6 +814,9 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses req.PluginDirectories = config.PluginDirectories req.InstructionDirectories = config.InstructionDirectories req.DisabledSkills = config.DisabledSkills + if config.DisabledMCPServers != nil { + req.DisabledMCPServers = &config.DisabledMCPServers + } req.InfiniteSessions = config.InfiniteSessions req.LargeOutput = config.LargeOutput req.ToolSearch = config.ToolSearch @@ -871,6 +874,7 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses config.Hooks.OnPostToolUse != nil || config.Hooks.OnPostToolUseFailure != nil || config.Hooks.OnUserPromptSubmitted != nil || + config.Hooks.OnUserPromptTransformed != nil || config.Hooks.OnSessionStart != nil || config.Hooks.OnSessionEnd != nil || config.Hooks.OnErrorOccurred != nil || @@ -1159,6 +1163,7 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string, config.Hooks.OnPostToolUse != nil || config.Hooks.OnPostToolUseFailure != nil || config.Hooks.OnUserPromptSubmitted != nil || + config.Hooks.OnUserPromptTransformed != nil || config.Hooks.OnSessionStart != nil || config.Hooks.OnSessionEnd != nil || config.Hooks.OnErrorOccurred != nil || @@ -1191,6 +1196,9 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string, req.PluginDirectories = config.PluginDirectories req.InstructionDirectories = config.InstructionDirectories req.DisabledSkills = config.DisabledSkills + if config.DisabledMCPServers != nil { + req.DisabledMCPServers = &config.DisabledMCPServers + } req.InfiniteSessions = config.InfiniteSessions req.LargeOutput = config.LargeOutput req.ToolSearch = config.ToolSearch diff --git a/go/client_test.go b/go/client_test.go index 14131bc4c..3cc123898 100644 --- a/go/client_test.go +++ b/go/client_test.go @@ -1103,9 +1103,11 @@ func TestSessionRequests_PluginDirectoriesAndLargeOutput(t *testing.T) { "outputDir": "/tmp/large-output", } expectedPluginDirs := []any{"/tmp/plugins/a", "/tmp/plugins/b"} + expectedDisabledMCPServers := []any{"local-files", "remote-github"} + disabledMCPServers := []string{"local-files", "remote-github"} t.Run("create includes pluginDirectories and largeOutput in JSON when set", func(t *testing.T) { - req := createSessionRequest{PluginDirectories: pluginDirs, LargeOutput: largeOutput} + req := createSessionRequest{PluginDirectories: pluginDirs, DisabledMCPServers: &disabledMCPServers, LargeOutput: largeOutput} data, err := json.Marshal(req) if err != nil { t.Fatalf("Failed to marshal: %v", err) @@ -1117,13 +1119,16 @@ func TestSessionRequests_PluginDirectoriesAndLargeOutput(t *testing.T) { if !reflect.DeepEqual(m["pluginDirectories"], expectedPluginDirs) { t.Errorf("Expected pluginDirectories %v, got %v", expectedPluginDirs, m["pluginDirectories"]) } + if !reflect.DeepEqual(m["disabledMcpServers"], expectedDisabledMCPServers) { + t.Errorf("Expected disabledMcpServers %v, got %v", expectedDisabledMCPServers, m["disabledMcpServers"]) + } if !reflect.DeepEqual(m["largeOutput"], expectedLargeOutput) { t.Errorf("Expected largeOutput %v, got %v", expectedLargeOutput, m["largeOutput"]) } }) t.Run("resume includes pluginDirectories and largeOutput in JSON when set", func(t *testing.T) { - req := resumeSessionRequest{SessionID: "s1", PluginDirectories: pluginDirs, LargeOutput: largeOutput} + req := resumeSessionRequest{SessionID: "s1", PluginDirectories: pluginDirs, DisabledMCPServers: &disabledMCPServers, LargeOutput: largeOutput} data, err := json.Marshal(req) if err != nil { t.Fatalf("Failed to marshal: %v", err) @@ -1135,11 +1140,36 @@ func TestSessionRequests_PluginDirectoriesAndLargeOutput(t *testing.T) { if !reflect.DeepEqual(m["pluginDirectories"], expectedPluginDirs) { t.Errorf("Expected pluginDirectories %v, got %v", expectedPluginDirs, m["pluginDirectories"]) } + if !reflect.DeepEqual(m["disabledMcpServers"], expectedDisabledMCPServers) { + t.Errorf("Expected disabledMcpServers %v, got %v", expectedDisabledMCPServers, m["disabledMcpServers"]) + } if !reflect.DeepEqual(m["largeOutput"], expectedLargeOutput) { t.Errorf("Expected largeOutput %v, got %v", expectedLargeOutput, m["largeOutput"]) } }) + t.Run("create and resume include explicit empty disabledMcpServers", func(t *testing.T) { + emptyDisabledMCPServers := []string{} + requests := []any{ + createSessionRequest{DisabledMCPServers: &emptyDisabledMCPServers}, + resumeSessionRequest{SessionID: "s1", DisabledMCPServers: &emptyDisabledMCPServers}, + } + + for _, request := range requests { + data, err := json.Marshal(request) + if err != nil { + t.Fatalf("Failed to marshal: %v", err) + } + var m map[string]any + if err := json.Unmarshal(data, &m); err != nil { + t.Fatalf("Failed to unmarshal: %v", err) + } + if value, ok := m["disabledMcpServers"]; !ok || !reflect.DeepEqual(value, []any{}) { + t.Errorf("Expected explicit empty disabledMcpServers, got %v", value) + } + } + }) + t.Run("create omits pluginDirectories and largeOutput when nil", func(t *testing.T) { req := createSessionRequest{} data, err := json.Marshal(req) @@ -1153,10 +1183,28 @@ func TestSessionRequests_PluginDirectoriesAndLargeOutput(t *testing.T) { if _, ok := m["pluginDirectories"]; ok { t.Errorf("Expected pluginDirectories to be omitted") } + if _, ok := m["disabledMcpServers"]; ok { + t.Error("Expected disabledMcpServers to be omitted") + } if _, ok := m["largeOutput"]; ok { t.Errorf("Expected largeOutput to be omitted") } }) + + t.Run("resume omits disabledMcpServers when nil", func(t *testing.T) { + req := resumeSessionRequest{SessionID: "s1"} + data, err := json.Marshal(req) + if err != nil { + t.Fatalf("Failed to marshal: %v", err) + } + var m map[string]any + if err := json.Unmarshal(data, &m); err != nil { + t.Fatalf("Failed to unmarshal: %v", err) + } + if _, ok := m["disabledMcpServers"]; ok { + t.Error("Expected disabledMcpServers to be omitted") + } + }) } func TestSessionRequests_Memory(t *testing.T) { diff --git a/go/internal/e2e/hooks_extended_e2e_test.go b/go/internal/e2e/hooks_extended_e2e_test.go index 1afb05a13..5cbba3856 100644 --- a/go/internal/e2e/hooks_extended_e2e_test.go +++ b/go/internal/e2e/hooks_extended_e2e_test.go @@ -14,8 +14,9 @@ import ( // Mirrors dotnet/test/HookLifecycleAndOutputTests.cs (snapshot category "hooks_extended"). // // Covers each handler exposed on copilot.SessionHooks: OnPreToolUse, -// OnPostToolUse, OnPostToolUseFailure, OnUserPromptSubmitted, OnSessionStart, -// OnSessionEnd, OnErrorOccurred, OnAgentStop. Output-shape behavior (modifiedPrompt / +// OnPostToolUse, OnPostToolUseFailure, OnUserPromptSubmitted, +// OnUserPromptTransformed, OnSessionStart, OnSessionEnd, OnErrorOccurred, +// OnAgentStop. Output-shape behavior (modifiedPrompt / modifiedTransformedPrompt / // additionalContext / errorHandling / modifiedArgs / modifiedResult / // sessionSummary) is asserted alongside hook invocation. If a new handler is // added to SessionHooks, add a corresponding test here. @@ -72,6 +73,59 @@ func TestHooksExtendedE2E(t *testing.T) { } }) + t.Run("should invoke userPromptTransformed hook and modify transformed prompt", func(t *testing.T) { + ctx.ConfigureForTest(t) + + var ( + mu sync.Mutex + inputs []copilot.UserPromptTransformedHookInput + ) + session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + Hooks: &copilot.SessionHooks{ + OnUserPromptTransformed: func(input copilot.UserPromptTransformedHookInput, invocation copilot.HookInvocation) (*copilot.UserPromptTransformedHookOutput, error) { + mu.Lock() + inputs = append(inputs, input) + mu.Unlock() + if invocation.SessionID == "" { + t.Error("Expected non-empty session ID in invocation") + } + return &copilot.UserPromptTransformedHookOutput{ + ModifiedTransformedPrompt: copilot.String("Reply with exactly: HOOKED_TRANSFORMED_PROMPT"), + }, nil + }, + }, + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + response, err := session.SendAndWait(t.Context(), copilot.MessageOptions{Prompt: "Answer the request above."}) + if err != nil { + t.Fatalf("Failed to send message: %v", err) + } + + mu.Lock() + defer mu.Unlock() + if len(inputs) == 0 { + t.Fatal("Expected at least one userPromptTransformed hook invocation") + } + if !strings.Contains(inputs[0].Prompt, "Answer the request above.") { + t.Errorf("Expected original prompt in hook input, got %q", inputs[0].Prompt) + } + if !strings.Contains(inputs[0].TransformedPrompt, "Answer the request above.") || + !strings.Contains(inputs[0].TransformedPrompt, "") { + t.Errorf("Expected runtime-transformed prompt in hook input, got %q", inputs[0].TransformedPrompt) + } + if !inputs[0].Timestamp.After(time.UnixMilli(0)) || inputs[0].WorkingDirectory == "" { + t.Error("Expected timestamp and working directory in hook input") + } + assistantMessage, ok := response.Data.(*copilot.AssistantMessageData) + if !ok || !strings.Contains(assistantMessage.Content, "HOOKED_TRANSFORMED_PROMPT") { + t.Errorf("Expected transformed prompt response, got %v", response.Data) + } + }) + t.Run("should invoke sessionStart hook", func(t *testing.T) { ctx.ConfigureForTest(t) diff --git a/go/session.go b/go/session.go index a04119720..99939de4a 100644 --- a/go/session.go +++ b/go/session.go @@ -786,6 +786,16 @@ func (s *Session) handleHooksInvoke(hookType string, rawInput json.RawMessage) ( } return hooks.OnUserPromptSubmitted(input, invocation) + case "userPromptTransformed": + if hooks.OnUserPromptTransformed == nil { + return nil, nil + } + var input UserPromptTransformedHookInput + if err := json.Unmarshal(rawInput, &input); err != nil { + return nil, fmt.Errorf("invalid hook input: %w", err) + } + return hooks.OnUserPromptTransformed(input, invocation) + case "sessionStart": if hooks.OnSessionStart == nil { return nil, nil @@ -1769,7 +1779,7 @@ func (s *Session) Abort(ctx context.Context) error { // SetModelOptions configures optional parameters for SetModel. type SetModelOptions struct { - // ReasoningEffort sets the reasoning effort level for the new model (e.g., "low", "medium", "high", "xhigh"). + // ReasoningEffort sets the reasoning effort level for the new model (e.g., "low", "medium", "high", "xhigh", "max"). ReasoningEffort *string // ReasoningSummary sets the reasoning summary mode for the new model. // Use ReasoningSummaryNone to suppress summary output regardless of whether reasoning is enabled. diff --git a/go/types.go b/go/types.go index 3ddcf930d..e44d91acd 100644 --- a/go/types.go +++ b/go/types.go @@ -679,6 +679,46 @@ type UserPromptSubmittedHookOutput struct { // UserPromptSubmittedHandler handles user-prompt-submitted hook invocations type UserPromptSubmittedHandler func(input UserPromptSubmittedHookInput, invocation HookInvocation) (*UserPromptSubmittedHookOutput, error) +// UserPromptTransformedHookInput is the input for a user-prompt-transformed hook. +type UserPromptTransformedHookInput struct { + SessionID string `json:"sessionId"` + Timestamp time.Time `json:"-"` + WorkingDirectory string `json:"cwd"` + Prompt string `json:"prompt"` + TransformedPrompt string `json:"transformedPrompt"` +} + +// MarshalJSON implements json.Marshaler, emitting Timestamp as Unix milliseconds. +func (h UserPromptTransformedHookInput) MarshalJSON() ([]byte, error) { + type alias UserPromptTransformedHookInput + return json.Marshal(&struct { + Timestamp int64 `json:"timestamp"` + alias + }{Timestamp: h.Timestamp.UnixMilli(), alias: alias(h)}) +} + +// UnmarshalJSON implements json.Unmarshaler, parsing Timestamp from Unix milliseconds. +func (h *UserPromptTransformedHookInput) UnmarshalJSON(data []byte) error { + type alias UserPromptTransformedHookInput + aux := &struct { + Timestamp int64 `json:"timestamp"` + *alias + }{alias: (*alias)(h)} + if err := json.Unmarshal(data, aux); err != nil { + return err + } + h.Timestamp = time.UnixMilli(aux.Timestamp) + return nil +} + +// UserPromptTransformedHookOutput is the output for a user-prompt-transformed hook. +type UserPromptTransformedHookOutput struct { + ModifiedTransformedPrompt *string `json:"modifiedTransformedPrompt,omitempty"` +} + +// UserPromptTransformedHandler handles user-prompt-transformed hook invocations. +type UserPromptTransformedHandler func(input UserPromptTransformedHookInput, invocation HookInvocation) (*UserPromptTransformedHookOutput, error) + // SessionStartHookInput is the input for a session-start hook type SessionStartHookInput struct { SessionID string `json:"sessionId"` @@ -899,15 +939,16 @@ type HookInvocation struct { // SessionHooks configures hook handlers for a session type SessionHooks struct { - OnPreToolUse PreToolUseHandler - OnPostToolUse PostToolUseHandler - OnPostToolUseFailure PostToolUseFailureHandler - OnUserPromptSubmitted UserPromptSubmittedHandler - OnSessionStart SessionStartHandler - OnSessionEnd SessionEndHandler - OnErrorOccurred ErrorOccurredHandler - OnAgentStop AgentStopHandler - OnPreMCPToolCall PreMCPToolCallHandler + OnPreToolUse PreToolUseHandler + OnPostToolUse PostToolUseHandler + OnPostToolUseFailure PostToolUseFailureHandler + OnUserPromptSubmitted UserPromptSubmittedHandler + OnUserPromptTransformed UserPromptTransformedHandler + OnSessionStart SessionStartHandler + OnSessionEnd SessionEndHandler + OnErrorOccurred ErrorOccurredHandler + OnAgentStop AgentStopHandler + OnPreMCPToolCall PreMCPToolCallHandler } // MCPServerConfig is implemented by MCP server configuration types. @@ -1163,7 +1204,7 @@ type SessionConfig struct { // Model to use for this session Model string // ReasoningEffort level for models that support it. - // Valid values: "low", "medium", "high", "xhigh" + // Valid values: "low", "medium", "high", "xhigh", "max" // Only applies to models where capabilities.supports.reasoningEffort is true. ReasoningEffort string // ReasoningSummary mode for models that support configurable reasoning summaries. @@ -1335,6 +1376,10 @@ type SessionConfig struct { InstructionDirectories []string // DisabledSkills is a list of skill names to disable DisabledSkills []string + // DisabledMCPServers is a list of exact MCP server names to disable for this session. + // Disabled servers are not started or authenticated on create or cold resume. + // A resident resume cannot stop servers that are already running. + DisabledMCPServers []string // InfiniteSessions configures infinite sessions for persistent workspaces and automatic compaction. // When enabled (default), sessions automatically manage context limits and persist state. InfiniteSessions *InfiniteSessionConfig @@ -1721,7 +1766,7 @@ type ResumeSessionConfig struct { // Only non-nil fields are applied over the runtime-resolved capabilities. ModelCapabilities *rpc.ModelCapabilitiesOverride // ReasoningEffort level for models that support it. - // Valid values: "low", "medium", "high", "xhigh" + // Valid values: "low", "medium", "high", "xhigh", "max" ReasoningEffort string // ReasoningSummary mode for models that support configurable reasoning summaries. // Use ReasoningSummaryNone to suppress summary output regardless of whether reasoning is enabled. @@ -1814,6 +1859,10 @@ type ResumeSessionConfig struct { InstructionDirectories []string // DisabledSkills is a list of skill names to disable DisabledSkills []string + // DisabledMCPServers is a list of exact MCP server names to disable for this session. + // Disabled servers are not started or authenticated on create or cold resume. + // A resident resume cannot stop servers that are already running. + DisabledMCPServers []string // InfiniteSessions configures infinite sessions for persistent workspaces and automatic compaction. InfiniteSessions *InfiniteSessionConfig // LargeOutput configures handling of large tool outputs. When a tool produces @@ -2349,6 +2398,7 @@ type createSessionRequest struct { PluginDirectories []string `json:"pluginDirectories,omitempty"` InstructionDirectories []string `json:"instructionDirectories,omitempty"` DisabledSkills []string `json:"disabledSkills,omitempty"` + DisabledMCPServers *[]string `json:"disabledMcpServers,omitempty"` InfiniteSessions *InfiniteSessionConfig `json:"infiniteSessions,omitempty"` LargeOutput *LargeToolOutputConfig `json:"largeOutput,omitempty"` ToolSearch *ToolSearchConfig `json:"toolSearch,omitempty"` @@ -2444,6 +2494,7 @@ type resumeSessionRequest struct { PluginDirectories []string `json:"pluginDirectories,omitempty"` InstructionDirectories []string `json:"instructionDirectories,omitempty"` DisabledSkills []string `json:"disabledSkills,omitempty"` + DisabledMCPServers *[]string `json:"disabledMcpServers,omitempty"` InfiniteSessions *InfiniteSessionConfig `json:"infiniteSessions,omitempty"` LargeOutput *LargeToolOutputConfig `json:"largeOutput,omitempty"` ToolSearch *ToolSearchConfig `json:"toolSearch,omitempty"` diff --git a/go/types_test.go b/go/types_test.go index a76ebaad4..4195464b3 100644 --- a/go/types_test.go +++ b/go/types_test.go @@ -5,6 +5,18 @@ import ( "testing" ) +func TestUserPromptTransformedHookOutput_PreservesEmptyReplacement(t *testing.T) { + data, err := json.Marshal(UserPromptTransformedHookOutput{ + ModifiedTransformedPrompt: String(""), + }) + if err != nil { + t.Fatalf("failed to marshal hook output: %v", err) + } + if string(data) != `{"modifiedTransformedPrompt":""}` { + t.Fatalf("expected empty replacement to be preserved, got %s", data) + } +} + func TestProviderConfig_JSONIncludesHeaders(t *testing.T) { config := ProviderConfig{ BaseURL: "https://example.com/provider", diff --git a/java/README.md b/java/README.md index 1ff1c90c7..94f2de410 100644 --- a/java/README.md +++ b/java/README.md @@ -32,14 +32,14 @@ Replace `${copilot.sdk.version}` with the latest release from Maven Central. com.github copilot-sdk-java - 1.0.5-01 + 1.0.9 ``` ### Gradle ```groovy -implementation 'com.github:copilot-sdk-java:1.0.9-preview.3-01' +implementation 'com.github:copilot-sdk-java:1.0.9' ``` #### Snapshot Builds @@ -58,7 +58,7 @@ Snapshot builds of the next development version are published to Maven Central S com.github copilot-sdk-java - 1.0.10-preview.3-SNAPSHOT + 1.0.10-SNAPSHOT ``` @@ -67,7 +67,7 @@ Snapshot builds of the next development version are published to Maven Central S Replace `${copilot.sdk.version}` with the latest release from Maven Central. ```groovy -implementation 'com.github:copilot-sdk-java:1.0.10-preview.3-SNAPSHOT' +implementation 'com.github:copilot-sdk-java:1.0.10-SNAPSHOT' ``` ## Quick Start @@ -127,6 +127,8 @@ and `setExcludedTools(...)`, prefer the source-qualified filter form `DefaultAgentConfig.setExcludedTools(...)`, use `-` directly. +`CopilotClientOptions.setCwd(...)` sets the runtime process working directory, which otherwise inherits the current process working directory. `SessionConfig.setWorkingDirectory(...)` sets the session working directory, which otherwise defaults to the runtime process working directory. + ## Permission Handling `PermissionHandler.APPROVE_ALL` approves requests when managed settings are disabled. When `enableManagedSettings` is true, it completes exceptionally. Custom handlers can inspect `request.getManagedApprovalRequired()` for human-facing confirmation logic. @@ -420,7 +422,7 @@ The gate also applies to individual methods annotated with `@CopilotExperimental ### Development Setup -Requires JDK 25 or later for development. The following steps validate the artifact built with JDK 25 runs on both 25 and 17, preserving the MR-JAR behavior. +Requires JDK 25 or later and a supported [Node.js version](../nodejs/README.md#prerequisites) for development. The following steps validate the artifact built with JDK 25 runs on both 25 and 17, preserving the MR-JAR behavior. ```bash # Clone the repository diff --git a/java/jbang-example.java b/java/jbang-example.java index 59e625724..bea647ae9 100644 --- a/java/jbang-example.java +++ b/java/jbang-example.java @@ -1,5 +1,5 @@ ///usr/bin/env jbang "$0" "$@" ; exit $? -//DEPS com.github:copilot-sdk-java:1.0.9-preview.3-01 +//DEPS com.github:copilot-sdk-java:1.0.9 import com.github.copilot.CopilotClient; import com.github.copilot.generated.AssistantMessageEvent; import com.github.copilot.generated.SessionUsageInfoEvent; diff --git a/java/pom.xml b/java/pom.xml index fe3d443d7..efdc43e7e 100644 --- a/java/pom.xml +++ b/java/pom.xml @@ -7,7 +7,7 @@ com.github copilot-sdk-java - 1.0.9-preview.3 + 1.0.9 jar GitHub Copilot SDK :: Java @@ -33,7 +33,7 @@ scm:git:https://github.com/github/copilot-sdk.git scm:git:https://github.com/github/copilot-sdk.git https://github.com/github/copilot-sdk - java/v1.0.9-preview.3 + java/v1.0.9 diff --git a/java/scripts/test-update-documentation-versions.sh b/java/scripts/test-update-documentation-versions.sh new file mode 100755 index 000000000..213bda6c4 --- /dev/null +++ b/java/scripts/test-update-documentation-versions.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +UPDATER="${SCRIPT_DIR}/update-documentation-versions.sh" +TEMP_DIR=$(mktemp -d) +trap 'rm -rf "$TEMP_DIR"' EXIT + +run_case() { + local name=$1 + local old_version=$2 + local old_dev_version=$3 + local version=$4 + local dev_version=$5 + local old_jbang_version=$6 + local case_dir="${TEMP_DIR}/${name}" + + mkdir "$case_dir" + printf '%s\n' \ + '' \ + ' copilot-sdk-java' \ + " ${old_version}" \ + '' \ + "implementation 'com.github:copilot-sdk-java:${old_version}'" \ + '' \ + ' copilot-sdk-java' \ + " ${old_dev_version}" \ + '' \ + "implementation 'com.github:copilot-sdk-java:${old_dev_version}'" \ + > "${case_dir}/README.md" + printf '%s\n' \ + "///usr/bin/env jbang \"\$0\" \"\$@\" ; exit \$?" \ + "//DEPS com.github:copilot-sdk-java:${old_jbang_version}" \ + > "${case_dir}/jbang-example.java" + + "$UPDATER" "$version" "$dev_version" "${case_dir}/README.md" "${case_dir}/jbang-example.java" + + grep -Fqx " ${version}" "${case_dir}/README.md" + grep -Fqx "implementation 'com.github:copilot-sdk-java:${version}'" "${case_dir}/README.md" + grep -Fqx " ${dev_version}" "${case_dir}/README.md" + grep -Fqx "implementation 'com.github:copilot-sdk-java:${dev_version}'" "${case_dir}/README.md" + grep -Fqx "//DEPS com.github:copilot-sdk-java:${version}" "${case_dir}/jbang-example.java" + + if grep -Fq "$old_version" "${case_dir}/README.md" "${case_dir}/jbang-example.java" || + grep -Fq "$old_dev_version" "${case_dir}/README.md"; then + echo "Stale version remained in ${name} test output" >&2 + exit 1 + fi +} + +run_case stable 1.0.8 1.0.9-SNAPSHOT 1.0.9 1.0.10-SNAPSHOT "\${project.version}" +run_case preview 1.0.9-preview.2-01 1.0.10-preview.2-SNAPSHOT 1.0.10-preview.2 1.0.11-preview.2-SNAPSHOT 1.0.9-preview.2-01 diff --git a/java/scripts/update-documentation-versions.sh b/java/scripts/update-documentation-versions.sh new file mode 100755 index 000000000..d2d23c4ff --- /dev/null +++ b/java/scripts/update-documentation-versions.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash + +set -euo pipefail + +if [[ $# -ne 4 ]]; then + echo "Usage: $0 " >&2 + exit 2 +fi + +VERSION=$1 +DEV_VERSION=$2 +README=$3 +JBANG_EXAMPLE=$4 +VERSION_FORMAT='[0-9]+\.[0-9]+\.[0-9]+(-(preview|(beta-)?java(-preview)?)\.[0-9]+)?' + +if [[ ! "$VERSION" =~ ^${VERSION_FORMAT}$ ]]; then + echo "Invalid release version: $VERSION" >&2 + exit 2 +fi +if [[ ! "$DEV_VERSION" =~ ^${VERSION_FORMAT}-SNAPSHOT$ ]]; then + echo "Invalid development version: $DEV_VERSION" >&2 + exit 2 +fi +if [[ ! -f "$README" || ! -f "$JBANG_EXAMPLE" ]]; then + echo "README and JBang example files must exist" >&2 + exit 2 +fi + +export VERSION DEV_VERSION + +perl -0 - "$README" <<'PERL' +use strict; +use warnings; + +my ($path) = @ARGV; +open my $input, '<', $path or die "Cannot read $path: $!\n"; +my $content = do { local $/; <$input> }; +close $input or die "Cannot close $path: $!\n"; + +# Match accepted release versions plus numeric suffixes left by the former broken updater. +my $version = qr/[0-9]+\.[0-9]+\.[0-9]+(?:-(?:preview|(?:beta-)?java(?:-preview)?)\.[0-9]+)?(?:-[0-9]+)*/; +my $snapshot_xml = ($content =~ s{$version-SNAPSHOT}{$ENV{DEV_VERSION}}g); +my $snapshot_gradle = ($content =~ s{(copilot-sdk-java:)$version-SNAPSHOT(?![-A-Za-z0-9.])}{$1 . $ENV{DEV_VERSION}}ge); +my $release_xml = ($content =~ s{$version}{$ENV{VERSION}}g); +my $release_gradle = ($content =~ s{(copilot-sdk-java:)$version(?![-A-Za-z0-9.])}{$1 . $ENV{VERSION}}ge); + +die "Expected one release and one snapshot example for both Maven and Gradle in $path\n" + unless $snapshot_xml == 1 && $snapshot_gradle == 1 && $release_xml == 1 && $release_gradle == 1; + +open my $output, '>', $path or die "Cannot write $path: $!\n"; +print {$output} $content; +close $output or die "Cannot close $path: $!\n"; +PERL + +perl -0 - "$JBANG_EXAMPLE" <<'PERL' +use strict; +use warnings; + +my ($path) = @ARGV; +open my $input, '<', $path or die "Cannot read $path: $!\n"; +my $content = do { local $/; <$input> }; +close $input or die "Cannot close $path: $!\n"; + +my $version = qr/[0-9]+\.[0-9]+\.[0-9]+(?:-(?:preview|(?:beta-)?java(?:-preview)?)\.[0-9]+)?(?:-[0-9]+)*/; +my $version_count = ($content =~ s{(copilot-sdk-java:)$version(?![-A-Za-z0-9.])}{$1 . $ENV{VERSION}}ge); +my $placeholder_count = ($content =~ s{copilot-sdk-java:\$\{project\.version\}}{copilot-sdk-java:$ENV{VERSION}}g); + +die "Expected exactly one Copilot SDK dependency in $path\n" + unless $version_count + $placeholder_count == 1; + +open my $output, '>', $path or die "Cannot write $path: $!\n"; +print {$output} $content; +close $output or die "Cannot close $path: $!\n"; +PERL + +grep -Fqx " ${VERSION}" "$README" +grep -Fqx "implementation 'com.github:copilot-sdk-java:${VERSION}'" "$README" +grep -Fqx " ${DEV_VERSION}" "$README" +grep -Fqx "implementation 'com.github:copilot-sdk-java:${DEV_VERSION}'" "$README" +grep -Fqx "//DEPS com.github:copilot-sdk-java:${VERSION}" "$JBANG_EXAMPLE" diff --git a/java/src/main/java/com/github/copilot/CopilotSession.java b/java/src/main/java/com/github/copilot/CopilotSession.java index aacd9ee48..b977b69a1 100644 --- a/java/src/main/java/com/github/copilot/CopilotSession.java +++ b/java/src/main/java/com/github/copilot/CopilotSession.java @@ -110,6 +110,7 @@ import com.github.copilot.rpc.UserInputRequest; import com.github.copilot.rpc.UserInputResponse; import com.github.copilot.rpc.UserPromptSubmittedHookInput; +import com.github.copilot.rpc.UserPromptTransformedHookInput; /** * Represents a single conversation session with the Copilot CLI. @@ -1869,6 +1870,17 @@ CompletableFuture handleHooksInvoke(String hookType, JsonNode input) { return promptResult.thenApply(output -> (Object) output); } break; + case "userPromptTransformed" : + if (hooks.getOnUserPromptTransformed() != null) { + UserPromptTransformedHookInput transformedInput = MAPPER.treeToValue(input, + UserPromptTransformedHookInput.class); + var transformedResult = hooks.getOnUserPromptTransformed().handle(transformedInput, invocation); + if (transformedResult == null) { + return CompletableFuture.completedFuture(null); + } + return transformedResult.thenApply(output -> (Object) output); + } + break; case "sessionStart" : if (hooks.getOnSessionStart() != null) { SessionStartHookInput startInput = MAPPER.treeToValue(input, SessionStartHookInput.class); @@ -1972,7 +1984,8 @@ public CompletableFuture abort() { * the model ID to switch to (e.g., {@code "gpt-5.4"}) * @param reasoningEffort * reasoning effort level (e.g., {@code "low"}, {@code "medium"}, - * {@code "high"}, {@code "xhigh"}); {@code null} to use default + * {@code "high"}, {@code "xhigh"}, {@code "max"}); {@code null} to + * use default * @return a future that completes when the model switch is acknowledged * @throws IllegalStateException * if this session has been terminated @@ -2003,7 +2016,8 @@ public CompletableFuture setModel(String model, String reasoningEffort) { * the model ID to switch to (e.g., {@code "gpt-5.4"}) * @param reasoningEffort * reasoning effort level (e.g., {@code "low"}, {@code "medium"}, - * {@code "high"}, {@code "xhigh"}); {@code null} to use default + * {@code "high"}, {@code "xhigh"}, {@code "max"}); {@code null} to + * use default * @param modelCapabilities * per-property overrides for model capabilities; {@code null} to use * runtime defaults diff --git a/java/src/main/java/com/github/copilot/SessionRequestBuilder.java b/java/src/main/java/com/github/copilot/SessionRequestBuilder.java index 62c712025..add4a79b6 100644 --- a/java/src/main/java/com/github/copilot/SessionRequestBuilder.java +++ b/java/src/main/java/com/github/copilot/SessionRequestBuilder.java @@ -160,6 +160,7 @@ static CreateSessionRequest buildCreateRequest(SessionConfig config, String sess request.setToolSearch(config.getToolSearch()); request.setMemory(config.getMemory()); request.setDisabledSkills(config.getDisabledSkills()); + request.setDisabledMcpServers(config.getDisabledMcpServers()); request.setConfigDirectory(config.getConfigDirectory()); config.getEnableConfigDiscovery().ifPresent(request::setEnableConfigDiscovery); config.getSkipEmbeddingRetrieval().ifPresent(request::setSkipEmbeddingRetrieval); @@ -309,6 +310,7 @@ static ResumeSessionRequest buildResumeRequest(String sessionId, ResumeSessionCo request.setToolSearch(config.getToolSearch()); request.setMemory(config.getMemory()); request.setDisabledSkills(config.getDisabledSkills()); + request.setDisabledMcpServers(config.getDisabledMcpServers()); request.setInfiniteSessions(config.getInfiniteSessions()); request.setModelCapabilities(config.getModelCapabilities()); diff --git a/java/src/main/java/com/github/copilot/rpc/CreateSessionRequest.java b/java/src/main/java/com/github/copilot/rpc/CreateSessionRequest.java index 44cb8145c..46f59a28c 100644 --- a/java/src/main/java/com/github/copilot/rpc/CreateSessionRequest.java +++ b/java/src/main/java/com/github/copilot/rpc/CreateSessionRequest.java @@ -152,6 +152,9 @@ public final class CreateSessionRequest { @JsonProperty("disabledSkills") private List disabledSkills; + @JsonProperty("disabledMcpServers") + private List disabledMcpServers; + @JsonProperty("configDir") private String configDirectory; @@ -697,6 +700,18 @@ public void setDisabledSkills(List disabledSkills) { this.disabledSkills = disabledSkills; } + /** Gets disabled MCP server names. @return the server names */ + public List getDisabledMcpServers() { + return disabledMcpServers == null ? null : Collections.unmodifiableList(disabledMcpServers); + } + + /** + * Sets disabled MCP server names. @param disabledMcpServers the server names + */ + public void setDisabledMcpServers(List disabledMcpServers) { + this.disabledMcpServers = disabledMcpServers; + } + /** Gets config directory. @return the config directory path */ public String getConfigDirectory() { return configDirectory; diff --git a/java/src/main/java/com/github/copilot/rpc/ResumeSessionConfig.java b/java/src/main/java/com/github/copilot/rpc/ResumeSessionConfig.java index 35072f129..1e32ec847 100644 --- a/java/src/main/java/com/github/copilot/rpc/ResumeSessionConfig.java +++ b/java/src/main/java/com/github/copilot/rpc/ResumeSessionConfig.java @@ -94,6 +94,7 @@ public class ResumeSessionConfig { private ToolSearchConfig toolSearch; private MemoryConfiguration memory; private List disabledSkills; + private List disabledMcpServers; private InfiniteSessionConfig infiniteSessions; private Consumer onEvent; private List commands; @@ -685,7 +686,8 @@ public ResumeSessionConfig clearManageScheduleEnabled() { /** * Gets the reasoning effort level. * - * @return the reasoning effort level ("low", "medium", "high", or "xhigh") + * @return the reasoning effort level ("low", "medium", "high", "xhigh", or + * "max") */ public String getReasoningEffort() { return reasoningEffort; @@ -694,7 +696,7 @@ public String getReasoningEffort() { /** * Sets the reasoning effort level for models that support it. *

- * Valid values: "low", "medium", "high", "xhigh". + * Valid values: "low", "medium", "high", "xhigh", "max". * * @param reasoningEffort * the reasoning effort level @@ -1576,6 +1578,29 @@ public ResumeSessionConfig setDisabledSkills(List disabledSkills) { return this; } + /** + * Gets exact MCP server names disabled for this session. + * + * @return the disabled MCP server names, or {@code null} when none are disabled + */ + public List getDisabledMcpServers() { + return disabledMcpServers == null ? null : Collections.unmodifiableList(disabledMcpServers); + } + + /** + * Sets exact MCP server names to disable for this session. Disabled servers are + * not started or authenticated on create or cold resume; a resident resume + * cannot stop servers already running. + * + * @param disabledMcpServers + * the server names to disable + * @return this config for method chaining + */ + public ResumeSessionConfig setDisabledMcpServers(List disabledMcpServers) { + this.disabledMcpServers = disabledMcpServers; + return this; + } + /** * Gets the infinite session configuration. * @@ -1953,6 +1978,7 @@ public ResumeSessionConfig clone() { copy.toolSearch = this.toolSearch; copy.memory = this.memory; copy.disabledSkills = this.disabledSkills != null ? new ArrayList<>(this.disabledSkills) : null; + copy.disabledMcpServers = this.disabledMcpServers != null ? new ArrayList<>(this.disabledMcpServers) : null; copy.infiniteSessions = this.infiniteSessions; copy.onEvent = this.onEvent; copy.commands = this.commands != null ? new ArrayList<>(this.commands) : null; diff --git a/java/src/main/java/com/github/copilot/rpc/ResumeSessionRequest.java b/java/src/main/java/com/github/copilot/rpc/ResumeSessionRequest.java index a0ecc2ed5..3fe17b182 100644 --- a/java/src/main/java/com/github/copilot/rpc/ResumeSessionRequest.java +++ b/java/src/main/java/com/github/copilot/rpc/ResumeSessionRequest.java @@ -192,6 +192,9 @@ public final class ResumeSessionRequest { @JsonProperty("disabledSkills") private List disabledSkills; + @JsonProperty("disabledMcpServers") + private List disabledMcpServers; + @JsonProperty("infiniteSessions") private InfiniteSessionConfig infiniteSessions; @@ -913,6 +916,18 @@ public void setDisabledSkills(List disabledSkills) { this.disabledSkills = disabledSkills; } + /** Gets disabled MCP server names. @return the server names */ + public List getDisabledMcpServers() { + return disabledMcpServers == null ? null : Collections.unmodifiableList(disabledMcpServers); + } + + /** + * Sets disabled MCP server names. @param disabledMcpServers the server names + */ + public void setDisabledMcpServers(List disabledMcpServers) { + this.disabledMcpServers = disabledMcpServers; + } + /** Gets infinite sessions config. @return the infinite sessions config */ public InfiniteSessionConfig getInfiniteSessions() { return infiniteSessions; diff --git a/java/src/main/java/com/github/copilot/rpc/SessionConfig.java b/java/src/main/java/com/github/copilot/rpc/SessionConfig.java index c062b1c7c..ad62551fd 100644 --- a/java/src/main/java/com/github/copilot/rpc/SessionConfig.java +++ b/java/src/main/java/com/github/copilot/rpc/SessionConfig.java @@ -84,6 +84,7 @@ public class SessionConfig { private ToolSearchConfig toolSearch; private MemoryConfiguration memory; private List disabledSkills; + private List disabledMcpServers; private String configDirectory; private Boolean enableConfigDiscovery; private Boolean skipEmbeddingRetrieval; @@ -180,7 +181,8 @@ public SessionConfig setModel(String model) { /** * Gets the reasoning effort level. * - * @return the reasoning effort level ("low", "medium", "high", or "xhigh") + * @return the reasoning effort level ("low", "medium", "high", "xhigh", or + * "max") */ public String getReasoningEffort() { return reasoningEffort; @@ -189,8 +191,8 @@ public String getReasoningEffort() { /** * Sets the reasoning effort level for models that support it. *

- * Valid values: "low", "medium", "high", "xhigh". Only applies to models where - * {@code capabilities.supports.reasoningEffort} is true. + * Valid values: "low", "medium", "high", "xhigh", "max". Only applies to models + * where {@code capabilities.supports.reasoningEffort} is true. * * @param reasoningEffort * the reasoning effort level @@ -1267,6 +1269,29 @@ public SessionConfig setDisabledSkills(List disabledSkills) { return this; } + /** + * Gets exact MCP server names disabled for this session. + * + * @return the disabled MCP server names, or {@code null} when none are disabled + */ + public List getDisabledMcpServers() { + return disabledMcpServers == null ? null : Collections.unmodifiableList(disabledMcpServers); + } + + /** + * Sets exact MCP server names to disable for this session. Disabled servers are + * not started or authenticated on create or cold resume; a resident resume + * cannot stop servers already running. + * + * @param disabledMcpServers + * the server names to disable + * @return this config for method chaining + */ + public SessionConfig setDisabledMcpServers(List disabledMcpServers) { + this.disabledMcpServers = disabledMcpServers; + return this; + } + /** * Gets the custom configuration directory. * @@ -2078,6 +2103,7 @@ public SessionConfig clone() { copy.toolSearch = this.toolSearch; copy.memory = this.memory; copy.disabledSkills = this.disabledSkills != null ? new ArrayList<>(this.disabledSkills) : null; + copy.disabledMcpServers = this.disabledMcpServers != null ? new ArrayList<>(this.disabledMcpServers) : null; copy.configDirectory = this.configDirectory; copy.enableConfigDiscovery = this.enableConfigDiscovery; copy.skipEmbeddingRetrieval = this.skipEmbeddingRetrieval; diff --git a/java/src/main/java/com/github/copilot/rpc/SessionHooks.java b/java/src/main/java/com/github/copilot/rpc/SessionHooks.java index 9cf68684d..e476f888e 100644 --- a/java/src/main/java/com/github/copilot/rpc/SessionHooks.java +++ b/java/src/main/java/com/github/copilot/rpc/SessionHooks.java @@ -42,6 +42,7 @@ public class SessionHooks { private PostToolUseHandler onPostToolUse; private PostToolUseFailureHandler onPostToolUseFailure; private UserPromptSubmittedHandler onUserPromptSubmitted; + private UserPromptTransformedHandler onUserPromptTransformed; private SessionStartHandler onSessionStart; private SessionEndHandler onSessionEnd; private AgentStopHandler onAgentStop; @@ -161,6 +162,29 @@ public SessionHooks setOnUserPromptSubmitted(UserPromptSubmittedHandler onUserPr return this; } + /** + * Gets the user-prompt-transformed handler. + * + * @return the handler, or {@code null} if not set + * @since 1.0.11 + */ + public UserPromptTransformedHandler getOnUserPromptTransformed() { + return onUserPromptTransformed; + } + + /** + * Sets the handler called after the runtime transforms a submitted prompt. + * + * @param onUserPromptTransformed + * the handler + * @return this instance for method chaining + * @since 1.0.11 + */ + public SessionHooks setOnUserPromptTransformed(UserPromptTransformedHandler onUserPromptTransformed) { + this.onUserPromptTransformed = onUserPromptTransformed; + return this; + } + /** * Gets the session-start handler. * @@ -237,7 +261,7 @@ public SessionHooks setOnAgentStop(AgentStopHandler onAgentStop) { */ public boolean hasHooks() { return onPreToolUse != null || onPreMcpToolCall != null || onPostToolUse != null || onPostToolUseFailure != null - || onUserPromptSubmitted != null || onSessionStart != null || onSessionEnd != null - || onAgentStop != null; + || onUserPromptSubmitted != null || onUserPromptTransformed != null || onSessionStart != null + || onSessionEnd != null || onAgentStop != null; } } diff --git a/java/src/main/java/com/github/copilot/rpc/UserPromptTransformedHandler.java b/java/src/main/java/com/github/copilot/rpc/UserPromptTransformedHandler.java new file mode 100644 index 000000000..ac8496078 --- /dev/null +++ b/java/src/main/java/com/github/copilot/rpc/UserPromptTransformedHandler.java @@ -0,0 +1,28 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.rpc; + +import java.util.concurrent.CompletableFuture; + +/** + * Handler for user-prompt-transformed hooks. + * + * @since 1.0.11 + */ +@FunctionalInterface +public interface UserPromptTransformedHandler { + + /** + * Handles a transformed user prompt before it is stored or sent to the model. + * + * @param input + * the hook input + * @param invocation + * metadata about the hook invocation + * @return a future resolving to the hook output, or {@code null} + */ + CompletableFuture handle(UserPromptTransformedHookInput input, + HookInvocation invocation); +} diff --git a/java/src/main/java/com/github/copilot/rpc/UserPromptTransformedHookInput.java b/java/src/main/java/com/github/copilot/rpc/UserPromptTransformedHookInput.java new file mode 100644 index 000000000..ea1759658 --- /dev/null +++ b/java/src/main/java/com/github/copilot/rpc/UserPromptTransformedHookInput.java @@ -0,0 +1,29 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.rpc; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Input for user-prompt-transformed hooks. + * + * @param sessionId + * the runtime session ID + * @param timestamp + * Unix timestamp in milliseconds + * @param cwd + * the current working directory + * @param prompt + * the prompt after user-prompt-submitted hooks + * @param transformedPrompt + * the model-facing prompt after runtime transformations + * @since 1.0.11 + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public record UserPromptTransformedHookInput(@JsonProperty("sessionId") String sessionId, + @JsonProperty("timestamp") long timestamp, @JsonProperty("cwd") String cwd, + @JsonProperty("prompt") String prompt, @JsonProperty("transformedPrompt") String transformedPrompt) { +} diff --git a/java/src/main/java/com/github/copilot/rpc/UserPromptTransformedHookOutput.java b/java/src/main/java/com/github/copilot/rpc/UserPromptTransformedHookOutput.java new file mode 100644 index 000000000..615f4ea7b --- /dev/null +++ b/java/src/main/java/com/github/copilot/rpc/UserPromptTransformedHookOutput.java @@ -0,0 +1,20 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.rpc; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Output for user-prompt-transformed hooks. + * + * @param modifiedTransformedPrompt + * replacement model-facing prompt to persist and send to the model + * @since 1.0.11 + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public record UserPromptTransformedHookOutput( + @JsonProperty("modifiedTransformedPrompt") String modifiedTransformedPrompt) { +} diff --git a/java/src/test/java/com/github/copilot/ConfigCloneTest.java b/java/src/test/java/com/github/copilot/ConfigCloneTest.java index a8e7fb2e0..c3f726ca1 100644 --- a/java/src/test/java/com/github/copilot/ConfigCloneTest.java +++ b/java/src/test/java/com/github/copilot/ConfigCloneTest.java @@ -120,6 +120,7 @@ void sessionConfigCloneBasic() { original.setReasoningSummary("detailed"); original.setContextTier("long_context"); original.setPluginDirectories(List.of("/plugins/a", "/plugins/b")); + original.setDisabledMcpServers(List.of("local-files", "remote-github")); original.setLargeOutput( new LargeToolOutputConfig().setEnabled(true).setMaxSizeBytes(1024L).setOutputDirectory("/tmp/out")); original.setMemory(new MemoryConfiguration().setEnabled(true)); @@ -133,6 +134,7 @@ void sessionConfigCloneBasic() { assertEquals(original.getReasoningSummary(), cloned.getReasoningSummary()); assertEquals(original.getContextTier(), cloned.getContextTier()); assertEquals(original.getPluginDirectories(), cloned.getPluginDirectories()); + assertEquals(original.getDisabledMcpServers(), cloned.getDisabledMcpServers()); assertEquals(original.getLargeOutput(), cloned.getLargeOutput()); assertEquals(original.getMemory(), cloned.getMemory()); assertEquals(original.isStreaming(), cloned.isStreaming()); @@ -146,6 +148,7 @@ void sessionConfigListIndependence() { toolList.add("bash"); original.setAvailableTools(toolList); original.setInstructionDirectories(new ArrayList<>(List.of("/path/a", "/path/b"))); + original.setDisabledMcpServers(new ArrayList<>(List.of("local-files"))); SessionConfig cloned = original.clone(); @@ -156,6 +159,7 @@ void sessionConfigListIndependence() { assertEquals(2, cloned.getAvailableTools().size()); assertEquals(3, original.getAvailableTools().size()); assertEquals(List.of("/path/a", "/path/b"), cloned.getInstructionDirectories()); + assertEquals(List.of("local-files"), cloned.getDisabledMcpServers()); } @Test @@ -194,6 +198,7 @@ void resumeSessionConfigCloneBasic() { original.setReasoningSummary("none"); original.setContextTier("long_context"); original.setPluginDirectories(List.of("/plugins/r")); + original.setDisabledMcpServers(List.of("local-files-r")); original.setLargeOutput( new LargeToolOutputConfig().setEnabled(false).setMaxSizeBytes(2048L).setOutputDirectory("/tmp/resume")); original.setMemory(new MemoryConfiguration().setEnabled(false)); @@ -205,6 +210,7 @@ void resumeSessionConfigCloneBasic() { assertEquals(original.getReasoningSummary(), cloned.getReasoningSummary()); assertEquals(original.getContextTier(), cloned.getContextTier()); assertEquals(original.getPluginDirectories(), cloned.getPluginDirectories()); + assertEquals(original.getDisabledMcpServers(), cloned.getDisabledMcpServers()); assertEquals(original.getLargeOutput(), cloned.getLargeOutput()); assertEquals(original.getMemory(), cloned.getMemory()); assertEquals(original.isStreaming(), cloned.isStreaming()); diff --git a/java/src/test/java/com/github/copilot/HooksTest.java b/java/src/test/java/com/github/copilot/HooksTest.java index 98cb962fc..c3833891c 100644 --- a/java/src/test/java/com/github/copilot/HooksTest.java +++ b/java/src/test/java/com/github/copilot/HooksTest.java @@ -27,6 +27,8 @@ import com.github.copilot.rpc.PreToolUseHookOutput; import com.github.copilot.rpc.SessionConfig; import com.github.copilot.rpc.SessionHooks; +import com.github.copilot.rpc.UserPromptTransformedHookInput; +import com.github.copilot.rpc.UserPromptTransformedHookOutput; /** * Tests for hooks functionality (pre-tool-use and post-tool-use hooks). @@ -267,4 +269,34 @@ void testInvokeAgentStopHookAndApplyBlockResponse() throws Exception { assertTrue(response.getData().content().contains("AGENT_STOP_CONTINUED")); } } + + @Test + void testInvokeUserPromptTransformedHookAndModifyTransformedPrompt() throws Exception { + ctx.configureForTest("hooks_extended", + "should_invoke_userprompttransformed_hook_and_modify_transformed_prompt"); + + var inputs = new ArrayList(); + var config = new SessionConfig().setOnPermissionRequest(PermissionHandler.APPROVE_ALL) + .setHooks(new SessionHooks().setOnUserPromptTransformed((input, invocation) -> { + assertFalse(invocation.getSessionId().isBlank()); + inputs.add(input); + return CompletableFuture.completedFuture( + new UserPromptTransformedHookOutput("Reply with exactly: HOOKED_TRANSFORMED_PROMPT")); + })); + + try (CopilotClient client = ctx.createClient()) { + CopilotSession session = client.createSession(config).get(); + var response = session.sendAndWait(new MessageOptions().setPrompt("Answer the request above.")).get(60, + TimeUnit.SECONDS); + + assertFalse(inputs.isEmpty()); + assertTrue(inputs.get(0).prompt().contains("Answer the request above.")); + assertTrue(inputs.get(0).transformedPrompt().contains("Answer the request above.")); + assertTrue(inputs.get(0).transformedPrompt().contains("")); + assertTrue(inputs.get(0).timestamp() > 0); + assertFalse(inputs.get(0).cwd().isBlank()); + assertNotNull(response); + assertTrue(response.getData().content().contains("HOOKED_TRANSFORMED_PROMPT")); + } + } } diff --git a/java/src/test/java/com/github/copilot/SessionHandlerTest.java b/java/src/test/java/com/github/copilot/SessionHandlerTest.java index 05994df8d..345fdccff 100644 --- a/java/src/test/java/com/github/copilot/SessionHandlerTest.java +++ b/java/src/test/java/com/github/copilot/SessionHandlerTest.java @@ -26,6 +26,7 @@ import com.github.copilot.rpc.UserInputRequest; import com.github.copilot.rpc.UserInputResponse; import com.github.copilot.rpc.UserPromptSubmittedHookOutput; +import com.github.copilot.rpc.UserPromptTransformedHookOutput; /** * Unit tests for CopilotSession internal handler methods. @@ -225,6 +226,26 @@ void testHandleHooksInvokeUserPromptSubmitted() throws Exception { assertEquals("modified prompt", output.modifiedPrompt()); } + @Test + void testHandleHooksInvokeUserPromptTransformed() throws Exception { + var hooks = new SessionHooks().setOnUserPromptTransformed((hookInput, invocation) -> { + assertEquals("handler-test-session", invocation.getSessionId()); + assertEquals("original prompt", hookInput.prompt()); + assertEquals("transformed prompt", hookInput.transformedPrompt()); + return CompletableFuture.completedFuture(new UserPromptTransformedHookOutput("replacement prompt")); + }); + session.registerHooks(hooks); + + JsonNode input = MAPPER.valueToTree(Map.of("sessionId", "runtime-session", "timestamp", 1735689600L, "cwd", + "/tmp", "prompt", "original prompt", "transformedPrompt", "transformed prompt")); + + Object result = session.handleHooksInvoke("userPromptTransformed", input).get(); + + assertInstanceOf(UserPromptTransformedHookOutput.class, result); + var output = (UserPromptTransformedHookOutput) result; + assertEquals("replacement prompt", output.modifiedTransformedPrompt()); + } + // ===== handleHooksInvoke: sessionStart ===== @Test diff --git a/java/src/test/java/com/github/copilot/SessionRequestBuilderTest.java b/java/src/test/java/com/github/copilot/SessionRequestBuilderTest.java index 54773662c..329a7500a 100644 --- a/java/src/test/java/com/github/copilot/SessionRequestBuilderTest.java +++ b/java/src/test/java/com/github/copilot/SessionRequestBuilderTest.java @@ -169,13 +169,17 @@ void testBuildCreateRequestSetsContextTier() { } @Test - void testBuildCreateRequestSetsPluginDirectoriesAndLargeOutput() { + void testBuildCreateRequestSetsPluginDirectoriesAndLargeOutput() throws Exception { var largeOutput = new LargeToolOutputConfig().setEnabled(true).setMaxSizeBytes(1024L) .setOutputDirectory("/tmp/out"); - var config = new SessionConfig().setPluginDirectories(List.of("/plugins/a")).setLargeOutput(largeOutput); + var config = new SessionConfig().setPluginDirectories(List.of("/plugins/a")) + .setDisabledMcpServers(List.of("local-files", "remote-github")).setLargeOutput(largeOutput); CreateSessionRequest request = SessionRequestBuilder.buildCreateRequest(config); assertEquals(List.of("/plugins/a"), request.getPluginDirectories()); + assertEquals(List.of("local-files", "remote-github"), request.getDisabledMcpServers()); assertEquals(largeOutput, request.getLargeOutput()); + assertTrue(JsonRpcClient.getObjectMapper().writeValueAsString(request) + .contains("\"disabledMcpServers\":[\"local-files\",\"remote-github\"]")); } @Test @@ -460,13 +464,17 @@ void testBuildResumeRequestSetsContextTier() { } @Test - void testBuildResumeRequestSetsPluginDirectoriesAndLargeOutput() { + void testBuildResumeRequestSetsPluginDirectoriesAndLargeOutput() throws Exception { var largeOutput = new LargeToolOutputConfig().setEnabled(false).setMaxSizeBytes(2048L) .setOutputDirectory("/tmp/resume"); - var config = new ResumeSessionConfig().setPluginDirectories(List.of("/plugins/r")).setLargeOutput(largeOutput); + var config = new ResumeSessionConfig().setPluginDirectories(List.of("/plugins/r")) + .setDisabledMcpServers(List.of("local-files-r")).setLargeOutput(largeOutput); ResumeSessionRequest request = SessionRequestBuilder.buildResumeRequest("sid-16", config); assertEquals(List.of("/plugins/r"), request.getPluginDirectories()); + assertEquals(List.of("local-files-r"), request.getDisabledMcpServers()); assertEquals(largeOutput, request.getLargeOutput()); + assertTrue(JsonRpcClient.getObjectMapper().writeValueAsString(request) + .contains("\"disabledMcpServers\":[\"local-files-r\"]")); } @Test diff --git a/nodejs/README.md b/nodejs/README.md index 4c430da05..eec674ce4 100644 --- a/nodejs/README.md +++ b/nodejs/README.md @@ -131,10 +131,11 @@ Create a new conversation session. - `sessionId?: string` - Custom session ID. - `model?: string` - Model to use ("gpt-5", "claude-sonnet-4.5", etc.). **Required when using custom provider.** -- `reasoningEffort?: "low" | "medium" | "high" | "xhigh"` - Reasoning effort level for models that support it. Use `listModels()` to check which models support this option. +- `reasoningEffort?: "low" | "medium" | "high" | "xhigh" | "max"` - Reasoning effort level for models that support it. Use `listModels()` to check which models support this option. - `tools?: Tool[]` - Custom tools exposed to the CLI. Tools without `handler` are declaration-only and must be resolved via pending tool-call RPCs. - `systemMessage?: SystemMessageConfig` - System message customization (see below) - `infiniteSessions?: InfiniteSessionConfig` - Configure automatic context compaction (see below) +- `workingDirectory?: string` - Working directory for the session (default: runtime process cwd). - `enableSessionStore?: boolean` - Enables the cross-session store for search and retrieval across sessions. When unset in `"copilot-cli"` mode, the runtime default applies (enabled). In `"empty"` mode, defaults to disabled. - `provider?: ProviderConfig` - Custom API provider configuration (BYOK - Bring Your Own Key). See [Custom Providers](#custom-providers) section. - `onPermissionRequest?: PermissionHandler` - Optional handler called before each tool execution to approve or deny it. When omitted, permission requests are emitted as events and left pending for manual resolution. `approveAll` approves requests when managed settings are disabled and throws when `enableManagedSettings` is true. Custom handlers can inspect `managedApprovalRequired` for human-facing confirmation logic. See [Permission Handling](#permission-handling) section. @@ -1108,6 +1109,21 @@ try { } ``` +## Development + +From the repository root: + +```bash +cd test/harness +npm ci +``` + +```bash +cd nodejs +npm ci +npm test +``` + ## License MIT diff --git a/nodejs/docs/agent-author.md b/nodejs/docs/agent-author.md index fa4bfb1ba..6b9366a7e 100644 --- a/nodejs/docs/agent-author.md +++ b/nodejs/docs/agent-author.md @@ -270,7 +270,7 @@ const unsub = session.on("tool.execution_complete", (event) => { | `tool.execution_start` | `toolCallId`, `toolName`, `arguments` | | `tool.execution_complete` | `toolCallId`, `success`, `result`, `error` | | `user.message` | `content`, `attachments`, `source` | -| `session.idle` | `backgroundTasks` | +| `session.idle` | `aborted` | | `session.error` | `errorType`, `message`, `stack` | | `permission.requested` | `requestId`, `permissionRequest.kind` | | `session.shutdown` | `shutdownType`, `totalPremiumRequests` | diff --git a/nodejs/docs/examples.md b/nodejs/docs/examples.md index a1c016cdf..63389c491 100644 --- a/nodejs/docs/examples.md +++ b/nodejs/docs/examples.md @@ -419,7 +419,7 @@ session.on("assistant.message", (event) => { | `tool.execution_start` | A tool is about to run | `toolCallId`, `toolName`, `arguments` | | `tool.execution_complete` | A tool finished running | `toolCallId`, `success`, `result`, `error` | | `user.message` | User sent a message | `content`, `attachments`, `source` | -| `session.idle` | Session finished processing a turn | `backgroundTasks` | +| `session.idle` | Session finished processing a turn | `aborted` | | `session.error` | An error occurred | `errorType`, `message`, `stack` | | `permission.requested` | Agent needs permission (shell, file write, etc.) | `requestId`, `permissionRequest.kind` | | `session.shutdown` | Session is ending | `shutdownType`, `totalPremiumRequests`, `codeChanges` | diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json index 0829c3f70..b3c43e927 100644 --- a/nodejs/package-lock.json +++ b/nodejs/package-lock.json @@ -3235,9 +3235,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "version": "3.3.17", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz", + "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==", "dev": true, "funding": [ { @@ -3444,9 +3444,9 @@ } }, "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "version": "8.5.25", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", + "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", "dev": true, "funding": [ { @@ -3464,7 +3464,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 3e100eddd..b4fe0f463 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -1599,6 +1599,7 @@ export class CopilotClient { pluginDirectories: config.pluginDirectories, instructionDirectories: config.instructionDirectories, disabledSkills: config.disabledSkills, + disabledMcpServers: config.disabledMcpServers, infiniteSessions: config.infiniteSessions, memory: config.memory, gitHubToken: config.gitHubToken, @@ -1842,6 +1843,7 @@ export class CopilotClient { pluginDirectories: config.pluginDirectories, instructionDirectories: config.instructionDirectories, disabledSkills: config.disabledSkills, + disabledMcpServers: config.disabledMcpServers, infiniteSessions: config.infiniteSessions, memory: config.memory, disableResume: config.suppressResumeEvent, diff --git a/nodejs/src/index.ts b/nodejs/src/index.ts index 8d79a71a1..f915a8707 100644 --- a/nodejs/src/index.ts +++ b/nodejs/src/index.ts @@ -63,6 +63,9 @@ export type { AgentStopHandler, AgentStopHookInput, AgentStopHookOutput, + UserPromptTransformedHandler, + UserPromptTransformedHookInput, + UserPromptTransformedHookOutput, CopilotClientMode, CopilotClientOptions, CopilotExpAssignmentResponse, diff --git a/nodejs/src/session.ts b/nodejs/src/session.ts index e0a3df0e6..ed575a515 100644 --- a/nodejs/src/session.ts +++ b/nodejs/src/session.ts @@ -734,11 +734,10 @@ export class CopilotSession { typeof optionsOrPrompt === "string" ? { prompt: optionsOrPrompt } : optionsOrPrompt; const effectiveTimeout = timeout ?? 60_000; - let resolveIdle: () => void; - let rejectWithError: (error: Error) => void; - const idlePromise = new Promise((resolve, reject) => { - resolveIdle = resolve; - rejectWithError = reject; + type SessionOutcome = { kind: "idle" } | { kind: "error"; error: Error }; + let resolveOutcome: (outcome: SessionOutcome) => void; + const outcomePromise = new Promise((resolve) => { + resolveOutcome = resolve; }); let lastAssistantMessage: AssistantMessageEvent | undefined; @@ -749,11 +748,11 @@ export class CopilotSession { if (event.type === "assistant.message") { lastAssistantMessage = event; } else if (event.type === "session.idle") { - resolveIdle(); + resolveOutcome({ kind: "idle" }); } else if (event.type === "session.error") { const error = new Error(event.data.message); error.stack = event.data.stack; - rejectWithError(error); + resolveOutcome({ kind: "error", error }); } }); @@ -772,7 +771,10 @@ export class CopilotSession { effectiveTimeout ); }); - await Promise.race([idlePromise, timeoutPromise]); + const outcome = await Promise.race([outcomePromise, timeoutPromise]); + if (outcome.kind === "error") { + throw outcome.error; + } return lastAssistantMessage; } finally { @@ -1879,6 +1881,7 @@ export class CopilotSession { postToolUse: this.hooks.onPostToolUse as GenericHandler | undefined, postToolUseFailure: this.hooks.onPostToolUseFailure as GenericHandler | undefined, userPromptSubmitted: this.hooks.onUserPromptSubmitted as GenericHandler | undefined, + userPromptTransformed: this.hooks.onUserPromptTransformed as GenericHandler | undefined, sessionStart: this.hooks.onSessionStart as GenericHandler | undefined, sessionEnd: this.hooks.onSessionEnd as GenericHandler | undefined, errorOccurred: this.hooks.onErrorOccurred as GenericHandler | undefined, diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index a8a9410f8..035ab6563 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -1437,6 +1437,33 @@ export type UserPromptSubmittedHandler = ( invocation: { sessionId: string } ) => Promise | UserPromptSubmittedHookOutput | void; +/** + * Input for the user-prompt-transformed hook. + * + * This hook runs after the runtime has transformed the submitted prompt with + * generated context, but before it is persisted to session history or sent to + * the model. + */ +export interface UserPromptTransformedHookInput extends BaseHookInput { + prompt: string; + transformedPrompt: string; +} + +/** + * Output for the user-prompt-transformed hook. + */ +export interface UserPromptTransformedHookOutput { + modifiedTransformedPrompt?: string; +} + +/** + * Handler for the user-prompt-transformed hook. + */ +export type UserPromptTransformedHandler = ( + input: UserPromptTransformedHookInput, + invocation: { sessionId: string } +) => Promise | UserPromptTransformedHookOutput | void; + /** * Input for session-start hook */ @@ -1593,6 +1620,11 @@ export interface SessionHooks { */ onUserPromptSubmitted?: UserPromptSubmittedHandler; + /** + * Called after the runtime transforms a submitted prompt and before it is stored. + */ + onUserPromptTransformed?: UserPromptTransformedHandler; + /** * Called when a session starts */ @@ -1823,7 +1855,7 @@ export interface LargeToolOutputConfig { /** * Valid reasoning effort levels for models that support it. */ -export type ReasoningEffort = "low" | "medium" | "high" | "xhigh"; +export type ReasoningEffort = "low" | "medium" | "high" | "xhigh" | "max"; /** * Context window tier for the session. "long_context" pins the session to the @@ -2503,6 +2535,13 @@ export interface SessionConfigBase { */ disabledSkills?: string[]; + /** + * Exact MCP server names to disable for this session. Disabled servers are not + * started or authenticated when creating or cold-resuming a session. Supplying + * this on a resident resume cannot stop servers that are already running. + */ + disabledMcpServers?: string[]; + /** * Infinite session configuration for persistent workspaces and automatic compaction. * When enabled (default), sessions automatically manage context limits and persist state. diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index bbe6fbe66..254a21fa4 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -1030,6 +1030,7 @@ describe("CopilotClient", () => { }); const pluginDirs = ["/tmp/plugins/a", "/tmp/plugins/b"]; + const disabledMcpServers = ["local-files", "remote-github"]; const largeOutput = { enabled: true, maxSizeBytes: 1024, @@ -1044,11 +1045,13 @@ describe("CopilotClient", () => { const session = await client.createSession({ onPermissionRequest: approveAll, pluginDirectories: pluginDirs, + disabledMcpServers, largeOutput, }); await client.resumeSession(session.sessionId, { onPermissionRequest: approveAll, pluginDirectories: pluginDirs, + disabledMcpServers, largeOutput, }); @@ -1059,8 +1062,10 @@ describe("CopilotClient", () => { ([method]) => method === "session.resume" )![1] as any; expect(createPayload.pluginDirectories).toEqual(pluginDirs); + expect(createPayload.disabledMcpServers).toEqual(disabledMcpServers); expect(createPayload.largeOutput).toEqual(expectedWireLargeOutput); expect(resumePayload.pluginDirectories).toEqual(pluginDirs); + expect(resumePayload.disabledMcpServers).toEqual(disabledMcpServers); expect(resumePayload.largeOutput).toEqual(expectedWireLargeOutput); }); diff --git a/nodejs/test/e2e/disabled_mcp_servers.e2e.test.ts b/nodejs/test/e2e/disabled_mcp_servers.e2e.test.ts new file mode 100644 index 000000000..ce1a504e8 --- /dev/null +++ b/nodejs/test/e2e/disabled_mcp_servers.e2e.test.ts @@ -0,0 +1,485 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import { randomUUID } from "node:crypto"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { + approveAll, + CopilotRequestHandler, + RuntimeConnection, + type CopilotSession, +} from "../../src/index.js"; +import { createSdkTestContext, isInProcessTransport } from "./harness/sdkTestContext.js"; +import { waitForCondition } from "./harness/sdkTestHelper.js"; + +const __dirname = resolve(fileURLToPath(new URL(".", import.meta.url))); +const TEST_MCP_SERVER = resolve(__dirname, "../../../test/harness/test-mcp-server.mjs"); +const SYNTHETIC_RESPONSE = "PERSISTED_SESSION_READY"; +const MCP_TRIGGER_PROMPT = "Reply with the configured MCP test completion marker."; + +class PersistingRequestHandler extends CopilotRequestHandler { + protected override async sendRequest(request: Request): Promise { + const body = request.body ? await request.text() : ""; + const wantsStream = /"stream"\s*:\s*true/.test(body); + const url = request.url.toLowerCase(); + + if (url.endsWith("/models")) { + return new Response(MODEL_CATALOG_JSON, { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + + if (url.includes("/responses")) { + return new Response(wantsStream ? RESPONSE_STREAM : RESPONSE_JSON, { + status: 200, + headers: { + "content-type": wantsStream ? "text/event-stream" : "application/json", + }, + }); + } + + if (url.includes("/chat/completions")) { + return new Response( + wantsStream ? CHAT_COMPLETION_STREAM : CHAT_COMPLETION_RESPONSE_JSON, + { + status: 200, + headers: { + "content-type": wantsStream ? "text/event-stream" : "application/json", + }, + } + ); + } + + return new Response("{}", { + status: 200, + headers: { "content-type": "application/json" }, + }); + } +} + +const RESPONSE_STREAM = [ + { + event: "response.created", + data: { + type: "response.created", + response: { + id: "persisted-session", + object: "response", + status: "in_progress", + output: [], + }, + }, + }, + { + event: "response.output_item.added", + data: { + type: "response.output_item.added", + output_index: 0, + item: { id: "message-1", type: "message", role: "assistant", content: [] }, + }, + }, + { + event: "response.content_part.added", + data: { + type: "response.content_part.added", + output_index: 0, + content_index: 0, + part: { type: "output_text", text: "" }, + }, + }, + { + event: "response.output_text.delta", + data: { + type: "response.output_text.delta", + output_index: 0, + content_index: 0, + delta: SYNTHETIC_RESPONSE, + }, + }, + { + event: "response.output_text.done", + data: { + type: "response.output_text.done", + output_index: 0, + content_index: 0, + text: SYNTHETIC_RESPONSE, + }, + }, + { + event: "response.completed", + data: { + type: "response.completed", + response: { + id: "persisted-session", + object: "response", + status: "completed", + output: [ + { + id: "message-1", + type: "message", + role: "assistant", + content: [{ type: "output_text", text: SYNTHETIC_RESPONSE }], + }, + ], + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, + }, + }, + }, +] + .map(({ event, data }) => `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`) + .join(""); + +const RESPONSE_JSON = JSON.stringify({ + id: "persisted-session", + object: "response", + status: "completed", + output: [ + { + id: "message-1", + type: "message", + role: "assistant", + content: [{ type: "output_text", text: SYNTHETIC_RESPONSE }], + }, + ], + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, +}); + +const CHAT_COMPLETION_STREAM = [ + { + id: "persisted-session", + object: "chat.completion.chunk", + created: 1, + model: "claude-sonnet-4.5", + choices: [ + { + index: 0, + delta: { role: "assistant", content: SYNTHETIC_RESPONSE }, + finish_reason: null, + }, + ], + }, + { + id: "persisted-session", + object: "chat.completion.chunk", + created: 1, + model: "claude-sonnet-4.5", + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + }, +] + .map((data) => `data: ${JSON.stringify(data)}\n\n`) + .concat("data: [DONE]\n\n") + .join(""); + +const CHAT_COMPLETION_RESPONSE_JSON = JSON.stringify({ + id: "persisted-session", + object: "chat.completion", + created: 1, + model: "claude-sonnet-4.5", + choices: [ + { + index: 0, + message: { role: "assistant", content: SYNTHETIC_RESPONSE }, + finish_reason: "stop", + }, + ], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, +}); + +const MODEL_CATALOG_JSON = JSON.stringify({ + data: [ + { + id: "claude-sonnet-4.5", + name: "Claude Sonnet 4.5", + object: "model", + vendor: "Anthropic", + version: "1", + preview: false, + model_picker_enabled: true, + capabilities: { + type: "chat", + family: "claude-sonnet-4.5", + tokenizer: "o200k_base", + limits: { max_context_window_tokens: 200000, max_output_tokens: 8192 }, + supports: { streaming: true, tool_calls: true, parallel_tool_calls: true }, + }, + }, + ], +}); + +describe("disabled MCP servers", async () => { + const { + copilotClient: client, + createClient, + openAiEndpoint, + workDir, + } = await createSdkTestContext({ + copilotClientOptions: { + requestHandler: new PersistingRequestHandler(), + }, + }); + + function createPluginDirectory(prefix: string): { + pluginDirectory: string; + controlMarker: string; + disabledMarker: string; + } { + const pluginDirectory = join(workDir, `${prefix}-${randomUUID()}`); + mkdirSync(pluginDirectory, { recursive: true }); + const controlMarker = join(pluginDirectory, "control-started.log"); + const disabledMarker = join(pluginDirectory, "disabled-started.log"); + + writeFileSync( + join(pluginDirectory, "plugin.json"), + JSON.stringify({ + name: `${prefix}-${randomUUID()}`, + version: "1.0.0", + }) + ); + writeFileSync( + join(pluginDirectory, ".mcp.json"), + JSON.stringify({ + mcpServers: { + control: { + type: "stdio", + command: process.execPath, + args: [ + TEST_MCP_SERVER, + "--startup-marker", + controlMarker, + "--server-name", + "control", + ], + }, + disabled: { + type: "stdio", + command: process.execPath, + args: [ + TEST_MCP_SERVER, + "--startup-marker", + disabledMarker, + "--server-name", + "disabled", + ], + }, + }, + }) + ); + + return { pluginDirectory, controlMarker, disabledMarker }; + } + + function markerCount(markerPath: string): number { + if (!existsSync(markerPath)) { + return 0; + } + return readFileSync(markerPath, "utf8").trim().split("\n").filter(Boolean).length; + } + + async function waitForMarkerCount(markerPath: string, expectedCount: number): Promise { + await waitForCondition(() => markerCount(markerPath) >= expectedCount, { + timeoutMs: 60_000, + intervalMs: 100, + timeoutMessage: `Timed out waiting for ${markerPath} to be written ${expectedCount} time(s).`, + }); + } + + async function waitForMcpStatus( + session: CopilotSession, + serverName: string, + expectedStatus: string + ): Promise { + let lastStatus = ""; + await waitForCondition( + async () => { + const result = await session.rpc.mcp.list(); + const server = result.servers.find((candidate) => candidate.name === serverName); + lastStatus = server?.status ?? ""; + return lastStatus === expectedStatus; + }, + { + timeoutMs: 60_000, + intervalMs: 100, + timeoutMessage: `${serverName} did not reach ${expectedStatus}; last status was ${lastStatus}.`, + } + ); + } + + function expectSyntheticResponse(response: Awaited>) { + expect(response?.data.content).toBe(SYNTHETIC_RESPONSE); + } + + async function drainPostCreateRpc(session: CopilotSession): Promise { + // Drain a non-MCP post-create RPC without initializing MCP before the first model turn. + await session.rpc.metadata.snapshot(); + } + + async function mcpRequestCount(): Promise { + const requests = await openAiEndpoint.getRequests(); + return requests.filter((request) => request.method === "POST" && request.url === "/mcp") + .length; + } + + async function waitForMcpRequestCount(expectedCount: number): Promise { + let lastCount = 0; + await waitForCondition( + async () => { + lastCount = await mcpRequestCount(); + return lastCount >= expectedCount; + }, + { + timeoutMs: 60_000, + intervalMs: 100, + timeoutMessage: `Timed out waiting for ${expectedCount} /mcp request(s); saw ${lastCount}.`, + } + ); + } + + it( + "keeps disabled plugin MCP servers per-session on create", + { timeout: 120_000 }, + async () => { + const { + pluginDirectory: disabledPluginDirectory, + controlMarker: disabledControlMarker, + disabledMarker, + } = createPluginDirectory("disabled-mcp-create"); + + await using disabledSession = await client.createSession({ + onPermissionRequest: approveAll, + pluginDirectories: [disabledPluginDirectory], + disabledMcpServers: ["disabled"], + }); + + await drainPostCreateRpc(disabledSession); + expect(existsSync(disabledControlMarker)).toBe(false); + expect(existsSync(disabledMarker)).toBe(false); + expectSyntheticResponse( + await disabledSession.sendAndWait({ prompt: MCP_TRIGGER_PROMPT }) + ); + await waitForMarkerCount(disabledControlMarker, 1); + expect(existsSync(disabledMarker)).toBe(false); + await waitForMcpStatus(disabledSession, "control", "connected"); + await waitForMcpStatus(disabledSession, "disabled", "disabled"); + + const { + pluginDirectory: enabledPluginDirectory, + controlMarker: enabledControlMarker, + disabledMarker: enabledDisabledMarker, + } = createPluginDirectory("enabled-mcp-create"); + await using enabledSession = await client.createSession({ + onPermissionRequest: approveAll, + pluginDirectories: [enabledPluginDirectory], + }); + await drainPostCreateRpc(enabledSession); + expect(existsSync(enabledControlMarker)).toBe(false); + expect(existsSync(enabledDisabledMarker)).toBe(false); + expectSyntheticResponse( + await enabledSession.sendAndWait({ prompt: MCP_TRIGGER_PROMPT }) + ); + await waitForMarkerCount(enabledControlMarker, 1); + await waitForMarkerCount(enabledDisabledMarker, 1); + await waitForMcpStatus(enabledSession, "control", "connected"); + await waitForMcpStatus(enabledSession, "disabled", "connected"); + } + ); + + it( + "keeps the built-in GitHub MCP server disabled on the first message", + { timeout: 120_000 }, + async () => { + const disabledSession = await client.createSession({ + onPermissionRequest: approveAll, + enableConfigDiscovery: true, + enableMcpApps: true, + githubMcpToolConfig: { enableAllTools: true }, + disabledMcpServers: ["github-mcp-server"], + }); + + let disabledRequestsBeforeFirstMessage: number; + try { + await drainPostCreateRpc(disabledSession); + disabledRequestsBeforeFirstMessage = await mcpRequestCount(); + expect(disabledRequestsBeforeFirstMessage).toBe(0); + expectSyntheticResponse( + await disabledSession.sendAndWait({ prompt: MCP_TRIGGER_PROMPT }) + ); + expect(await mcpRequestCount()).toBe(disabledRequestsBeforeFirstMessage); + await waitForMcpStatus(disabledSession, "github-mcp-server", "disabled"); + expect(await mcpRequestCount()).toBe(disabledRequestsBeforeFirstMessage); + } finally { + await disabledSession.disconnect(); + } + + expect(await mcpRequestCount()).toBe(disabledRequestsBeforeFirstMessage); + + await using enabledSession = await client.createSession({ + onPermissionRequest: approveAll, + enableConfigDiscovery: true, + enableMcpApps: true, + githubMcpToolConfig: { enableAllTools: true }, + }); + await drainPostCreateRpc(enabledSession); + const requestsBeforeFirstMessage = await mcpRequestCount(); + expect(requestsBeforeFirstMessage).toBe(0); + expectSyntheticResponse( + await enabledSession.sendAndWait({ prompt: MCP_TRIGGER_PROMPT }) + ); + await waitForMcpRequestCount(requestsBeforeFirstMessage + 1); + await waitForMcpStatus(enabledSession, "github-mcp-server", "connected"); + } + ); + + it.skipIf(isInProcessTransport)( + "applies disabled plugin MCP servers on cold stdio resume", + async () => { + const { pluginDirectory, controlMarker, disabledMarker } = + createPluginDirectory("disabled-mcp-resume"); + const initialClient = createClient({ + connection: RuntimeConnection.forStdio({ path: process.env.COPILOT_CLI_PATH }), + requestHandler: new PersistingRequestHandler(), + }); + const resumeClient = createClient({ + connection: RuntimeConnection.forStdio({ path: process.env.COPILOT_CLI_PATH }), + }); + + try { + const originalSession = await initialClient.createSession({ + onPermissionRequest: approveAll, + enableSessionStore: true, + }); + const sessionId = originalSession.sessionId; + // A session.log entry alone does not materialize a session that a + // restarted runtime can resume. This self-contained model turn + // persists it without initializing MCP because no plugin directory + // is supplied until the resume request below. + const response = await originalSession.sendAndWait({ + prompt: "Return the configured persistence marker.", + }); + expectSyntheticResponse(response); + + expect(existsSync(controlMarker)).toBe(false); + expect(existsSync(disabledMarker)).toBe(false); + await initialClient.stop(); + + await using resumedSession = await resumeClient.resumeSession(sessionId, { + onPermissionRequest: approveAll, + enableSessionStore: true, + pluginDirectories: [pluginDirectory], + disabledMcpServers: ["disabled"], + }); + await waitForMcpStatus(resumedSession, "control", "connected"); + await waitForMcpStatus(resumedSession, "disabled", "disabled"); + await waitForMarkerCount(controlMarker, 1); + expect(existsSync(disabledMarker)).toBe(false); + } finally { + await initialClient.stop().catch(() => {}); + await resumeClient.stop().catch(() => {}); + } + } + ); +}); diff --git a/nodejs/test/e2e/hooks_extended.e2e.test.ts b/nodejs/test/e2e/hooks_extended.e2e.test.ts index 5b997adb2..3ac858650 100644 --- a/nodejs/test/e2e/hooks_extended.e2e.test.ts +++ b/nodejs/test/e2e/hooks_extended.e2e.test.ts @@ -14,6 +14,7 @@ import type { SessionEndHookInput, SessionStartHookInput, UserPromptSubmittedHookInput, + UserPromptTransformedHookInput, } from "../../src/types.js"; import { createSdkTestContext } from "./harness/sdkTestContext.js"; @@ -169,6 +170,36 @@ describe("Extended session hooks", async () => { await session.disconnect(); }); + it("should invoke userPromptTransformed hook and modify transformed prompt", async () => { + const inputs: UserPromptTransformedHookInput[] = []; + const session = await client.createSession({ + onPermissionRequest: approveAll, + hooks: { + onUserPromptTransformed: async (input, invocation) => { + inputs.push(input); + expect(invocation.sessionId).toBeTruthy(); + return { + modifiedTransformedPrompt: "Reply with exactly: HOOKED_TRANSFORMED_PROMPT", + }; + }, + }, + }); + + const response = await session.sendAndWait({ + prompt: "Answer the request above.", + }); + + expect(inputs.length).toBeGreaterThan(0); + expect(inputs[0].prompt).toContain("Answer the request above."); + expect(inputs[0].transformedPrompt).toContain("Answer the request above."); + expect(inputs[0].transformedPrompt).toContain(""); + expect(inputs[0].timestamp).toBeInstanceOf(Date); + expect(inputs[0].workingDirectory).toBeDefined(); + expect(response?.data.content ?? "").toContain("HOOKED_TRANSFORMED_PROMPT"); + + await session.disconnect(); + }); + it("should invoke sessionStart hook", async () => { const inputs: SessionStartHookInput[] = []; const invocationSessionIds: string[] = []; diff --git a/nodejs/test/session-send-and-wait.test.ts b/nodejs/test/session-send-and-wait.test.ts new file mode 100644 index 000000000..8b6e390c4 --- /dev/null +++ b/nodejs/test/session-send-and-wait.test.ts @@ -0,0 +1,137 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import { describe, expect, it, onTestFinished } from "vitest"; +import type { MessageConnection } from "vscode-jsonrpc/node.js"; +import { CopilotSession } from "../src/session.js"; +import type { SessionEvent } from "../src/generated/session-events.js"; + +function sessionEvent(type: "session.idle", data: Record = {}): SessionEvent { + return { + type, + id: "00000000-0000-4000-8000-000000000001", + parentId: null, + timestamp: new Date().toISOString(), + ephemeral: true, + data, + } as SessionEvent; +} + +/** Builds a `session.error` event, the shape `session.log(…, { level: "error" })` produces. */ +function errorEvent(message: string): SessionEvent { + return { + type: "session.error", + id: "00000000-0000-4000-8000-000000000001", + parentId: null, + timestamp: new Date().toISOString(), + data: { errorType: "notification", message }, + } as SessionEvent; +} + +function controlledSession(): { + session: CopilotSession; + sendStarted: Promise; + resolveSend: () => void; + rejectSend: (error: Error) => void; +} { + let resolveSendRequest: ((value: unknown) => void) | undefined; + let rejectSendRequest: ((error: Error) => void) | undefined; + let markSendStarted: () => void; + const sendStarted = new Promise((resolve) => { + markSendStarted = resolve; + }); + const connection = { + sendRequest: () => + new Promise((resolve, reject) => { + resolveSendRequest = resolve; + rejectSendRequest = reject; + markSendStarted(); + }), + } as unknown as MessageConnection; + + return { + session: new CopilotSession("session-1", connection), + sendStarted, + resolveSend: () => resolveSendRequest?.({ messageId: "msg-1" }), + rejectSend: (error) => rejectSendRequest?.(error), + }; +} + +describe("sendAndWait", () => { + it("does not emit an unhandled rejection when session.error arrives before the idle race is armed", async () => { + const { session, sendStarted, resolveSend } = controlledSession(); + + const unhandled: unknown[] = []; + const onUnhandled = (reason: unknown): void => { + unhandled.push(reason); + }; + process.on("unhandledRejection", onUnhandled); + onTestFinished(() => { + process.off("unhandledRejection", onUnhandled); + }); + + const pending = session.sendAndWait({ prompt: "hi" }); + await sendStarted; + + // A session.error lands while send()'s RPC is still in flight. This is + // ordinary traffic: a joined client calling session.log(…, { level: "error" }) + // or an MCP server failing to start both produce one. + session._dispatchEvent(errorEvent("MCP server failed to start")); + + // Yield past a macrotask boundary so Node has run the checkpoint at which + // it classifies a rejection as unhandled. + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(unhandled).toEqual([]); + + resolveSend(); + await expect(pending).rejects.toThrow("MCP server failed to start"); + }); + + it("preserves an early idle event until send completes", async () => { + const { session, sendStarted, resolveSend } = controlledSession(); + const pending = session.sendAndWait({ prompt: "hi" }); + await sendStarted; + + session._dispatchEvent(sessionEvent("session.idle")); + + const stateBeforeSend = await Promise.race([ + pending.then(() => "settled"), + new Promise<"pending">((resolve) => setTimeout(() => resolve("pending"), 0)), + ]); + expect(stateBeforeSend).toBe("pending"); + + resolveSend(); + await expect(pending).resolves.toBeUndefined(); + }); + + it("preserves the send rejection when a session error arrives first", async () => { + const { session, sendStarted, rejectSend } = controlledSession(); + const pending = session.sendAndWait({ prompt: "hi" }); + await sendStarted; + + session._dispatchEvent(errorEvent("session error")); + rejectSend(new Error("send failed")); + + await expect(pending).rejects.toThrow("send failed"); + }); + + it("uses the first session outcome observed while send is in flight", async () => { + const idleFirst = controlledSession(); + const idleFirstPending = idleFirst.session.sendAndWait({ prompt: "hi" }); + await idleFirst.sendStarted; + idleFirst.session._dispatchEvent(sessionEvent("session.idle")); + idleFirst.session._dispatchEvent(errorEvent("later error")); + idleFirst.resolveSend(); + await expect(idleFirstPending).resolves.toBeUndefined(); + + const errorFirst = controlledSession(); + const errorFirstPending = errorFirst.session.sendAndWait({ prompt: "hi" }); + await errorFirst.sendStarted; + errorFirst.session._dispatchEvent(errorEvent("first error")); + errorFirst.session._dispatchEvent(sessionEvent("session.idle")); + errorFirst.resolveSend(); + await expect(errorFirstPending).rejects.toThrow("first error"); + }); +}); diff --git a/nodejs/vitest.config.ts b/nodejs/vitest.config.ts index 03f6c779e..bb07cb017 100644 --- a/nodejs/vitest.config.ts +++ b/nodejs/vitest.config.ts @@ -1,11 +1,13 @@ import { defineConfig } from "vitest/config"; +const integrationTestTimeout = process.platform === "win32" ? 60000 : 30000; + export default defineConfig({ test: { globals: true, environment: "node", - testTimeout: 30000, // 30 seconds for integration tests - hookTimeout: 30000, + testTimeout: integrationTestTimeout, + hookTimeout: integrationTestTimeout, teardownTimeout: 10000, isolate: true, // Run each test file in isolation pool: "forks", // Use process forking for better isolation diff --git a/python/README.md b/python/README.md index 0206ad49b..10630fb84 100644 --- a/python/README.md +++ b/python/README.md @@ -272,13 +272,14 @@ finally: These are passed as keyword arguments to `create_session()`: - `model` (str): Model to use ("gpt-5", "claude-sonnet-4.5", etc.). **Required when using custom provider.** -- `reasoning_effort` (str): Reasoning effort level for models that support it ("low", "medium", "high", "xhigh"). Use `list_models()` to check which models support this option. +- `reasoning_effort` (str): Reasoning effort level for models that support it ("low", "medium", "high", "xhigh", "max"). Use `list_models()` to check which models support this option. - `session_id` (str): Custom session ID - `tools` (list): Custom tools exposed to the CLI. Tools with `handler=None` are declaration-only and must be resolved via pending tool-call RPCs. - `system_message` (SystemMessageConfig): System message configuration - `streaming` (bool): Enable streaming delta events - `provider` (ProviderConfig): Custom API provider configuration (BYOK). See [Custom Providers](#custom-providers) section. - `infinite_sessions` (InfiniteSessionConfig): Automatic context compaction configuration +- `working_directory` (str | None): Working directory for the session (default: runtime process working directory). - `enable_session_store` (bool): Enables the cross-session store for search and retrieval across sessions. When unset in `"copilot-cli"` mode, the runtime default applies (enabled). In `"empty"` mode, defaults to disabled. - `on_permission_request` (callable): Optional handler called before each tool execution to approve or deny it. When omitted, permission requests are emitted as events and left pending for manual resolution. `PermissionHandler.approve_all` approves requests when managed settings are disabled and raises an error when `enable_managed_settings` is true. Custom handlers can inspect `managed_approval_required` for human-facing confirmation logic. See [Permission Handling](#permission-handling) section. - `on_user_input_request` (callable): Handler for user input requests from the agent (enables ask_user tool). See [User Input Requests](#user-input-requests) section. @@ -1141,3 +1142,23 @@ When `on_elicitation_request` is provided, the SDK automatically: - Reports the `elicitation` capability on the session - Dispatches `elicitation.requested` events to your handler - Auto-cancels if your handler throws an error (so the server doesn't hang) + +## Development + +Install [uv](https://docs.astral.sh/uv/) and a supported [Node.js version](../nodejs/README.md#prerequisites), then from the repository root: + +```bash +cd nodejs +npm ci +``` + +```bash +cd test/harness +npm ci +``` + +```bash +cd python +uv sync +uv run pytest +``` diff --git a/python/copilot/__init__.py b/python/copilot/__init__.py index 8b0100df2..678fffbf1 100644 --- a/python/copilot/__init__.py +++ b/python/copilot/__init__.py @@ -173,6 +173,9 @@ UserPromptSubmittedHandler, UserPromptSubmittedHookInput, UserPromptSubmittedHookOutput, + UserPromptTransformedHandler, + UserPromptTransformedHookInput, + UserPromptTransformedHookOutput, ) from .session_fs_provider import ( SessionFsFileInfo, @@ -362,6 +365,9 @@ "UserPromptSubmittedHandler", "UserPromptSubmittedHookInput", "UserPromptSubmittedHookOutput", + "UserPromptTransformedHandler", + "UserPromptTransformedHookInput", + "UserPromptTransformedHookOutput", "convert_mcp_call_tool_result", "create_session_fs_adapter", "define_tool", diff --git a/python/copilot/client.py b/python/copilot/client.py index 737619ef3..61f08e641 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -2065,6 +2065,7 @@ async def create_session( plugin_directories: list[str] | None = None, instruction_directories: list[str] | None = None, disabled_skills: list[str] | None = None, + disabled_mcp_servers: list[str] | None = None, infinite_sessions: InfiniteSessionConfig | None = None, large_output: LargeToolOutputConfig | None = None, memory: MemoryConfiguration | None = None, @@ -2193,6 +2194,10 @@ async def create_session( instruction_directories: Additional directories to search for custom instruction files. disabled_skills: Skills to disable. + disabled_mcp_servers: Exact MCP server names to disable only for this + session. Disabled servers are not started or authenticated on + create or cold resume; a resident resume cannot stop servers + already running. This does not change global MCP settings. infinite_sessions: Infinite session configuration. memory: Session memory configuration. cloud: Creates a remote session in the cloud instead of a local @@ -2496,6 +2501,8 @@ async def create_session( # Add disabled skills configuration if provided if disabled_skills: payload["disabledSkills"] = disabled_skills + if disabled_mcp_servers is not None: + payload["disabledMcpServers"] = disabled_mcp_servers # Add infinite sessions configuration if provided if infinite_sessions: @@ -2764,6 +2771,7 @@ async def resume_session( plugin_directories: list[str] | None = None, instruction_directories: list[str] | None = None, disabled_skills: list[str] | None = None, + disabled_mcp_servers: list[str] | None = None, infinite_sessions: InfiniteSessionConfig | None = None, large_output: LargeToolOutputConfig | None = None, memory: MemoryConfiguration | None = None, @@ -2893,6 +2901,10 @@ async def resume_session( instruction_directories: Additional directories to search for custom instruction files. disabled_skills: Skills to disable. + disabled_mcp_servers: Exact MCP server names to disable only for this + session. Disabled servers are not started or authenticated on + create or cold resume; a resident resume cannot stop servers + already running. This does not change global MCP settings. infinite_sessions: Infinite session configuration. memory: Session memory configuration. on_event: Callback for session events. @@ -3165,6 +3177,8 @@ async def resume_session( payload["instructionDirectories"] = instruction_directories if disabled_skills: payload["disabledSkills"] = disabled_skills + if disabled_mcp_servers is not None: + payload["disabledMcpServers"] = disabled_mcp_servers if infinite_sessions: wire_config: dict[str, Any] = {} diff --git a/python/copilot/session.py b/python/copilot/session.py index 4474ce562..92c24bdd8 100644 --- a/python/copilot/session.py +++ b/python/copilot/session.py @@ -169,7 +169,7 @@ def _capabilities_to_dict(caps: ModelCapabilitiesOverride) -> dict: return result -ReasoningEffort = Literal["low", "medium", "high", "xhigh"] +ReasoningEffort = Literal["low", "medium", "high", "xhigh", "max"] ReasoningSummary = Literal["none", "concise", "detailed"] ContextTier = Literal["default", "long_context"] SessionFsConventions = Literal["posix", "windows"] @@ -957,6 +957,28 @@ class UserPromptSubmittedHookOutput(TypedDict, total=False): ] +class UserPromptTransformedHookInput(TypedDict): + """Input for the user-prompt-transformed hook.""" + + sessionId: str + timestamp: datetime + workingDirectory: str + prompt: str + transformedPrompt: str + + +class UserPromptTransformedHookOutput(TypedDict, total=False): + """Output for the user-prompt-transformed hook.""" + + modifiedTransformedPrompt: str + + +UserPromptTransformedHandler = Callable[ + [UserPromptTransformedHookInput, dict[str, str]], + UserPromptTransformedHookOutput | None | Awaitable[UserPromptTransformedHookOutput | None], +] + + class SessionStartHookInput(TypedDict): """Input for session-start hook""" @@ -1063,6 +1085,7 @@ class SessionHooks(TypedDict, total=False): on_post_tool_use: PostToolUseHandler on_post_tool_use_failure: PostToolUseFailureHandler on_user_prompt_submitted: UserPromptSubmittedHandler + on_user_prompt_transformed: UserPromptTransformedHandler on_session_start: SessionStartHandler on_session_end: SessionEndHandler on_error_occurred: ErrorOccurredHandler @@ -2794,6 +2817,7 @@ async def _handle_hooks_invoke(self, hook_type: str, input_data: Any) -> Any: "postToolUse": hooks.get("on_post_tool_use"), "postToolUseFailure": hooks.get("on_post_tool_use_failure"), "userPromptSubmitted": hooks.get("on_user_prompt_submitted"), + "userPromptTransformed": hooks.get("on_user_prompt_transformed"), "sessionStart": hooks.get("on_session_start"), "sessionEnd": hooks.get("on_session_end"), "errorOccurred": hooks.get("on_error_occurred"), @@ -2976,7 +3000,7 @@ async def set_model( Args: model: Model ID to switch to (e.g., "gpt-5.4", "claude-sonnet-4"). reasoning_effort: Optional reasoning effort level for the new model - (e.g., "low", "medium", "high", "xhigh"). + (e.g., "low", "medium", "high", "xhigh", "max"). reasoning_summary: Optional reasoning summary mode for supported models. Use "none" to suppress summary output regardless of whether reasoning is enabled. diff --git a/python/e2e/test_hooks_extended_e2e.py b/python/e2e/test_hooks_extended_e2e.py index b38534ea2..7af20f32b 100644 --- a/python/e2e/test_hooks_extended_e2e.py +++ b/python/e2e/test_hooks_extended_e2e.py @@ -3,7 +3,8 @@ E2E coverage for every handler exposed on ``SessionHooks``: ``on_pre_tool_use``, ``on_post_tool_use``, ``on_post_tool_use_failure``, -``on_user_prompt_submitted``, ``on_session_start``, ``on_session_end``, +``on_user_prompt_submitted``, ``on_user_prompt_transformed``, ``on_session_start``, +``on_session_end``, ``on_error_occurred``, ``on_agent_stop``. Output-shape behavior (modifiedPrompt / additionalContext / errorHandling / modifiedArgs / modifiedResult / sessionSummary) is asserted alongside hook invocation. @@ -48,6 +49,32 @@ async def on_user_prompt_submitted(input_data, invocation): finally: await session.disconnect() + async def test_should_invoke_userprompttransformed_hook_and_modify_transformed_prompt( + self, ctx: E2ETestContext + ): + inputs: list[dict] = [] + + async def on_user_prompt_transformed(input_data, invocation): + assert invocation["session_id"] + inputs.append(input_data) + return {"modifiedTransformedPrompt": "Reply with exactly: HOOKED_TRANSFORMED_PROMPT"} + + session = await ctx.client.create_session( + on_permission_request=PermissionHandler.approve_all, + hooks={"on_user_prompt_transformed": on_user_prompt_transformed}, + ) + try: + response = await session.send_and_wait("Answer the request above.") + assert inputs + assert "Answer the request above." in inputs[0]["prompt"] + assert "Answer the request above." in inputs[0]["transformedPrompt"] + assert "" in inputs[0]["transformedPrompt"] + assert inputs[0]["timestamp"].timestamp() > 0 + assert inputs[0]["workingDirectory"] + assert "HOOKED_TRANSFORMED_PROMPT" in (response.data.content or "") + finally: + await session.disconnect() + async def test_should_invoke_sessionstart_hook(self, ctx: E2ETestContext): inputs: list[dict] = [] invocation_session_ids: list[str] = [] diff --git a/python/pyproject.toml b/python/pyproject.toml index 7e6274d9c..e96c587a6 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -46,6 +46,7 @@ dev = [ "pytest>=7.0.0", "pytest-asyncio>=0.21.0", "pytest-timeout>=2.0.0", + "pytest-xdist>=3.6.0", "websockets>=12.0", "opentelemetry-sdk>=1.0.0", ] diff --git a/python/test_client.py b/python/test_client.py index 0bba1ccd7..9e116f449 100644 --- a/python/test_client.py +++ b/python/test_client.py @@ -937,6 +937,7 @@ async def mock_request(method, params, **kwargs): client._client.request = mock_request plugin_dirs = ["/tmp/plugins/a", "/tmp/plugins/b"] + disabled_mcp_servers = ["local-files", "remote-github"] large_output = { "enabled": True, "max_size_bytes": 1024, @@ -951,19 +952,45 @@ async def mock_request(method, params, **kwargs): session = await client.create_session( on_permission_request=PermissionHandler.approve_all, plugin_directories=plugin_dirs, + disabled_mcp_servers=disabled_mcp_servers, large_output=large_output, ) await client.resume_session( session.session_id, on_permission_request=PermissionHandler.approve_all, plugin_directories=plugin_dirs, + disabled_mcp_servers=disabled_mcp_servers, large_output=large_output, ) assert captured["session.create"]["pluginDirectories"] == plugin_dirs + assert captured["session.create"]["disabledMcpServers"] == disabled_mcp_servers assert captured["session.create"]["largeOutput"] == expected_large_output_wire assert captured["session.resume"]["pluginDirectories"] == plugin_dirs + assert captured["session.resume"]["disabledMcpServers"] == disabled_mcp_servers assert captured["session.resume"]["largeOutput"] == expected_large_output_wire + + empty_session = await client.create_session( + on_permission_request=PermissionHandler.approve_all, + disabled_mcp_servers=[], + ) + await client.resume_session( + empty_session.session_id, + on_permission_request=PermissionHandler.approve_all, + disabled_mcp_servers=[], + ) + assert captured["session.create"]["disabledMcpServers"] == [] + assert captured["session.resume"]["disabledMcpServers"] == [] + + omitted_session = await client.create_session( + on_permission_request=PermissionHandler.approve_all, + ) + await client.resume_session( + omitted_session.session_id, + on_permission_request=PermissionHandler.approve_all, + ) + assert "disabledMcpServers" not in captured["session.create"] + assert "disabledMcpServers" not in captured["session.resume"] finally: await client.force_stop() diff --git a/rust/README.md b/rust/README.md index eccc29aa2..314090044 100644 --- a/rust/README.md +++ b/rust/README.md @@ -108,6 +108,8 @@ With the default `CliProgram::Resolve`, `Client::start()` resolves the CLI in th Created via `Client::create_session` or `Client::resume_session`. Owns an internal event loop that dispatches CLI callbacks to the focused handler traits you install on `SessionConfig`, and broadcasts session events through `subscribe()`. +`SessionConfig::working_directory` sets the session working directory. When unset, the runtime uses its process working directory. + ```rust,ignore use github_copilot_sdk::MessageOptions; @@ -318,7 +320,7 @@ let session = client .await?; ``` -**Hook events:** `PreToolUse`, `PostToolUse`, `PostToolUseFailure`, `UserPromptSubmitted`, `SessionStart`, `SessionEnd`, `ErrorOccurred`. Each carries typed input/output structs. `PostToolUse` only fires on success; override `on_post_tool_use_failure` to observe failed tool calls. Return `HookOutput::None` for events you don't handle. +**Hook events:** `PreToolUse`, `PostToolUse`, `PostToolUseFailure`, `UserPromptSubmitted`, `UserPromptTransformed`, `SessionStart`, `SessionEnd`, `ErrorOccurred`. Each carries typed input/output structs. `PostToolUse` only fires on success; override `on_post_tool_use_failure` to observe failed tool calls. Return `HookOutput::None` for events you don't handle. ### System Message Transforms @@ -963,3 +965,22 @@ github-copilot-sdk = { version = "0.1", default-features = false } # Derive JSON Schema for tool parameters (adds to default bundled-cli). github-copilot-sdk = { version = "0.1", features = ["derive"] } ``` + +## Development + +Tests require a supported [Node.js version](../nodejs/README.md#prerequisites). From the repository root: + +```bash +cd nodejs +npm ci +``` + +```bash +cd test/harness +npm ci +``` + +```bash +cd rust +cargo test --features test-support +``` diff --git a/rust/src/hooks.rs b/rust/src/hooks.rs index a2b61ed8b..4986d6cb1 100644 --- a/rust/src/hooks.rs +++ b/rust/src/hooks.rs @@ -199,6 +199,32 @@ pub struct UserPromptSubmittedOutput { pub suppress_output: Option, } +/// Input for the `userPromptTransformed` hook. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UserPromptTransformedInput { + /// The runtime session ID of the session that triggered the hook. + pub session_id: String, + /// Unix timestamp in ms. + pub timestamp: f64, + /// Working directory. + #[serde(rename = "cwd")] + pub working_directory: PathBuf, + /// The prompt after any `userPromptSubmitted` hooks have run. + pub prompt: String, + /// The model-facing prompt after runtime transformations. + pub transformed_prompt: String, +} + +/// Output for the `userPromptTransformed` hook. +#[derive(Debug, Clone, Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct UserPromptTransformedOutput { + /// Replacement model-facing prompt to persist and send to the model. + #[serde(skip_serializing_if = "Option::is_none")] + pub modified_transformed_prompt: Option, +} + /// Input for the `sessionStart` hook. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] @@ -381,6 +407,13 @@ pub enum HookEvent { /// Session context. ctx: HookContext, }, + /// Fired after the runtime transforms a submitted prompt. + UserPromptTransformed { + /// Typed input data. + input: UserPromptTransformedInput, + /// Session context. + ctx: HookContext, + }, /// Fired at session creation or resume. SessionStart { /// Typed input data. @@ -430,6 +463,8 @@ pub enum HookOutput { PostToolUseFailure(PostToolUseFailureOutput), /// Response for a user-prompt-submitted hook. UserPromptSubmitted(UserPromptSubmittedOutput), + /// Response for a user-prompt-transformed hook. + UserPromptTransformed(UserPromptTransformedOutput), /// Response for a session-start hook. SessionStart(SessionStartOutput), /// Response for a session-end hook. @@ -449,6 +484,7 @@ impl HookOutput { Self::PostToolUse(_) => "PostToolUse", Self::PostToolUseFailure(_) => "PostToolUseFailure", Self::UserPromptSubmitted(_) => "UserPromptSubmitted", + Self::UserPromptTransformed(_) => "UserPromptTransformed", Self::SessionStart(_) => "SessionStart", Self::SessionEnd(_) => "SessionEnd", Self::ErrorOccurred(_) => "ErrorOccurred", @@ -506,6 +542,11 @@ pub trait SessionHooks: Send + Sync + 'static { .await .map(HookOutput::UserPromptSubmitted) .unwrap_or(HookOutput::None), + HookEvent::UserPromptTransformed { input, ctx } => self + .on_user_prompt_transformed(input, ctx) + .await + .map(HookOutput::UserPromptTransformed) + .unwrap_or(HookOutput::None), HookEvent::SessionStart { input, ctx } => self .on_session_start(input, ctx) .await @@ -583,6 +624,16 @@ pub trait SessionHooks: Send + Sync + 'static { None } + /// Called after the runtime transforms a submitted prompt. Return + /// `Some(output)` to replace the model-facing content before it is stored. + async fn on_user_prompt_transformed( + &self, + _input: UserPromptTransformedInput, + _ctx: HookContext, + ) -> Option { + None + } + /// Called at session creation or resume. Return `Some(output)` to /// inject startup context. async fn on_session_start( @@ -660,6 +711,10 @@ pub(crate) async fn dispatch_hook( let input: UserPromptSubmittedInput = serde_json::from_value(raw_input)?; HookEvent::UserPromptSubmitted { input, ctx } } + "userPromptTransformed" => { + let input: UserPromptTransformedInput = serde_json::from_value(raw_input)?; + HookEvent::UserPromptTransformed { input, ctx } + } "sessionStart" => { let input: SessionStartInput = serde_json::from_value(raw_input)?; HookEvent::SessionStart { input, ctx } @@ -708,6 +763,9 @@ pub(crate) async fn dispatch_hook( ("userPromptSubmitted", HookOutput::UserPromptSubmitted(o)) => { Some(serde_json::to_value(o)?) } + ("userPromptTransformed", HookOutput::UserPromptTransformed(o)) => { + Some(serde_json::to_value(o)?) + } ("sessionStart", HookOutput::SessionStart(o)) => Some(serde_json::to_value(o)?), ("sessionEnd", HookOutput::SessionEnd(o)) => Some(serde_json::to_value(o)?), ("errorOccurred", HookOutput::ErrorOccurred(o)) => Some(serde_json::to_value(o)?), @@ -753,6 +811,14 @@ mod tests { ..Default::default() }) } + HookEvent::UserPromptTransformed { input, .. } => { + HookOutput::UserPromptTransformed(UserPromptTransformedOutput { + modified_transformed_prompt: Some(format!( + "[transformed] {}", + input.transformed_prompt + )), + }) + } _ => HookOutput::None, } } @@ -813,6 +879,30 @@ mod tests { assert_eq!(result["output"]["modifiedPrompt"], "[prefixed] hello world"); } + #[tokio::test] + async fn dispatch_user_prompt_transformed() { + let hooks = TestHooks; + let input = serde_json::json!({ + "sessionId": "sess-1", + "timestamp": 1234567890, + "cwd": "/tmp", + "prompt": "hello world", + "transformedPrompt": "now\nhello world" + }); + let result = dispatch_hook( + &hooks, + &SessionId::new("sess-1"), + "userPromptTransformed", + input, + ) + .await + .unwrap(); + assert_eq!( + result["output"]["modifiedTransformedPrompt"], + "[transformed] now\nhello world" + ); + } + #[tokio::test] async fn dispatch_unregistered_hook_returns_empty() { let hooks = TestHooks; diff --git a/rust/src/lib.rs b/rust/src/lib.rs index f998d7225..cafa3c596 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -2196,6 +2196,60 @@ impl Client { Ok(()) } + /// Start this client's notification and request router on the current runtime. + /// This is test-harness plumbing, not part of the supported SDK API. + #[cfg(feature = "test-support")] + #[doc(hidden)] + pub fn start_router_for_test(&self) { + self.inner.router.ensure_started( + &self.inner.notification_tx, + &self.inner.request_rx, + self.inner.llm_inference.get().cloned(), + self.inner.on_github_telemetry.clone(), + ); + } + + #[cfg(feature = "test-support")] + #[doc(hidden)] + /// Disconnect and delete every session owned by this test client's isolated + /// runtime. This is test-harness plumbing, not part of the supported SDK API. + pub async fn cleanup_sessions_for_test(&self) -> Result<()> { + let mut first_error = None; + + for session_id in self.inner.router.session_ids() { + if let Err(error) = self + .call( + "session.destroy", + Some(serde_json::json!({ "sessionId": session_id })), + ) + .await + && first_error.is_none() + { + first_error = Some(error); + } + self.inner.router.unregister(&session_id); + } + + match self.list_sessions(None).await { + Ok(sessions) => { + for session in sessions { + if let Err(error) = self.delete_session(&session.session_id).await + && first_error.is_none() + { + first_error = Some(error); + } + } + } + Err(error) if first_error.is_none() => first_error = Some(error), + Err(_) => {} + } + + match first_error { + Some(error) => Err(error), + None => Ok(()), + } + } + /// Return the ID of the most recently updated session, if any. /// /// Useful for resuming the last conversation when the session ID was diff --git a/rust/src/types.rs b/rust/src/types.rs index d2b8dcb93..895529760 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -1928,6 +1928,10 @@ pub struct SessionConfig { /// Skill names to disable. Skills in this set will not be available /// even if found in skill directories. pub disabled_skills: Option>, + /// Exact MCP server names to disable for this session. Disabled servers are + /// not started or authenticated on create or cold resume; a resident resume + /// cannot stop servers that are already running. + pub disabled_mcp_servers: Option>, /// Enable session hooks. When `true`, the CLI sends `hooks.invoke` /// RPC requests at key lifecycle points (pre/post tool use, prompt /// submission, session start/end, errors). @@ -2148,6 +2152,7 @@ impl std::fmt::Debug for SessionConfig { .field("large_output", &self.large_output) .field("tool_search", &self.tool_search) .field("disabled_skills", &self.disabled_skills) + .field("disabled_mcp_servers", &self.disabled_mcp_servers) .field("hooks", &self.hooks) .field("custom_agents", &self.custom_agents) .field("default_agent", &self.default_agent) @@ -2263,6 +2268,7 @@ impl Default for SessionConfig { large_output: None, tool_search: None, disabled_skills: None, + disabled_mcp_servers: None, hooks: None, custom_agents: None, default_agent: None, @@ -2425,6 +2431,7 @@ impl SessionConfig { large_output: self.large_output, tool_search: self.tool_search, disabled_skills: self.disabled_skills, + disabled_mcp_servers: self.disabled_mcp_servers, custom_agents: self.custom_agents, custom_agents_local_only: self.custom_agents_local_only, default_agent: self.default_agent, @@ -2862,6 +2869,16 @@ impl SessionConfig { self } + /// Set exact MCP server names to disable for this session. + pub fn with_disabled_mcp_servers(mut self, names: I) -> Self + where + I: IntoIterator, + S: Into, + { + self.disabled_mcp_servers = Some(names.into_iter().map(Into::into).collect()); + self + } + /// Set the custom agents (sub-agents) configured for this session. pub fn with_custom_agents>( mut self, @@ -3176,6 +3193,9 @@ pub struct ResumeSessionConfig { pub tool_search: Option, /// Skill names to disable on resume. pub disabled_skills: Option>, + /// Exact MCP server names to disable on resume. This prevents startup and + /// authentication during a cold resume, but cannot stop resident servers. + pub disabled_mcp_servers: Option>, /// Enable session hooks on resume. pub hooks: Option, /// Custom agents to re-supply on resume. @@ -3363,6 +3383,7 @@ impl std::fmt::Debug for ResumeSessionConfig { .field("large_output", &self.large_output) .field("tool_search", &self.tool_search) .field("disabled_skills", &self.disabled_skills) + .field("disabled_mcp_servers", &self.disabled_mcp_servers) .field("hooks", &self.hooks) .field("custom_agents", &self.custom_agents) .field("default_agent", &self.default_agent) @@ -3522,6 +3543,7 @@ impl ResumeSessionConfig { large_output: self.large_output, tool_search: self.tool_search, disabled_skills: self.disabled_skills, + disabled_mcp_servers: self.disabled_mcp_servers, custom_agents: self.custom_agents, custom_agents_local_only: self.custom_agents_local_only, default_agent: self.default_agent, @@ -3616,6 +3638,7 @@ impl ResumeSessionConfig { large_output: None, tool_search: None, disabled_skills: None, + disabled_mcp_servers: None, hooks: None, custom_agents: None, default_agent: None, @@ -4032,6 +4055,16 @@ impl ResumeSessionConfig { self } + /// Set exact MCP server names to disable for this session. + pub fn with_disabled_mcp_servers(mut self, names: I) -> Self + where + I: IntoIterator, + S: Into, + { + self.disabled_mcp_servers = Some(names.into_iter().map(Into::into).collect()); + self + } + /// Re-supply custom agents on resume. pub fn with_custom_agents>( mut self, @@ -4391,7 +4424,7 @@ impl LogOptions { #[derive(Debug, Clone, Default)] pub struct SetModelOptions { /// Reasoning effort for the new model (e.g. `"low"`, `"medium"`, - /// `"high"`, `"xhigh"`). + /// `"high"`, `"xhigh"`, `"max"`). pub reasoning_effort: Option, /// Reasoning summary mode for the new model. Use /// [`ReasoningSummary::None`] to suppress summary output regardless of @@ -6342,6 +6375,10 @@ mod tests { let cfg = SessionConfig { plugin_directories: Some(vec![PathBuf::from("/tmp/plugins")]), + disabled_mcp_servers: Some(vec![ + "local-files".to_string(), + "remote-github".to_string(), + ]), large_output: Some( LargeToolOutputConfig::new() .with_enabled(true) @@ -6356,6 +6393,10 @@ mod tests { .expect("no duplicate handlers"); let wire_json = serde_json::to_value(&wire).unwrap(); assert_eq!(wire_json["pluginDirectories"][0], "/tmp/plugins"); + assert_eq!( + wire_json["disabledMcpServers"], + serde_json::json!(["local-files", "remote-github"]) + ); assert_eq!(wire_json["largeOutput"]["enabled"], true); assert_eq!(wire_json["largeOutput"]["maxSizeBytes"], 1024); assert_eq!(wire_json["largeOutput"]["outputDir"], "/tmp/large-output"); @@ -6365,6 +6406,7 @@ mod tests { .expect("default has no duplicate handlers"); let empty_json = serde_json::to_value(&empty_wire).unwrap(); assert!(empty_json.get("pluginDirectories").is_none()); + assert!(empty_json.get("disabledMcpServers").is_none()); assert!(empty_json.get("largeOutput").is_none()); } @@ -6414,6 +6456,7 @@ mod tests { let mut cfg = ResumeSessionConfig::new(SessionId::from("sess-1")); cfg.plugin_directories = Some(vec![PathBuf::from("/tmp/plugins-r")]); + cfg.disabled_mcp_servers = Some(vec!["local-files-r".to_string()]); cfg.large_output = Some( LargeToolOutputConfig::new() .with_enabled(false) @@ -6424,6 +6467,10 @@ mod tests { let (wire, _) = cfg.into_wire().expect("no duplicate handlers"); let wire_json = serde_json::to_value(&wire).unwrap(); assert_eq!(wire_json["pluginDirectories"][0], "/tmp/plugins-r"); + assert_eq!( + wire_json["disabledMcpServers"], + serde_json::json!(["local-files-r"]) + ); assert_eq!(wire_json["largeOutput"]["enabled"], false); assert_eq!(wire_json["largeOutput"]["maxSizeBytes"], 2048); assert_eq!(wire_json["largeOutput"]["outputDir"], "/tmp/large-output-r"); @@ -6433,9 +6480,38 @@ mod tests { .expect("default resume has no duplicate handlers"); let empty_json = serde_json::to_value(&empty_wire).unwrap(); assert!(empty_json.get("pluginDirectories").is_none()); + assert!(empty_json.get("disabledMcpServers").is_none()); assert!(empty_json.get("largeOutput").is_none()); } + #[test] + fn session_config_clones_disabled_mcp_servers() { + let create = SessionConfig::default().with_disabled_mcp_servers(["local-files"]); + let mut create_clone = create.clone(); + create_clone + .disabled_mcp_servers + .as_mut() + .expect("configured disabled MCP servers") + .push("remote-github".to_string()); + assert_eq!( + create.disabled_mcp_servers.as_deref(), + Some(&["local-files".to_string()][..]) + ); + + let resume = ResumeSessionConfig::new(SessionId::from("sess-1")) + .with_disabled_mcp_servers(["local-files"]); + let mut resume_clone = resume.clone(); + resume_clone + .disabled_mcp_servers + .as_mut() + .expect("configured disabled MCP servers") + .push("remote-github".to_string()); + assert_eq!( + resume.disabled_mcp_servers.as_deref(), + Some(&["local-files".to_string()][..]) + ); + } + #[test] fn session_config_builder_composes() { use indexmap::IndexMap; @@ -6457,6 +6533,7 @@ mod tests { .with_enable_on_demand_instruction_discovery(true) .with_skill_directories([PathBuf::from("/tmp/skills")]) .with_disabled_skills(["broken-skill"]) + .with_disabled_mcp_servers(["local-files"]) .with_agent("researcher") .with_config_directory(PathBuf::from("/tmp/config")) .with_working_directory(PathBuf::from("/tmp/work")) @@ -6495,6 +6572,10 @@ mod tests { cfg.disabled_skills.as_deref(), Some(&["broken-skill".to_string()][..]) ); + assert_eq!( + cfg.disabled_mcp_servers.as_deref(), + Some(&["local-files".to_string()][..]) + ); assert_eq!(cfg.agent.as_deref(), Some("researcher")); assert_eq!(cfg.config_directory, Some(PathBuf::from("/tmp/config"))); assert_eq!(cfg.working_directory, Some(PathBuf::from("/tmp/work"))); @@ -6533,6 +6614,7 @@ mod tests { .with_enable_on_demand_instruction_discovery(false) .with_skill_directories([PathBuf::from("/tmp/skills")]) .with_disabled_skills(["broken-skill"]) + .with_disabled_mcp_servers(["local-files"]) .with_agent("researcher") .with_config_directory(PathBuf::from("/tmp/config")) .with_working_directory(PathBuf::from("/tmp/work")) @@ -6571,6 +6653,10 @@ mod tests { cfg.disabled_skills.as_deref(), Some(&["broken-skill".to_string()][..]) ); + assert_eq!( + cfg.disabled_mcp_servers.as_deref(), + Some(&["local-files".to_string()][..]) + ); assert_eq!(cfg.agent.as_deref(), Some("researcher")); assert_eq!(cfg.config_directory, Some(PathBuf::from("/tmp/config"))); assert_eq!(cfg.working_directory, Some(PathBuf::from("/tmp/work"))); diff --git a/rust/src/wire.rs b/rust/src/wire.rs index 261350e97..3e19063fc 100644 --- a/rust/src/wire.rs +++ b/rust/src/wire.rs @@ -130,6 +130,8 @@ pub(crate) struct SessionCreateWire { #[serde(skip_serializing_if = "Option::is_none")] pub disabled_skills: Option>, #[serde(skip_serializing_if = "Option::is_none")] + pub disabled_mcp_servers: Option>, + #[serde(skip_serializing_if = "Option::is_none")] pub custom_agents: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub custom_agents_local_only: Option, @@ -274,6 +276,8 @@ pub(crate) struct SessionResumeWire { #[serde(skip_serializing_if = "Option::is_none")] pub disabled_skills: Option>, #[serde(skip_serializing_if = "Option::is_none")] + pub disabled_mcp_servers: Option>, + #[serde(skip_serializing_if = "Option::is_none")] pub custom_agents: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub custom_agents_local_only: Option, diff --git a/rust/tests/e2e/abort.rs b/rust/tests/e2e/abort.rs index d4e79452b..34fc66b60 100644 --- a/rust/tests/e2e/abort.rs +++ b/rust/tests/e2e/abort.rs @@ -10,22 +10,24 @@ use tokio::sync::{Mutex, mpsc, oneshot}; use super::support::{ DEFAULT_TEST_TOKEN, assistant_message_content, recv_with_timeout, wait_for_event, - with_e2e_context, }; #[tokio::test] async fn should_abort_during_active_streaming() { - with_e2e_context("abort", "should_abort_during_active_streaming", |ctx| { - Box::pin(async move { - ctx.set_default_copilot_user(); - let client = ctx.start_client().await; - let session = client - .create_session(ctx.approve_all_session_config().with_streaming(true)) - .await - .expect("create session"); - let events = session.subscribe(); + super::support::with_dedicated_e2e_context( + "abort", + "should_abort_during_active_streaming", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config().with_streaming(true)) + .await + .expect("create session"); + let events = session.subscribe(); - session + session .send( "Write a very long essay about the history of computing, covering every decade \ from the 1940s to the 2020s in great detail.", @@ -33,54 +35,55 @@ async fn should_abort_during_active_streaming() { .await .expect("send long streaming turn"); - let delta = wait_for_event(events, "assistant.message_delta", |event| { - event.parsed_type() == SessionEventType::AssistantMessageDelta + let delta = wait_for_event(events, "assistant.message_delta", |event| { + event.parsed_type() == SessionEventType::AssistantMessageDelta + }) + .await; + assert!( + !delta + .typed_data::() + .expect("assistant.message_delta data") + .delta_content + .is_empty() + ); + + session.abort().await.expect("abort session"); + + // Session should be usable after abort. Wait for the specific recovery + // message rather than racing against a late idle from the aborted turn. + let recovery_events = session.subscribe(); + session + .send("Say 'abort_recovery_ok'.") + .await + .expect("send recovery"); + let recovery = wait_for_event( + recovery_events, + "assistant.message containing abort_recovery_ok", + |event| { + event.parsed_type() == SessionEventType::AssistantMessage + && assistant_message_content(event) + .to_lowercase() + .contains("abort_recovery_ok") + }, + ) + .await; + assert!( + assistant_message_content(&recovery) + .to_lowercase() + .contains("abort_recovery_ok") + ); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); }) - .await; - assert!( - !delta - .typed_data::() - .expect("assistant.message_delta data") - .delta_content - .is_empty() - ); - - session.abort().await.expect("abort session"); - - // Session should be usable after abort. Wait for the specific recovery - // message rather than racing against a late idle from the aborted turn. - let recovery_events = session.subscribe(); - session - .send("Say 'abort_recovery_ok'.") - .await - .expect("send recovery"); - let recovery = wait_for_event( - recovery_events, - "assistant.message containing abort_recovery_ok", - |event| { - event.parsed_type() == SessionEventType::AssistantMessage - && assistant_message_content(event) - .to_lowercase() - .contains("abort_recovery_ok") - }, - ) - .await; - assert!( - assistant_message_content(&recovery) - .to_lowercase() - .contains("abort_recovery_ok") - ); - - session.disconnect().await.expect("disconnect session"); - client.stop().await.expect("stop client"); - }) - }) + }, + ) .await; } #[tokio::test] async fn should_abort_during_active_tool_execution() { - with_e2e_context( + super::support::with_dedicated_e2e_context( "abort", "should_abort_during_active_tool_execution", |ctx| { diff --git a/rust/tests/e2e/ask_user.rs b/rust/tests/e2e/ask_user.rs index c134ad3c9..d7d089358 100644 --- a/rust/tests/e2e/ask_user.rs +++ b/rust/tests/e2e/ask_user.rs @@ -12,13 +12,11 @@ use github_copilot_sdk::{ use serde_json::json; use tokio::sync::{Notify, mpsc}; -use super::support::{ - DEFAULT_TEST_TOKEN, assistant_message_content, recv_with_timeout, with_e2e_context, -}; +use super::support::{DEFAULT_TEST_TOKEN, assistant_message_content, recv_with_timeout}; #[tokio::test] async fn should_invoke_user_input_handler_when_model_uses_ask_user_tool() { - with_e2e_context( + super::support::with_shared_e2e_context(&E2E, "ask_user", "should_invoke_user_input_handler_when_model_uses_ask_user_tool", |ctx| { @@ -62,7 +60,7 @@ async fn should_invoke_user_input_handler_when_model_uses_ask_user_tool() { #[tokio::test] async fn should_receive_choices_in_user_input_request() { - with_e2e_context( + super::support::with_shared_e2e_context(&E2E, "ask_user", "should_receive_choices_in_user_input_request", |ctx| { @@ -107,7 +105,7 @@ async fn should_receive_choices_in_user_input_request() { #[tokio::test] async fn should_handle_freeform_user_input_response() { - with_e2e_context( + super::support::with_shared_e2e_context(&E2E, "ask_user", "should_handle_freeform_user_input_response", |ctx| { @@ -164,7 +162,8 @@ async fn should_handle_freeform_user_input_response() { /// the handler observes the sibling tool while its own request is still pending. #[tokio::test] async fn ask_user_does_not_block_sibling_tool_call_in_same_turn() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "ask_user", "ask_user_does_not_block_sibling_tool_call_in_same_turn", |ctx| { @@ -346,3 +345,5 @@ impl ToolHandler for SetMarkerTool { Ok(ToolResult::Text(format!("MARKER_{}", value.to_uppercase()))) } } +static E2E: super::support::SharedE2eGroup = + super::support::SharedE2eGroup::standard("ask_user", 4); diff --git a/rust/tests/e2e/builtin_tools.rs b/rust/tests/e2e/builtin_tools.rs index 41584d3a0..12bcad4fa 100644 --- a/rust/tests/e2e/builtin_tools.rs +++ b/rust/tests/e2e/builtin_tools.rs @@ -2,7 +2,7 @@ use std::time::Duration; use github_copilot_sdk::MessageOptions; -use super::support::{assistant_message_content, with_e2e_context}; +use super::support::assistant_message_content; /// Built-in tool tests spawn a real CLI subprocess and execute actual shell / /// file tools. Under concurrent Windows CI load (e2e runs 4-wide on a 4-vCPU @@ -16,7 +16,8 @@ fn message(prompt: &str) -> MessageOptions { #[tokio::test] async fn should_capture_exit_code_in_output() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "builtin_tools", "should_capture_exit_code_in_output", |ctx| { @@ -49,7 +50,7 @@ async fn should_capture_exit_code_in_output() { #[tokio::test] async fn should_capture_stderr_output() { - with_e2e_context("builtin_tools", "should_capture_stderr_output", |ctx| { + super::support::with_shared_e2e_context(&E2E, "builtin_tools", "should_capture_stderr_output", |ctx| { Box::pin(async move { if cfg!(windows) { return; @@ -77,7 +78,7 @@ async fn should_capture_stderr_output() { #[tokio::test] async fn should_read_file_with_line_range() { - with_e2e_context("builtin_tools", "should_read_file_with_line_range", |ctx| { + super::support::with_shared_e2e_context(&E2E, "builtin_tools", "should_read_file_with_line_range", |ctx| { Box::pin(async move { ctx.set_default_copilot_user(); std::fs::write(ctx.work_dir().join("lines.txt"), "line1\nline2\nline3\nline4\nline5\n") @@ -106,7 +107,7 @@ async fn should_read_file_with_line_range() { #[tokio::test] async fn should_handle_nonexistent_file_gracefully() { - with_e2e_context( + super::support::with_shared_e2e_context(&E2E, "builtin_tools", "should_handle_nonexistent_file_gracefully", |ctx| { @@ -144,7 +145,7 @@ async fn should_handle_nonexistent_file_gracefully() { #[tokio::test] async fn should_edit_a_file_successfully() { - with_e2e_context("builtin_tools", "should_edit_a_file_successfully", |ctx| { + super::support::with_shared_e2e_context(&E2E, "builtin_tools", "should_edit_a_file_successfully", |ctx| { Box::pin(async move { ctx.set_default_copilot_user(); std::fs::write(ctx.work_dir().join("edit_me.txt"), "Hello World\nGoodbye World\n") @@ -171,7 +172,7 @@ async fn should_edit_a_file_successfully() { #[tokio::test] async fn should_create_a_new_file() { - with_e2e_context("builtin_tools", "should_create_a_new_file", |ctx| { + super::support::with_shared_e2e_context(&E2E, "builtin_tools", "should_create_a_new_file", |ctx| { Box::pin(async move { ctx.set_default_copilot_user(); let client = ctx.start_client().await; @@ -196,7 +197,7 @@ async fn should_create_a_new_file() { #[tokio::test] async fn should_search_for_patterns_in_files() { - with_e2e_context( + super::support::with_shared_e2e_context(&E2E, "builtin_tools", "should_search_for_patterns_in_files", |ctx| { @@ -229,7 +230,7 @@ async fn should_search_for_patterns_in_files() { #[tokio::test] async fn should_find_files_by_pattern() { - with_e2e_context("builtin_tools", "should_find_files_by_pattern", |ctx| { + super::support::with_shared_e2e_context(&E2E, "builtin_tools", "should_find_files_by_pattern", |ctx| { Box::pin(async move { ctx.set_default_copilot_user(); let src = ctx.work_dir().join("src"); @@ -256,3 +257,5 @@ async fn should_find_files_by_pattern() { }) .await; } +static E2E: super::support::SharedE2eGroup = + super::support::SharedE2eGroup::standard("builtin_tools", 8); diff --git a/rust/tests/e2e/canvas.rs b/rust/tests/e2e/canvas.rs index 1736e9711..2418e9e5a 100644 --- a/rust/tests/e2e/canvas.rs +++ b/rust/tests/e2e/canvas.rs @@ -10,8 +10,6 @@ use github_copilot_sdk::types::ExtensionInfo; use parking_lot::Mutex; use serde_json::{Value, json}; -use super::support::with_e2e_context; - struct TestCanvasHandler { open_calls: Mutex>, close_calls: Mutex>, @@ -74,33 +72,38 @@ fn canvas_session_config( #[tokio::test] async fn canvas_list_discovers_declared_canvases() { - with_e2e_context("canvas", "canvas_list_discovers_declared_canvases", |ctx| { - Box::pin(async move { - ctx.set_default_copilot_user(); - let client = ctx.start_client().await; - let handler = Arc::new(TestCanvasHandler::new()); - let session = client - .create_session(canvas_session_config(ctx, handler)) - .await - .expect("create session"); - - let result = session.rpc().canvas().list().await.expect("list canvases"); - - assert_eq!(result.canvases.len(), 1); - assert_eq!(result.canvases[0].canvas_id, "counter"); - assert_eq!(result.canvases[0].display_name, "Counter"); - assert_eq!(result.canvases[0].description, "Tracks a counter value."); - - session.disconnect().await.expect("disconnect session"); - client.stop().await.expect("stop client"); - }) - }) + super::support::with_shared_e2e_context( + &E2E, + "canvas", + "canvas_list_discovers_declared_canvases", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let handler = Arc::new(TestCanvasHandler::new()); + let session = client + .create_session(canvas_session_config(ctx, handler)) + .await + .expect("create session"); + + let result = session.rpc().canvas().list().await.expect("list canvases"); + + assert_eq!(result.canvases.len(), 1); + assert_eq!(result.canvases[0].canvas_id, "counter"); + assert_eq!(result.canvases[0].display_name, "Counter"); + assert_eq!(result.canvases[0].description, "Tracks a counter value."); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) .await; } #[tokio::test] async fn canvas_open_round_trip() { - with_e2e_context("canvas", "canvas_open_round_trip", |ctx| { + super::support::with_shared_e2e_context(&E2E, "canvas", "canvas_open_round_trip", |ctx| { Box::pin(async move { ctx.set_default_copilot_user(); let client = ctx.start_client().await; @@ -158,64 +161,69 @@ async fn canvas_open_round_trip() { #[tokio::test] async fn canvas_invoke_action_round_trip() { - with_e2e_context("canvas", "canvas_invoke_action_round_trip", |ctx| { - Box::pin(async move { - ctx.set_default_copilot_user(); - let client = ctx.start_client().await; - let handler = Arc::new(TestCanvasHandler::new()); - let session = client - .create_session(canvas_session_config(ctx, handler.clone())) - .await - .expect("create session"); - - let canvas_list = session.rpc().canvas().list().await.expect("list canvases"); - let canvas = &canvas_list.canvases[0]; - - session - .rpc() - .canvas() - .open(github_copilot_sdk::rpc::CanvasOpenRequest { - canvas_id: "counter".to_string(), - instance_id: "counter-2".to_string(), - extension_id: Some(canvas.extension_id.clone()), - input: Some(json!({})), - }) - .await - .expect("open canvas"); - - let result = session - .rpc() - .canvas() - .action() - .invoke(github_copilot_sdk::rpc::CanvasActionInvokeRequest { - instance_id: "counter-2".to_string(), - action_name: "increment".to_string(), - input: Some(json!({ "delta": 1 })), - }) - .await - .expect("invoke action"); - - assert_eq!(result.result, Some(json!({ "newValue": 42 }))); - - { - let actions = handler.action_calls.lock(); - assert_eq!(actions.len(), 1); - assert_eq!(actions[0].canvas_id, "counter"); - assert_eq!(actions[0].instance_id, "counter-2"); - assert_eq!(actions[0].action_name, "increment"); - assert_eq!(actions[0].input, Some(json!({ "delta": 1 }))); - } - - session.disconnect().await.expect("disconnect session"); - client.stop().await.expect("stop client"); - }) - }) + super::support::with_shared_e2e_context( + &E2E, + "canvas", + "canvas_invoke_action_round_trip", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let handler = Arc::new(TestCanvasHandler::new()); + let session = client + .create_session(canvas_session_config(ctx, handler.clone())) + .await + .expect("create session"); + + let canvas_list = session.rpc().canvas().list().await.expect("list canvases"); + let canvas = &canvas_list.canvases[0]; + + session + .rpc() + .canvas() + .open(github_copilot_sdk::rpc::CanvasOpenRequest { + canvas_id: "counter".to_string(), + instance_id: "counter-2".to_string(), + extension_id: Some(canvas.extension_id.clone()), + input: Some(json!({})), + }) + .await + .expect("open canvas"); + + let result = session + .rpc() + .canvas() + .action() + .invoke(github_copilot_sdk::rpc::CanvasActionInvokeRequest { + instance_id: "counter-2".to_string(), + action_name: "increment".to_string(), + input: Some(json!({ "delta": 1 })), + }) + .await + .expect("invoke action"); + + assert_eq!(result.result, Some(json!({ "newValue": 42 }))); + + { + let actions = handler.action_calls.lock(); + assert_eq!(actions.len(), 1); + assert_eq!(actions[0].canvas_id, "counter"); + assert_eq!(actions[0].instance_id, "counter-2"); + assert_eq!(actions[0].action_name, "increment"); + assert_eq!(actions[0].input, Some(json!({ "delta": 1 }))); + } + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) .await; } #[tokio::test] async fn canvas_close_round_trip() { - with_e2e_context("canvas", "canvas_close_round_trip", |ctx| { + super::support::with_shared_e2e_context(&E2E, "canvas", "canvas_close_round_trip", |ctx| { Box::pin(async move { ctx.set_default_copilot_user(); let client = ctx.start_client().await; @@ -272,3 +280,4 @@ async fn canvas_close_round_trip() { }) .await; } +static E2E: super::support::SharedE2eGroup = super::support::SharedE2eGroup::standard("canvas", 4); diff --git a/rust/tests/e2e/client_api.rs b/rust/tests/e2e/client_api.rs index 951fe8720..35cdf6f28 100644 --- a/rust/tests/e2e/client_api.rs +++ b/rust/tests/e2e/client_api.rs @@ -1,41 +1,47 @@ use github_copilot_sdk::SessionId; -use super::support::{wait_for_condition, with_e2e_context}; +use super::support::wait_for_condition; #[tokio::test] async fn should_delete_session_by_id() { - with_e2e_context("client_api", "should_delete_session_by_id", |ctx| { - Box::pin(async move { - ctx.set_default_copilot_user(); - let client = ctx.start_client().await; - let session = client - .create_session(ctx.approve_all_session_config()) - .await - .expect("create session"); - let session_id = session.id().clone(); - - session.send_and_wait("Say OK.").await.expect("send"); - session.disconnect().await.expect("disconnect session"); - client - .delete_session(&session_id) - .await - .expect("delete session"); - - let metadata = client - .get_session_metadata(&session_id) - .await - .expect("get metadata"); - assert!(metadata.is_none()); - - client.stop().await.expect("stop client"); - }) - }) + super::support::with_shared_e2e_context( + &E2E, + "client_api", + "should_delete_session_by_id", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + let session_id = session.id().clone(); + + session.send_and_wait("Say OK.").await.expect("send"); + session.disconnect().await.expect("disconnect session"); + client + .delete_session(&session_id) + .await + .expect("delete session"); + + let metadata = client + .get_session_metadata(&session_id) + .await + .expect("get metadata"); + assert!(metadata.is_none()); + + client.stop().await.expect("stop client"); + }) + }, + ) .await; } #[tokio::test] async fn should_report_error_when_deleting_unknown_session_id() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "client_api", "should_report_error_when_deleting_unknown_session_id", |ctx| { @@ -62,7 +68,7 @@ async fn should_report_error_when_deleting_unknown_session_id() { #[tokio::test] async fn should_get_null_last_session_id_before_any_sessions_exist() { - with_e2e_context( + super::support::with_dedicated_e2e_context( "client_api", "should_get_null_last_session_id_before_any_sessions_exist", |ctx| { @@ -81,7 +87,8 @@ async fn should_get_null_last_session_id_before_any_sessions_exist() { #[tokio::test] async fn should_track_last_session_id_after_session_created() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "client_api", "should_track_last_session_id_after_session_created", |ctx| { @@ -122,7 +129,8 @@ async fn should_track_last_session_id_after_session_created() { #[tokio::test] async fn should_get_null_foreground_session_id_in_headless_mode() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "client_api", "should_get_null_foreground_session_id_in_headless_mode", |ctx| { @@ -144,7 +152,8 @@ async fn should_get_null_foreground_session_id_in_headless_mode() { #[tokio::test] async fn should_report_error_when_setting_foreground_session_in_headless_mode() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "client_api", "should_report_error_when_setting_foreground_session_in_headless_mode", |ctx| { @@ -175,3 +184,5 @@ async fn should_report_error_when_setting_foreground_session_in_headless_mode() ) .await; } +static E2E: super::support::SharedE2eGroup = + super::support::SharedE2eGroup::standard("client_api", 5); diff --git a/rust/tests/e2e/commands.rs b/rust/tests/e2e/commands.rs index d6cb6699f..d110d3b35 100644 --- a/rust/tests/e2e/commands.rs +++ b/rust/tests/e2e/commands.rs @@ -11,11 +11,12 @@ use github_copilot_sdk::{CommandContext, CommandDefinition, CommandHandler, Requ use serde_json::json; use tokio::sync::mpsc; -use super::support::{recv_with_timeout, wait_for_event, with_e2e_context}; +use super::support::{recv_with_timeout, wait_for_event}; #[tokio::test] async fn session_commands_list_returns_builtins_and_respects_client_command_filter() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "commands", "session_with_commands_creates_successfully", |ctx| { @@ -85,7 +86,8 @@ async fn session_commands_list_returns_builtins_and_respects_client_command_filt #[tokio::test] async fn session_commands_invoke_known_builtin_returns_expected_result() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "commands", "session_with_no_commands_creates_successfully", |ctx| { @@ -129,7 +131,8 @@ async fn session_commands_invoke_known_builtin_returns_expected_result() { #[tokio::test] async fn session_commands_execute_runs_registered_command_handler() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "commands", "session_with_commands_creates_successfully", |ctx| { @@ -175,7 +178,8 @@ async fn session_commands_execute_runs_registered_command_handler() { #[tokio::test] async fn session_commands_enqueue_and_respond_to_queued_command() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "commands", "session_with_no_commands_creates_successfully", |ctx| { @@ -289,3 +293,5 @@ fn assert_command( assert_eq!(command.kind, kind); assert!(!command.description.trim().is_empty()); } +static E2E: super::support::SharedE2eGroup = + super::support::SharedE2eGroup::standard("commands", 4); diff --git a/rust/tests/e2e/compaction.rs b/rust/tests/e2e/compaction.rs index b9854ef1d..d56687d5f 100644 --- a/rust/tests/e2e/compaction.rs +++ b/rust/tests/e2e/compaction.rs @@ -1,10 +1,9 @@ use github_copilot_sdk::rpc::{LogRequest, SessionLogLevel}; -use super::support::with_e2e_context; - #[tokio::test] async fn should_return_empty_handoff_summary_for_fresh_session() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "compaction", "should_return_empty_handoff_summary_for_fresh_session", |ctx| { @@ -34,7 +33,8 @@ async fn should_return_empty_handoff_summary_for_fresh_session() { #[tokio::test] async fn should_report_noop_when_cancelling_compaction_without_inflight_work() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "compaction", "should_report_noop_when_cancelling_compaction_without_inflight_work", |ctx| { @@ -71,7 +71,8 @@ async fn should_report_noop_when_cancelling_compaction_without_inflight_work() { #[tokio::test] async fn should_summarize_for_handoff_after_non_ephemeral_log_event() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "compaction", "should_summarize_for_handoff_after_non_ephemeral_log_event", |ctx| { @@ -111,3 +112,5 @@ async fn should_summarize_for_handoff_after_non_ephemeral_log_event() { ) .await; } +static E2E: super::support::SharedE2eGroup = + super::support::SharedE2eGroup::standard("compaction", 3); diff --git a/rust/tests/e2e/elicitation.rs b/rust/tests/e2e/elicitation.rs index 5575e67f3..31da30adb 100644 --- a/rust/tests/e2e/elicitation.rs +++ b/rust/tests/e2e/elicitation.rs @@ -10,11 +10,12 @@ use github_copilot_sdk::{ use serde_json::json; use tokio::sync::Mutex; -use super::support::{DEFAULT_TEST_TOKEN, assert_uuid_like, with_e2e_context}; +use super::support::{DEFAULT_TEST_TOKEN, assert_uuid_like}; #[tokio::test] async fn defaults_capabilities_when_not_provided() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "elicitation", "defaults_capabilities_when_not_provided", |ctx| { @@ -39,7 +40,8 @@ async fn defaults_capabilities_when_not_provided() { #[tokio::test] async fn elicitation_throws_when_capability_is_missing() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "elicitation", "elicitation_throws_when_capability_is_missing", |ctx| { @@ -83,7 +85,8 @@ async fn elicitation_throws_when_capability_is_missing() { #[tokio::test] async fn sends_requestelicitation_when_handler_provided() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "elicitation", "sends_requestelicitation_when_handler_provided", |ctx| { @@ -115,7 +118,8 @@ async fn sends_requestelicitation_when_handler_provided() { #[tokio::test] async fn should_report_elicitation_capability_based_on_handler_presence() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "elicitation", "should_report_elicitation_capability_based_on_handler_presence", |ctx| { @@ -161,7 +165,8 @@ async fn should_report_elicitation_capability_based_on_handler_presence() { #[tokio::test] async fn session_without_elicitationhandler_creates_successfully() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "elicitation", "session_without_elicitationhandler_creates_successfully", |ctx| { @@ -185,7 +190,8 @@ async fn session_without_elicitationhandler_creates_successfully() { #[tokio::test] async fn confirm_returns_true_when_handler_accepts() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "elicitation", "confirm_returns_true_when_handler_accepts", |ctx| { @@ -215,7 +221,8 @@ async fn confirm_returns_true_when_handler_accepts() { #[tokio::test] async fn confirm_returns_false_when_handler_declines() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "elicitation", "confirm_returns_false_when_handler_declines", |ctx| { @@ -243,83 +250,94 @@ async fn confirm_returns_false_when_handler_declines() { #[tokio::test] async fn select_returns_selected_option() { - with_e2e_context("elicitation", "select_returns_selected_option", |ctx| { - Box::pin(async move { - ctx.set_default_copilot_user(); - let client = ctx.start_client().await; - let session = client - .create_session( - SessionConfig::default() - .with_github_token(DEFAULT_TEST_TOKEN) - .pipe_handler(QueuedElicitationHandler::new([accept( - json!({ "selection": "beta" }), - )])), - ) - .await - .expect("create session"); - - assert_eq!( - session - .ui() - .select("Choose", &["alpha", "beta"]) + super::support::with_shared_e2e_context( + &E2E, + "elicitation", + "select_returns_selected_option", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session( + SessionConfig::default() + .with_github_token(DEFAULT_TEST_TOKEN) + .pipe_handler(QueuedElicitationHandler::new([accept( + json!({ "selection": "beta" }), + )])), + ) .await - .expect("select") - .as_deref(), - Some("beta") - ); - - session.disconnect().await.expect("disconnect session"); - client.stop().await.expect("stop client"); - }) - }) + .expect("create session"); + + assert_eq!( + session + .ui() + .select("Choose", &["alpha", "beta"]) + .await + .expect("select") + .as_deref(), + Some("beta") + ); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) .await; } #[tokio::test] async fn input_returns_freeform_value() { - with_e2e_context("elicitation", "input_returns_freeform_value", |ctx| { - Box::pin(async move { - ctx.set_default_copilot_user(); - let client = ctx.start_client().await; - let session = client - .create_session( - SessionConfig::default() - .with_github_token(DEFAULT_TEST_TOKEN) - .pipe_handler(QueuedElicitationHandler::new([accept( - json!({ "value": "typed value" }), - )])), - ) - .await - .expect("create session"); - let options = UiInputOptions { - title: Some("Value"), - description: Some("A value to test"), - min_length: Some(1), - max_length: Some(20), - default: Some("default"), - ..UiInputOptions::default() - }; - - assert_eq!( - session - .ui() - .input("Enter value", Some(&options)) + super::support::with_shared_e2e_context( + &E2E, + "elicitation", + "input_returns_freeform_value", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session( + SessionConfig::default() + .with_github_token(DEFAULT_TEST_TOKEN) + .pipe_handler(QueuedElicitationHandler::new([accept( + json!({ "value": "typed value" }), + )])), + ) .await - .expect("input") - .as_deref(), - Some("typed value") - ); - - session.disconnect().await.expect("disconnect session"); - client.stop().await.expect("stop client"); - }) - }) + .expect("create session"); + let options = UiInputOptions { + title: Some("Value"), + description: Some("A value to test"), + min_length: Some(1), + max_length: Some(20), + default: Some("default"), + ..UiInputOptions::default() + }; + + assert_eq!( + session + .ui() + .input("Enter value", Some(&options)) + .await + .expect("input") + .as_deref(), + Some("typed value") + ); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) .await; } #[tokio::test] async fn elicitation_returns_all_action_shapes() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "elicitation", "elicitation_returns_all_action_shapes", |ctx| { @@ -606,3 +624,5 @@ fn cancel() -> ElicitationResult { content: None, } } +static E2E: super::support::SharedE2eGroup = + super::support::SharedE2eGroup::standard("elicitation", 10); diff --git a/rust/tests/e2e/event_fidelity.rs b/rust/tests/e2e/event_fidelity.rs index 770ed5da1..7176a7e66 100644 --- a/rust/tests/e2e/event_fidelity.rs +++ b/rust/tests/e2e/event_fidelity.rs @@ -3,11 +3,12 @@ use github_copilot_sdk::session_events::{ ToolExecutionCompleteData, ToolExecutionStartData, UserMessageData, }; -use super::support::{collect_until_idle, event_types, with_e2e_context}; +use super::support::{collect_until_idle, event_types}; #[tokio::test] async fn should_include_valid_fields_on_all_events() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "event_fidelity", "should_include_valid_fields_on_all_events", |ctx| { @@ -54,7 +55,8 @@ async fn should_include_valid_fields_on_all_events() { #[tokio::test] async fn should_emit_tool_execution_events_with_correct_fields() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "event_fidelity", "should_emit_tool_execution_events_with_correct_fields", |ctx| { @@ -99,7 +101,8 @@ async fn should_emit_tool_execution_events_with_correct_fields() { #[tokio::test] async fn should_emit_assistant_usage_event_after_model_call() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "event_fidelity", "should_emit_assistant_usage_event_after_model_call", |ctx| { @@ -136,7 +139,8 @@ async fn should_emit_assistant_usage_event_after_model_call() { #[tokio::test] async fn should_emit_session_usage_info_event_after_model_call() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "event_fidelity", "should_emit_session_usage_info_event_after_model_call", |ctx| { @@ -175,7 +179,8 @@ async fn should_emit_session_usage_info_event_after_model_call() { #[tokio::test] async fn should_emit_pending_messages_modified_event_when_message_queue_changes() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "event_fidelity", "should_emit_pending_messages_modified_event_when_message_queue_changes", |ctx| { @@ -218,7 +223,8 @@ async fn should_emit_pending_messages_modified_event_when_message_queue_changes( #[tokio::test] async fn should_emit_events_in_correct_order_for_tool_using_conversation() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "event_fidelity", "should_emit_events_in_correct_order_for_tool_using_conversation", |ctx| { @@ -265,7 +271,8 @@ async fn should_emit_events_in_correct_order_for_tool_using_conversation() { #[tokio::test] async fn should_emit_assistant_message_with_messageid() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "event_fidelity", "should_emit_assistant_message_with_messageid", |ctx| { @@ -299,7 +306,8 @@ async fn should_emit_assistant_message_with_messageid() { #[tokio::test] async fn should_preserve_message_order_in_getmessages_after_tool_use() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "event_fidelity", "should_preserve_message_order_in_getmessages_after_tool_use", |ctx| { @@ -366,3 +374,5 @@ async fn should_preserve_message_order_in_getmessages_after_tool_use() { ) .await; } +static E2E: super::support::SharedE2eGroup = + super::support::SharedE2eGroup::standard("event_fidelity", 8); diff --git a/rust/tests/e2e/hooks.rs b/rust/tests/e2e/hooks.rs index b4a211d87..051019073 100644 --- a/rust/tests/e2e/hooks.rs +++ b/rust/tests/e2e/hooks.rs @@ -7,11 +7,12 @@ use github_copilot_sdk::hooks::{ }; use tokio::sync::mpsc; -use super::support::{recv_with_timeout, with_e2e_context}; +use super::support::recv_with_timeout; #[tokio::test] async fn should_invoke_pretooluse_hook_when_model_runs_a_tool() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "hooks", "should_invoke_pretooluse_hook_when_model_runs_a_tool", |ctx| { @@ -51,7 +52,8 @@ async fn should_invoke_pretooluse_hook_when_model_runs_a_tool() { #[tokio::test] async fn should_invoke_posttooluse_hook_after_model_runs_a_tool() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "hooks", "should_invoke_posttooluse_hook_after_model_runs_a_tool", |ctx| { @@ -92,7 +94,7 @@ async fn should_invoke_posttooluse_hook_after_model_runs_a_tool() { #[tokio::test] async fn should_invoke_both_pretooluse_and_posttooluse_hooks_for_single_tool_call() { - with_e2e_context( + super::support::with_shared_e2e_context(&E2E, "hooks", "should_invoke_both_pretooluse_and_posttooluse_hooks_for_single_tool_call", |ctx| { @@ -147,7 +149,8 @@ async fn should_invoke_both_pretooluse_and_posttooluse_hooks_for_single_tool_cal #[tokio::test] async fn should_deny_tool_execution_when_pretooluse_returns_deny() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "hooks", "should_deny_tool_execution_when_pretooluse_returns_deny", |ctx| { @@ -226,3 +229,4 @@ impl SessionHooks for RecordingHooks { None } } +static E2E: super::support::SharedE2eGroup = super::support::SharedE2eGroup::standard("hooks", 4); diff --git a/rust/tests/e2e/hooks_extended.rs b/rust/tests/e2e/hooks_extended.rs index 4c61757e8..dfd77ed7c 100644 --- a/rust/tests/e2e/hooks_extended.rs +++ b/rust/tests/e2e/hooks_extended.rs @@ -8,17 +8,19 @@ use github_copilot_sdk::hooks::{ PostToolUseFailureInput, PostToolUseFailureOutput, PostToolUseInput, PostToolUseOutput, PreToolUseInput, PreToolUseOutput, SessionEndInput, SessionEndOutput, SessionHooks, SessionStartInput, SessionStartOutput, UserPromptSubmittedInput, UserPromptSubmittedOutput, + UserPromptTransformedInput, UserPromptTransformedOutput, }; use github_copilot_sdk::tool::ToolHandler; use github_copilot_sdk::{Error, SessionConfig, Tool, ToolInvocation, ToolResult}; use serde_json::json; use tokio::sync::mpsc; -use super::support::{assistant_message_content, recv_with_timeout, with_e2e_context}; +use super::support::{assistant_message_content, recv_with_timeout}; #[tokio::test] async fn should_invoke_onsessionstart_hook_on_new_session() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "hooks_extended", "should_invoke_onsessionstart_hook_on_new_session", |ctx| { @@ -50,7 +52,8 @@ async fn should_invoke_onsessionstart_hook_on_new_session() { #[tokio::test] async fn should_invoke_onuserpromptsubmitted_hook_when_sending_a_message() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "hooks_extended", "should_invoke_onuserpromptsubmitted_hook_when_sending_a_message", |ctx| { @@ -82,7 +85,8 @@ async fn should_invoke_onuserpromptsubmitted_hook_when_sending_a_message() { #[tokio::test] async fn should_invoke_onsessionend_hook_when_session_is_disconnected() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "hooks_extended", "should_invoke_onsessionend_hook_when_session_is_disconnected", |ctx| { @@ -113,7 +117,8 @@ async fn should_invoke_onsessionend_hook_when_session_is_disconnected() { #[tokio::test] async fn should_invoke_onerroroccurred_hook_when_error_occurs() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "hooks_extended", "should_invoke_onerroroccurred_hook_when_error_occurs", |ctx| { @@ -144,7 +149,8 @@ async fn should_invoke_onerroroccurred_hook_when_error_occurs() { #[tokio::test] async fn should_invoke_userpromptsubmitted_hook_and_modify_prompt() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "hooks_extended", "should_invoke_userpromptsubmitted_hook_and_modify_prompt", |ctx| { @@ -184,71 +190,126 @@ async fn should_invoke_userpromptsubmitted_hook_and_modify_prompt() { .await; } +#[tokio::test] +async fn should_invoke_userprompttransformed_hook_and_modify_transformed_prompt() { + super::support::with_shared_e2e_context( + &E2E, + "hooks_extended", + "should_invoke_userprompttransformed_hook_and_modify_transformed_prompt", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let (tx, mut rx) = mpsc::unbounded_channel(); + let client = ctx.start_client().await; + let session = client + .create_session( + ctx.approve_all_session_config() + .with_hooks(Arc::new(UserPromptTransformedHooks { tx })), + ) + .await + .expect("create session"); + + let answer = session + .send_and_wait("Answer the request above.") + .await + .expect("send") + .expect("assistant message"); + let input = recv_with_timeout(&mut rx, "userPromptTransformed hook").await; + assert!(input.prompt.contains("Answer the request above.")); + assert!( + input + .transformed_prompt + .contains("Answer the request above.") + ); + assert!(input.transformed_prompt.contains("")); + assert!(input.timestamp > 0.0); + assert!(!input.working_directory.as_os_str().is_empty()); + assert!(assistant_message_content(&answer).contains("HOOKED_TRANSFORMED_PROMPT")); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) + .await; +} + #[tokio::test] async fn should_invoke_sessionstart_hook() { - with_e2e_context("hooks_extended", "should_invoke_sessionstart_hook", |ctx| { - Box::pin(async move { - ctx.set_default_copilot_user(); - let (tx, mut rx) = mpsc::unbounded_channel(); - let client = ctx.start_client().await; - let session = client - .create_session(ctx.approve_all_session_config().with_hooks(Arc::new( - RecordingHooks::session_start( - tx, - Some(SessionStartOutput { - additional_context: Some("Session start hook context.".to_string()), - ..SessionStartOutput::default() - }), - ), - ))) - .await - .expect("create session"); - - session.send_and_wait("Say hi").await.expect("send"); - let input = recv_with_timeout(&mut rx, "sessionStart hook").await; - assert_eq!(input.source, "new"); - - session.disconnect().await.expect("disconnect session"); - client.stop().await.expect("stop client"); - }) - }) + super::support::with_shared_e2e_context( + &E2E, + "hooks_extended", + "should_invoke_sessionstart_hook", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let (tx, mut rx) = mpsc::unbounded_channel(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config().with_hooks(Arc::new( + RecordingHooks::session_start( + tx, + Some(SessionStartOutput { + additional_context: Some("Session start hook context.".to_string()), + ..SessionStartOutput::default() + }), + ), + ))) + .await + .expect("create session"); + + session.send_and_wait("Say hi").await.expect("send"); + let input = recv_with_timeout(&mut rx, "sessionStart hook").await; + assert_eq!(input.source, "new"); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) .await; } #[tokio::test] async fn should_invoke_sessionend_hook() { - with_e2e_context("hooks_extended", "should_invoke_sessionend_hook", |ctx| { - Box::pin(async move { - ctx.set_default_copilot_user(); - let (tx, mut rx) = mpsc::unbounded_channel(); - let client = ctx.start_client().await; - let session = client - .create_session(ctx.approve_all_session_config().with_hooks(Arc::new( - RecordingHooks::session_end( - tx, - Some(SessionEndOutput { - session_summary: Some("session ended".to_string()), - ..SessionEndOutput::default() - }), - ), - ))) - .await - .expect("create session"); - - session.send_and_wait("Say bye").await.expect("send"); - session.disconnect().await.expect("disconnect session"); - let input = recv_with_timeout(&mut rx, "sessionEnd hook").await; - assert!(input.timestamp > 0.0); - - client.stop().await.expect("stop client"); - }) - }) + super::support::with_shared_e2e_context( + &E2E, + "hooks_extended", + "should_invoke_sessionend_hook", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let (tx, mut rx) = mpsc::unbounded_channel(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config().with_hooks(Arc::new( + RecordingHooks::session_end( + tx, + Some(SessionEndOutput { + session_summary: Some("session ended".to_string()), + ..SessionEndOutput::default() + }), + ), + ))) + .await + .expect("create session"); + + session.send_and_wait("Say bye").await.expect("send"); + session.disconnect().await.expect("disconnect session"); + let input = recv_with_timeout(&mut rx, "sessionEnd hook").await; + assert!(input.timestamp > 0.0); + + client.stop().await.expect("stop client"); + }) + }, + ) .await; } #[tokio::test] async fn should_register_erroroccurred_hook() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "hooks_extended", "should_register_erroroccurred_hook", |ctx| { @@ -284,7 +345,8 @@ async fn should_register_erroroccurred_hook() { #[tokio::test] async fn should_invoke_agentstop_hook_and_apply_block_response() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "hooks_extended", "should_invoke_agentstop_hook_and_apply_block_response", |ctx| { @@ -326,7 +388,8 @@ async fn should_invoke_agentstop_hook_and_apply_block_response() { #[tokio::test] async fn should_allow_pretooluse_to_return_modifiedargs_and_suppressoutput() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "hooks_extended", "should_allow_pretooluse_to_return_modifiedargs_and_suppressoutput", |ctx| { @@ -369,7 +432,8 @@ async fn should_allow_pretooluse_to_return_modifiedargs_and_suppressoutput() { #[tokio::test] async fn should_allow_posttooluse_to_return_modifiedresult() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "hooks_extended", "should_allow_posttooluse_to_return_modifiedresult", |ctx| { @@ -415,7 +479,7 @@ async fn should_allow_posttooluse_to_return_modifiedresult() { #[tokio::test] #[ignore = "Fails with 1.0.64-0 runtime: built-in tools are not available when hooks restrict availableTools, so the failure path cannot be exercised. Follow up with runtime team."] async fn should_invoke_posttoolusefailure_hook_for_failed_tool_result() { - with_e2e_context( + super::support::with_shared_e2e_context(&E2E, "hooks_extended", "should_invoke_posttoolusefailure_hook_for_failed_tool_result", |ctx| { @@ -489,6 +553,27 @@ struct AgentStopHooks { call_count: AtomicUsize, } +struct UserPromptTransformedHooks { + tx: mpsc::UnboundedSender, +} + +#[async_trait] +impl SessionHooks for UserPromptTransformedHooks { + async fn on_user_prompt_transformed( + &self, + input: UserPromptTransformedInput, + ctx: HookContext, + ) -> Option { + assert!(!ctx.session_id.as_str().is_empty()); + let _ = self.tx.send(input); + Some(UserPromptTransformedOutput { + modified_transformed_prompt: Some( + "Reply with exactly: HOOKED_TRANSFORMED_PROMPT".to_string(), + ), + }) + } +} + #[async_trait] impl SessionHooks for AgentStopHooks { async fn on_agent_stop( @@ -719,3 +804,5 @@ impl ToolHandler for EchoValueTool { )) } } +static E2E: super::support::SharedE2eGroup = + super::support::SharedE2eGroup::standard("hooks_extended", 12); diff --git a/rust/tests/e2e/mode_empty.rs b/rust/tests/e2e/mode_empty.rs index af1e9267e..2a62d66cf 100644 --- a/rust/tests/e2e/mode_empty.rs +++ b/rust/tests/e2e/mode_empty.rs @@ -12,10 +12,22 @@ use std::sync::Arc; use github_copilot_sdk::handler::ApproveAllHandler; use github_copilot_sdk::types::SystemMessageConfig; -use github_copilot_sdk::{BUILTIN_TOOLS_ISOLATED, Client, ClientMode, SessionConfig, ToolSet}; +use github_copilot_sdk::{BUILTIN_TOOLS_ISOLATED, ClientMode, SessionConfig, ToolSet}; use serde_json::Value; -use super::support::{assistant_message_content, with_e2e_context}; +use super::support::assistant_message_content; + +static E2E: super::support::SharedE2eGroup = + super::support::SharedE2eGroup::new("mode_empty", empty_shared_client_options, 6); + +fn empty_shared_client_options( + context: &super::support::E2eContext, +) -> github_copilot_sdk::ClientOptions { + context + .client_options() + .with_mode(ClientMode::Empty) + .with_base_directory(context.work_dir().to_path_buf()) +} const SHELL_TOOL_NAME: &str = if cfg!(windows) { "powershell" } else { "bash" }; @@ -85,17 +97,14 @@ fn system_message_from_request(exchange: &Value) -> String { #[tokio::test] async fn empty_mode_isolated_set_shell_tool_is_not_exposed() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "mode_empty", "empty_mode_isolated_set_shell_tool_is_not_exposed", |ctx| { Box::pin(async move { ctx.set_default_copilot_user(); - let options = ctx - .client_options() - .with_mode(ClientMode::Empty) - .with_base_directory(ctx.work_dir().to_path_buf()); - let client = Client::start(options).await.expect("start client"); + let client = ctx.start_client().await; let session = client .create_session( SessionConfig::default() @@ -135,17 +144,14 @@ async fn empty_mode_isolated_set_shell_tool_is_not_exposed() { #[tokio::test] async fn empty_mode_builtin_star_exposes_all_built_in_tools() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "mode_empty", "empty_mode_builtin_star_exposes_all_built_in_tools", |ctx| { Box::pin(async move { ctx.set_default_copilot_user(); - let options = ctx - .client_options() - .with_mode(ClientMode::Empty) - .with_base_directory(ctx.work_dir().to_path_buf()); - let client = Client::start(options).await.expect("start client"); + let client = ctx.start_client().await; let session = client .create_session( SessionConfig::default() @@ -175,17 +181,14 @@ async fn empty_mode_builtin_star_exposes_all_built_in_tools() { #[tokio::test] async fn empty_mode_excluded_tools_subtracts_from_available_tools() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "mode_empty", "empty_mode_excluded_tools_subtracts_from_available_tools", |ctx| { Box::pin(async move { ctx.set_default_copilot_user(); - let options = ctx - .client_options() - .with_mode(ClientMode::Empty) - .with_base_directory(ctx.work_dir().to_path_buf()); - let client = Client::start(options).await.expect("start client"); + let client = ctx.start_client().await; let session = client .create_session( SessionConfig::default() @@ -217,17 +220,14 @@ async fn empty_mode_excluded_tools_subtracts_from_available_tools() { #[tokio::test] async fn empty_mode_strips_environment_context_from_the_system_message_by_default() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "mode_empty", "empty_mode_strips_environment_context_from_the_system_message_by_default", |ctx| { Box::pin(async move { ctx.set_default_copilot_user(); - let options = ctx - .client_options() - .with_mode(ClientMode::Empty) - .with_base_directory(ctx.work_dir().to_path_buf()); - let client = Client::start(options).await.expect("start client"); + let client = ctx.start_client().await; let session = client .create_session( SessionConfig::default() @@ -274,17 +274,14 @@ async fn empty_mode_strips_environment_context_from_the_system_message_by_defaul #[tokio::test] async fn empty_mode_system_message_replace_llm_follows_caller_content_verbatim() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "mode_empty", "empty_mode_system_message_replace_llm_follows_caller_content_verbatim", |ctx| { Box::pin(async move { ctx.set_default_copilot_user(); - let options = ctx - .client_options() - .with_mode(ClientMode::Empty) - .with_base_directory(ctx.work_dir().to_path_buf()); - let client = Client::start(options).await.expect("start client"); + let client = ctx.start_client().await; let session = client .create_session( SessionConfig::default() @@ -320,17 +317,14 @@ async fn empty_mode_system_message_replace_llm_follows_caller_content_verbatim() #[tokio::test] async fn empty_mode_append_caller_instruction_takes_effect_and_env_context_stripped() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "mode_empty", "empty_mode_append_caller_instruction_takes_effect_and_env_context_stripped", |ctx| { Box::pin(async move { ctx.set_default_copilot_user(); - let options = ctx - .client_options() - .with_mode(ClientMode::Empty) - .with_base_directory(ctx.work_dir().to_path_buf()); - let client = Client::start(options).await.expect("start client"); + let client = ctx.start_client().await; let session = client .create_session( SessionConfig::default() diff --git a/rust/tests/e2e/mode_handlers.rs b/rust/tests/e2e/mode_handlers.rs index b4089ca28..7ab6fe5bf 100644 --- a/rust/tests/e2e/mode_handlers.rs +++ b/rust/tests/e2e/mode_handlers.rs @@ -15,9 +15,7 @@ use github_copilot_sdk::session_events::{ use github_copilot_sdk::{ExitPlanModeData, SessionConfig, SessionId}; use tokio::sync::mpsc; -use super::support::{ - recv_with_timeout, wait_for_event, wait_for_event_allowing_rate_limit, with_e2e_context, -}; +use super::support::{recv_with_timeout, wait_for_event, wait_for_event_allowing_rate_limit}; const MODE_HANDLER_TOKEN: &str = "mode-handler-token"; const PLAN_SUMMARY: &str = "Greeting file implementation plan"; @@ -64,7 +62,8 @@ impl AutoModeSwitchHandler for AutoModeHandler { #[tokio::test] async fn should_invoke_exit_plan_mode_handler_when_model_uses_tool() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "mode_handlers", "should_invoke_exit_plan_mode_handler_when_model_uses_tool", |ctx| { @@ -181,7 +180,8 @@ async fn should_invoke_exit_plan_mode_handler_when_model_uses_tool() { #[tokio::test] async fn should_invoke_auto_mode_switch_handler_when_rate_limited() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "mode_handlers", "should_invoke_auto_mode_switch_handler_when_rate_limited", |ctx| { @@ -288,3 +288,5 @@ async fn should_invoke_auto_mode_switch_handler_when_rate_limited() { ) .await; } +static E2E: super::support::SharedE2eGroup = + super::support::SharedE2eGroup::standard("mode_handlers", 2); diff --git a/rust/tests/e2e/multi_provider_registry.rs b/rust/tests/e2e/multi_provider_registry.rs index 8c37deaa2..d07acd356 100644 --- a/rust/tests/e2e/multi_provider_registry.rs +++ b/rust/tests/e2e/multi_provider_registry.rs @@ -5,8 +5,6 @@ use github_copilot_sdk::{ }; use serde_json::Value; -use super::support::with_e2e_context; - const CATEGORY: &str = "multi_provider_registry"; fn headers(provider: &str) -> HashMap { @@ -17,7 +15,8 @@ fn headers(provider: &str) -> HashMap { #[tokio::test] async fn should_register_multiple_providers_with_custom_agents_bound_to_their_models() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, CATEGORY, "should_register_multiple_providers_with_custom_agents_bound_to_their_models", |ctx| { @@ -124,7 +123,7 @@ async fn assert_routing( expected_wire_model: &'static str, expected_provider_header: &'static str, ) { - with_e2e_context(CATEGORY, snapshot_name, move |ctx| { + super::support::with_shared_e2e_context(&E2E, CATEGORY, snapshot_name, move |ctx| { Box::pin(async move { ctx.set_default_copilot_user(); let client = ctx.start_client().await; @@ -241,3 +240,4 @@ async fn should_route_delta_turbo_turn_to_its_provider_and_wire_model() { ) .await; } +static E2E: super::support::SharedE2eGroup = super::support::SharedE2eGroup::standard(CATEGORY, 4); diff --git a/rust/tests/e2e/multi_turn.rs b/rust/tests/e2e/multi_turn.rs index 8c3bc5cb9..e57fe2294 100644 --- a/rust/tests/e2e/multi_turn.rs +++ b/rust/tests/e2e/multi_turn.rs @@ -1,13 +1,12 @@ use github_copilot_sdk::SessionEvent; use github_copilot_sdk::session_events::SessionEventType; -use super::support::{ - assistant_message_content, collect_until_idle, event_types, with_e2e_context, -}; +use super::support::{assistant_message_content, collect_until_idle, event_types}; #[tokio::test] async fn should_use_tool_results_from_previous_turns() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "multi_turn", "should_use_tool_results_from_previous_turns", |ctx| { @@ -52,7 +51,8 @@ async fn should_use_tool_results_from_previous_turns() { #[tokio::test] async fn should_handle_file_creation_then_reading_across_turns() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "multi_turn", "should_handle_file_creation_then_reading_across_turns", |ctx| { @@ -154,3 +154,5 @@ fn index_of( .skip(start_index) .find_map(|(index, event)| (event.parsed_type() == event_type).then_some(index)) } +static E2E: super::support::SharedE2eGroup = + super::support::SharedE2eGroup::standard("multi_turn", 2); diff --git a/rust/tests/e2e/permissions.rs b/rust/tests/e2e/permissions.rs index e97aeacb0..8f594841f 100644 --- a/rust/tests/e2e/permissions.rs +++ b/rust/tests/e2e/permissions.rs @@ -11,12 +11,13 @@ use tokio::sync::{mpsc, oneshot}; use super::support::{ DEFAULT_TEST_TOKEN, assistant_message_content, recv_with_timeout, wait_for_condition, - wait_for_event, with_e2e_context, + wait_for_event, }; #[tokio::test] async fn should_work_with_approve_all_permission_handler() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "permissions", "should_work_with_approve_all_permission_handler", |ctx| { @@ -68,7 +69,8 @@ async fn should_handle_concurrent_permission_requests_from_parallel_tools() { #[tokio::test] async fn should_deny_permission_when_handler_returns_denied() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "permissions", "should_deny_permission_when_handler_returns_denied", |ctx| { @@ -120,7 +122,8 @@ async fn should_deny_permission_when_handler_returns_denied() { #[tokio::test] async fn should_deny_tool_operations_when_handler_explicitly_denies() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "permissions", "should_deny_tool_operations_when_handler_explicitly_denies", |ctx| { @@ -159,7 +162,8 @@ async fn should_deny_tool_operations_when_handler_explicitly_denies() { #[tokio::test] async fn should_handle_async_permission_handler() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "permissions", "should_handle_async_permission_handler", |ctx| { @@ -195,7 +199,7 @@ async fn should_handle_async_permission_handler() { #[tokio::test] async fn should_resume_session_with_permission_handler() { - with_e2e_context( + super::support::with_dedicated_e2e_context( "permissions", "should_resume_session_with_permission_handler", |ctx| { @@ -250,7 +254,7 @@ async fn should_resume_session_with_permission_handler() { #[tokio::test] async fn should_deny_tool_operations_when_handler_explicitly_denies_after_resume() { - with_e2e_context( + super::support::with_dedicated_e2e_context( "permissions", "should_deny_tool_operations_when_handler_explicitly_denies_after_resume", |ctx| { @@ -310,7 +314,8 @@ async fn should_deny_tool_operations_when_handler_explicitly_denies_after_resume #[tokio::test] async fn should_receive_toolcallid_in_permission_requests() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "permissions", "should_receive_toolcallid_in_permission_requests", |ctx| { @@ -350,7 +355,8 @@ async fn should_receive_toolcallid_in_permission_requests() { #[tokio::test] async fn should_deny_permission_with_noresult_kind() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "permissions", "should_deny_permission_with_noresult_kind", |ctx| { @@ -385,7 +391,8 @@ async fn should_deny_permission_with_noresult_kind() { #[tokio::test] async fn should_short_circuit_permission_handler_when_set_approve_all_enabled() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "permissions", "should_short_circuit_permission_handler_when_set_approve_all_enabled", |ctx| { @@ -454,7 +461,8 @@ async fn should_short_circuit_permission_handler_when_set_approve_all_enabled() #[tokio::test] async fn should_wait_for_slow_permission_handler() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "permissions", "should_wait_for_slow_permission_handler", |ctx| { @@ -520,7 +528,8 @@ async fn should_wait_for_slow_permission_handler() { #[tokio::test] async fn should_invoke_permission_handler_for_write_operations() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "permissions", "should_invoke_permission_handler_for_write_operations", |ctx| { @@ -720,3 +729,5 @@ impl PermissionHandler for SlowPermissionHandler { PermissionResult::approve_once() } } +static E2E: super::support::SharedE2eGroup = + super::support::SharedE2eGroup::standard("permissions", 9); diff --git a/rust/tests/e2e/pre_mcp_tool_call_hook.rs b/rust/tests/e2e/pre_mcp_tool_call_hook.rs index fd05796fc..31e69d106 100644 --- a/rust/tests/e2e/pre_mcp_tool_call_hook.rs +++ b/rust/tests/e2e/pre_mcp_tool_call_hook.rs @@ -8,7 +8,7 @@ use github_copilot_sdk::{IndexMap, McpServerConfig, McpStdioServerConfig}; use serde_json::{Value, json}; use tokio::sync::mpsc; -use super::support::{assistant_message_content, recv_with_timeout, with_e2e_context}; +use super::support::{assistant_message_content, recv_with_timeout}; fn meta_echo_mcp_servers(repo_root: &std::path::Path) -> IndexMap { let harness_dir = repo_root.join("test").join("harness"); @@ -88,7 +88,7 @@ impl SessionHooks for RemoveMetaHooks { #[tokio::test] async fn should_set_meta_via_premcptoolcall_hook() { - with_e2e_context( + super::support::with_shared_e2e_context(&E2E, "pre_mcp_tool_call_hook", "should_set_meta_via_premcptoolcall_hook", |ctx| { @@ -138,7 +138,7 @@ async fn should_set_meta_via_premcptoolcall_hook() { #[tokio::test] async fn should_replace_meta_via_premcptoolcall_hook() { - with_e2e_context( + super::support::with_shared_e2e_context(&E2E, "pre_mcp_tool_call_hook", "should_replace_meta_via_premcptoolcall_hook", |ctx| { @@ -186,7 +186,7 @@ async fn should_replace_meta_via_premcptoolcall_hook() { #[tokio::test] async fn should_remove_meta_via_premcptoolcall_hook() { - with_e2e_context( + super::support::with_shared_e2e_context(&E2E, "pre_mcp_tool_call_hook", "should_remove_meta_via_premcptoolcall_hook", |ctx| { @@ -231,3 +231,5 @@ async fn should_remove_meta_via_premcptoolcall_hook() { ) .await; } +static E2E: super::support::SharedE2eGroup = + super::support::SharedE2eGroup::standard("pre_mcp_tool_call_hook", 3); diff --git a/rust/tests/e2e/rpc_additional_edge_cases.rs b/rust/tests/e2e/rpc_additional_edge_cases.rs index 59891e94a..d7537f314 100644 --- a/rust/tests/e2e/rpc_additional_edge_cases.rs +++ b/rust/tests/e2e/rpc_additional_edge_cases.rs @@ -5,11 +5,12 @@ use github_copilot_sdk::rpc::{ }; use github_copilot_sdk::session_events::SessionMode; -use super::support::{wait_for_condition, with_e2e_context}; +use super::support::wait_for_condition; #[tokio::test] async fn shell_exec_with_zero_timeout_does_not_kill_long_running_command() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_additional_edge_cases", "shell_exec_with_zero_timeout_does_not_kill_long_running_command", |ctx| { @@ -49,7 +50,8 @@ async fn shell_exec_with_zero_timeout_does_not_kill_long_running_command() { #[tokio::test] async fn workspaces_create_file_with_empty_content_round_trips() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_additional_edge_cases", "workspaces_create_file_with_empty_content_round_trips", |ctx| { @@ -98,7 +100,8 @@ async fn workspaces_create_file_with_empty_content_round_trips() { #[tokio::test] async fn workspaces_create_file_with_unicode_content_round_trips() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_additional_edge_cases", "workspaces_create_file_with_unicode_content_round_trips", |ctx| { @@ -141,7 +144,8 @@ async fn workspaces_create_file_with_unicode_content_round_trips() { #[tokio::test] async fn workspaces_create_file_with_large_content_round_trips() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_additional_edge_cases", "workspaces_create_file_with_large_content_round_trips", |ctx| { @@ -187,7 +191,8 @@ async fn workspaces_create_file_with_large_content_round_trips() { #[tokio::test] async fn plan_update_with_empty_content_then_read_returns_empty() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_additional_edge_cases", "plan_update_with_empty_content_then_read_returns_empty", |ctx| { @@ -220,7 +225,8 @@ async fn plan_update_with_empty_content_then_read_returns_empty() { #[tokio::test] async fn plan_delete_when_none_exists_is_idempotent() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_additional_edge_cases", "plan_delete_when_none_exists_is_idempotent", |ctx| { @@ -252,7 +258,8 @@ async fn plan_delete_when_none_exists_is_idempotent() { #[tokio::test] async fn mode_set_to_same_value_multiple_times_stays_stable() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_additional_edge_cases", "mode_set_to_same_value_multiple_times_stays_stable", |ctx| { @@ -289,7 +296,8 @@ async fn mode_set_to_same_value_multiple_times_stays_stable() { #[tokio::test] async fn name_set_with_unicode_round_trips() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_additional_edge_cases", "name_set_with_unicode_round_trips", |ctx| { @@ -323,7 +331,8 @@ async fn name_set_with_unicode_round_trips() { #[tokio::test] async fn usage_get_metrics_on_fresh_session_returns_zero_tokens() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_additional_edge_cases", "usage_get_metrics_on_fresh_session_returns_zero_tokens", |ctx| { @@ -351,7 +360,8 @@ async fn usage_get_metrics_on_fresh_session_returns_zero_tokens() { #[tokio::test] async fn permissions_reset_session_approvals_on_fresh_session_is_noop() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_additional_edge_cases", "permissions_reset_session_approvals_on_fresh_session_is_noop", |ctx| { @@ -381,7 +391,8 @@ async fn permissions_reset_session_approvals_on_fresh_session_is_noop() { #[tokio::test] async fn permissions_set_approve_all_toggle_round_trips() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_additional_edge_cases", "permissions_set_approve_all_toggle_round_trips", |ctx| { @@ -440,7 +451,8 @@ async fn permissions_set_approve_all_toggle_round_trips() { #[tokio::test] async fn workspaces_createfile_then_listfiles_returns_all_files() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_additional_edge_cases", "workspaces_createfile_then_listfiles_returns_all_files", |ctx| { @@ -492,7 +504,8 @@ async fn workspaces_createfile_then_listfiles_returns_all_files() { #[tokio::test] async fn workspaces_getworkspace_returns_stable_result_across_calls() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_additional_edge_cases", "workspaces_getworkspace_returns_stable_result_across_calls", |ctx| { @@ -545,3 +558,5 @@ fn delayed_marker_command(marker_path: &std::path::Path) -> String { marker_path.display() ) } +static E2E: super::support::SharedE2eGroup = + super::support::SharedE2eGroup::standard("rpc_additional_edge_cases", 13); diff --git a/rust/tests/e2e/rpc_agent.rs b/rust/tests/e2e/rpc_agent.rs index e254460bc..24fbd3067 100644 --- a/rust/tests/e2e/rpc_agent.rs +++ b/rust/tests/e2e/rpc_agent.rs @@ -3,41 +3,47 @@ use github_copilot_sdk::rpc::{AgentInfo, AgentSelectRequest}; use github_copilot_sdk::session_events::SessionEventType; use serde_json::json; -use super::support::{wait_for_event, with_e2e_context}; +use super::support::wait_for_event; #[tokio::test] async fn should_list_available_custom_agents() { - with_e2e_context("rpc_agents", "should_list_available_custom_agents", |ctx| { - Box::pin(async move { - ctx.set_default_copilot_user(); - let client = ctx.start_client().await; - let session = client - .create_session( - ctx.approve_all_session_config() - .with_custom_agents(create_custom_agents()), - ) - .await - .expect("create session"); + super::support::with_shared_e2e_context( + &E2E, + "rpc_agents", + "should_list_available_custom_agents", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session( + ctx.approve_all_session_config() + .with_custom_agents(create_custom_agents()), + ) + .await + .expect("create session"); - let result = session.rpc().agent().list().await.expect("agent list"); - assert_agent(&result.agents, "test-agent", "Test Agent", "A test agent"); - assert_agent( - &result.agents, - "another-agent", - "Another Agent", - "Another test agent", - ); + let result = session.rpc().agent().list().await.expect("agent list"); + assert_agent(&result.agents, "test-agent", "Test Agent", "A test agent"); + assert_agent( + &result.agents, + "another-agent", + "Another Agent", + "Another test agent", + ); - session.disconnect().await.expect("disconnect session"); - client.stop().await.expect("stop client"); - }) - }) + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) .await; } #[tokio::test] async fn should_return_null_when_no_agent_is_selected() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_agents", "should_return_null_when_no_agent_is_selected", |ctx| { @@ -71,47 +77,53 @@ async fn should_return_null_when_no_agent_is_selected() { #[tokio::test] async fn should_select_and_get_current_agent() { - with_e2e_context("rpc_agents", "should_select_and_get_current_agent", |ctx| { - Box::pin(async move { - ctx.set_default_copilot_user(); - let client = ctx.start_client().await; - let session = client - .create_session( - ctx.approve_all_session_config() - .with_custom_agents([create_custom_agents().remove(0)]), - ) - .await - .expect("create session"); + super::support::with_shared_e2e_context( + &E2E, + "rpc_agents", + "should_select_and_get_current_agent", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session( + ctx.approve_all_session_config() + .with_custom_agents([create_custom_agents().remove(0)]), + ) + .await + .expect("create session"); - let selected = session - .rpc() - .agent() - .select(AgentSelectRequest { - name: "test-agent".to_string(), - }) - .await - .expect("select agent"); - assert_eq!(selected.agent.name, "test-agent"); - assert_eq!(selected.agent.display_name, "Test Agent"); + let selected = session + .rpc() + .agent() + .select(AgentSelectRequest { + name: "test-agent".to_string(), + }) + .await + .expect("select agent"); + assert_eq!(selected.agent.name, "test-agent"); + assert_eq!(selected.agent.display_name, "Test Agent"); - let current = session - .rpc() - .agent() - .get_current() - .await - .expect("get selected agent"); - assert_eq!(current.agent.name, "test-agent"); + let current = session + .rpc() + .agent() + .get_current() + .await + .expect("get selected agent"); + assert_eq!(current.agent.name, "test-agent"); - session.disconnect().await.expect("disconnect session"); - client.stop().await.expect("stop client"); - }) - }) + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) .await; } #[tokio::test] async fn should_emit_subagent_selected_and_deselected_events() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_agents", "should_emit_subagent_selected_and_deselected_events", |ctx| { @@ -185,51 +197,57 @@ async fn should_emit_subagent_selected_and_deselected_events() { #[tokio::test] async fn should_deselect_current_agent() { - with_e2e_context("rpc_agents", "should_deselect_current_agent", |ctx| { - Box::pin(async move { - ctx.set_default_copilot_user(); - let client = ctx.start_client().await; - let session = client - .create_session( - ctx.approve_all_session_config() - .with_custom_agents([create_custom_agents().remove(0)]), - ) - .await - .expect("create session"); + super::support::with_shared_e2e_context( + &E2E, + "rpc_agents", + "should_deselect_current_agent", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session( + ctx.approve_all_session_config() + .with_custom_agents([create_custom_agents().remove(0)]), + ) + .await + .expect("create session"); - session - .rpc() - .agent() - .select(AgentSelectRequest { - name: "test-agent".to_string(), - }) - .await - .expect("select agent"); - session - .rpc() - .agent() - .deselect() - .await - .expect("deselect agent"); - let value = client - .call( - "session.agent.getCurrent", - Some(json!({ "sessionId": session.id() })), - ) - .await - .expect("get current agent"); - assert!(value.get("agent").is_some_and(serde_json::Value::is_null)); + session + .rpc() + .agent() + .select(AgentSelectRequest { + name: "test-agent".to_string(), + }) + .await + .expect("select agent"); + session + .rpc() + .agent() + .deselect() + .await + .expect("deselect agent"); + let value = client + .call( + "session.agent.getCurrent", + Some(json!({ "sessionId": session.id() })), + ) + .await + .expect("get current agent"); + assert!(value.get("agent").is_some_and(serde_json::Value::is_null)); - session.disconnect().await.expect("disconnect session"); - client.stop().await.expect("stop client"); - }) - }) + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) .await; } #[tokio::test] async fn should_return_empty_list_when_no_custom_agents_configured() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_agents", "should_return_empty_list_when_no_custom_agents_configured", |ctx| { @@ -254,46 +272,53 @@ async fn should_return_empty_list_when_no_custom_agents_configured() { #[tokio::test] async fn should_call_agent_reload() { - with_e2e_context("rpc_agents", "should_call_agent_reload", |ctx| { - Box::pin(async move { - ctx.set_default_copilot_user(); - let reload_agent = - CustomAgentConfig::new("reload-test-agent-rust", "You are a reload test agent.") - .with_display_name("Reload Test Agent") - .with_description("Used by the agent reload RPC test."); - let client = ctx.start_client().await; - let session = client - .create_session( - ctx.approve_all_session_config() - .with_custom_agents([reload_agent.clone()]), + super::support::with_shared_e2e_context( + &E2E, + "rpc_agents", + "should_call_agent_reload", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let reload_agent = CustomAgentConfig::new( + "reload-test-agent-rust", + "You are a reload test agent.", ) - .await - .expect("create session"); - - assert_agent( - &session - .rpc() - .agent() - .list() + .with_display_name("Reload Test Agent") + .with_description("Used by the agent reload RPC test."); + let client = ctx.start_client().await; + let session = client + .create_session( + ctx.approve_all_session_config() + .with_custom_agents([reload_agent.clone()]), + ) .await - .expect("list before") - .agents, - "reload-test-agent-rust", - "Reload Test Agent", - "Used by the agent reload RPC test.", - ); - let reloaded = session.rpc().agent().reload().await.expect("reload agents"); - let current = session.rpc().agent().list().await.expect("list after"); - assert_eq!( - agent_names(&reloaded.agents), - agent_names(¤t.agents), - "reload result should match current list" - ); + .expect("create session"); - session.disconnect().await.expect("disconnect session"); - client.stop().await.expect("stop client"); - }) - }) + assert_agent( + &session + .rpc() + .agent() + .list() + .await + .expect("list before") + .agents, + "reload-test-agent-rust", + "Reload Test Agent", + "Used by the agent reload RPC test.", + ); + let reloaded = session.rpc().agent().reload().await.expect("reload agents"); + let current = session.rpc().agent().list().await.expect("list after"); + assert_eq!( + agent_names(&reloaded.agents), + agent_names(¤t.agents), + "reload result should match current list" + ); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) .await; } @@ -322,3 +347,5 @@ fn agent_names(agents: &[AgentInfo]) -> Vec<&str> { names.sort_unstable(); names } +static E2E: super::support::SharedE2eGroup = + super::support::SharedE2eGroup::standard("rpc_agents", 7); diff --git a/rust/tests/e2e/rpc_event_log.rs b/rust/tests/e2e/rpc_event_log.rs index 84d575ee3..b116f3e50 100644 --- a/rust/tests/e2e/rpc_event_log.rs +++ b/rust/tests/e2e/rpc_event_log.rs @@ -7,11 +7,10 @@ use github_copilot_sdk::session_events::{ }; use serde_json::json; -use super::support::with_e2e_context; - #[tokio::test] async fn should_read_persisted_events_from_beginning() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_event_log", "should_read_persisted_events_from_beginning", |ctx| { @@ -73,7 +72,8 @@ async fn should_read_persisted_events_from_beginning() { #[tokio::test] async fn should_return_tail_cursor_and_read_empty_when_no_new_events() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_event_log", "should_return_tail_cursor_and_read_empty_when_no_new_events", |ctx| { @@ -116,7 +116,8 @@ async fn should_return_tail_cursor_and_read_empty_when_no_new_events() { #[tokio::test] async fn should_register_and_release_event_interest_idempotently() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_event_log", "should_register_and_release_event_interest_idempotently", |ctx| { @@ -162,7 +163,8 @@ async fn should_register_and_release_event_interest_idempotently() { #[tokio::test] async fn should_longpoll_with_types_filter_for_titlechanged_event() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_event_log", "should_longpoll_with_types_filter_for_titlechanged_event", |ctx| { @@ -213,3 +215,5 @@ async fn should_longpoll_with_types_filter_for_titlechanged_event() { ) .await; } +static E2E: super::support::SharedE2eGroup = + super::support::SharedE2eGroup::standard("rpc_event_log", 4); diff --git a/rust/tests/e2e/rpc_event_side_effects.rs b/rust/tests/e2e/rpc_event_side_effects.rs index 4b634cb89..e8d7b29b2 100644 --- a/rust/tests/e2e/rpc_event_side_effects.rs +++ b/rust/tests/e2e/rpc_event_side_effects.rs @@ -8,11 +8,12 @@ use github_copilot_sdk::session_events::{ SessionWorkspaceFileChangedData, }; -use super::support::{assistant_message_content, wait_for_event, with_e2e_context}; +use super::support::{assistant_message_content, wait_for_event}; #[tokio::test] async fn should_emit_mode_changed_event_when_mode_set() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_event_side_effects", "should_emit_mode_changed_event_when_mode_set", |ctx| { @@ -54,7 +55,8 @@ async fn should_emit_mode_changed_event_when_mode_set() { #[tokio::test] async fn should_emit_plan_changed_event_for_update_and_delete() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_event_side_effects", "should_emit_plan_changed_event_for_update_and_delete", |ctx| { @@ -91,7 +93,8 @@ async fn should_emit_plan_changed_event_for_update_and_delete() { #[tokio::test] async fn should_emit_plan_changed_update_operation_on_second_update() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_event_side_effects", "should_emit_plan_changed_update_operation_on_second_update", |ctx| { @@ -132,7 +135,8 @@ async fn should_emit_plan_changed_update_operation_on_second_update() { #[tokio::test] async fn should_emit_workspace_file_changed_event_when_file_created() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_event_side_effects", "should_emit_workspace_file_changed_event_when_file_created", |ctx| { @@ -177,7 +181,8 @@ async fn should_emit_workspace_file_changed_event_when_file_created() { #[tokio::test] async fn should_emit_title_changed_event_when_name_set() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_event_side_effects", "should_emit_title_changed_event_when_name_set", |ctx| { @@ -220,7 +225,8 @@ async fn should_emit_title_changed_event_when_name_set() { #[tokio::test] async fn should_emit_snapshot_rewind_event_and_remove_events_on_truncate() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_event_side_effects", "should_emit_snapshot_rewind_event_and_remove_events_on_truncate", |ctx| { @@ -281,7 +287,8 @@ async fn should_emit_snapshot_rewind_event_and_remove_events_on_truncate() { #[tokio::test] async fn should_allow_session_use_after_truncate() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_event_side_effects", "should_allow_session_use_after_truncate", |ctx| { @@ -351,3 +358,5 @@ fn wait_for_plan_event( == operation }) } +static E2E: super::support::SharedE2eGroup = + super::support::SharedE2eGroup::standard("rpc_event_side_effects", 7); diff --git a/rust/tests/e2e/rpc_mcp_and_skills.rs b/rust/tests/e2e/rpc_mcp_and_skills.rs index eb8368ebc..d5a295e07 100644 --- a/rust/tests/e2e/rpc_mcp_and_skills.rs +++ b/rust/tests/e2e/rpc_mcp_and_skills.rs @@ -14,11 +14,10 @@ use github_copilot_sdk::rpc::{ }; use github_copilot_sdk::{IndexMap, McpServerConfig, McpStdioServerConfig}; -use super::support::with_e2e_context; - #[tokio::test] async fn should_list_and_toggle_session_skills() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_mcp_and_skills", "should_list_and_toggle_session_skills", |ctx| { @@ -87,7 +86,8 @@ async fn should_list_and_toggle_session_skills() { #[tokio::test] async fn should_ensure_skills_are_loaded_and_list_invoked_skills() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_mcp_and_skills", "should_ensure_skills_are_loaded_and_list_invoked_skills", |ctx| { @@ -137,7 +137,8 @@ async fn should_ensure_skills_are_loaded_and_list_invoked_skills() { #[tokio::test] async fn should_reload_session_skills() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_mcp_and_skills", "should_reload_session_skills", |ctx| { @@ -183,7 +184,8 @@ async fn should_reload_session_skills() { #[tokio::test] async fn should_list_mcp_servers_with_configured_server() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_mcp_and_skills", "should_list_mcp_servers_with_configured_server", |ctx| { @@ -217,7 +219,8 @@ async fn should_list_mcp_servers_with_configured_server() { #[tokio::test] async fn should_set_mcp_env_value_mode_and_remove_github_server() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_mcp_and_skills", "should_set_mcp_env_value_mode_and_remove_github_server", |ctx| { @@ -256,7 +259,8 @@ async fn should_set_mcp_env_value_mode_and_remove_github_server() { #[tokio::test] async fn should_report_mcp_sampling_failure_and_cancel_missing_sampling() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_mcp_and_skills", "should_report_mcp_sampling_failure_and_cancel_missing_sampling", |ctx| { @@ -312,76 +316,87 @@ async fn should_report_mcp_sampling_failure_and_cancel_missing_sampling() { #[tokio::test] async fn should_list_plugins() { - with_e2e_context("rpc_mcp_and_skills", "should_list_plugins", |ctx| { - Box::pin(async move { - ctx.set_default_copilot_user(); - let client = ctx.start_client().await; - let session = client - .create_session(ctx.approve_all_session_config()) - .await - .expect("create session"); - - let result = session.rpc().plugins().list().await.expect("plugins list"); - assert!( - result.plugins.iter().all(|plugin| !plugin.name.is_empty()), - "plugins should have names: {:?}", - result.plugins - ); - - session.disconnect().await.expect("disconnect session"); - client.stop().await.expect("stop client"); - }) - }) + super::support::with_shared_e2e_context( + &E2E, + "rpc_mcp_and_skills", + "should_list_plugins", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + + let result = session.rpc().plugins().list().await.expect("plugins list"); + assert!( + result.plugins.iter().all(|plugin| !plugin.name.is_empty()), + "plugins should have names: {:?}", + result.plugins + ); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) .await; } #[tokio::test] async fn should_list_extensions() { - with_e2e_context("rpc_mcp_and_skills", "should_list_extensions", |ctx| { - Box::pin(async move { - ctx.set_default_copilot_user(); - let client = ctx.start_client().await; - let session = client - .create_session(ctx.approve_all_session_config()) - .await - .expect("create session"); - session - .rpc() - .permissions() - .set_allow_all(PermissionsSetAllowAllRequest { - enabled: None, - mode: Some(PermissionsAllowAllMode::On), - model: None, - source: None, - }) - .await - .expect("enable allow-all"); - - let result = session - .rpc() - .extensions() - .list() - .await - .expect("extensions list"); - assert!( - result - .extensions - .iter() - .all(|extension| !extension.id.is_empty() && !extension.name.is_empty()), - "extensions should have ids and names: {:?}", - result.extensions - ); - - session.disconnect().await.expect("disconnect session"); - client.stop().await.expect("stop client"); - }) - }) + super::support::with_shared_e2e_context( + &E2E, + "rpc_mcp_and_skills", + "should_list_extensions", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + session + .rpc() + .permissions() + .set_allow_all(PermissionsSetAllowAllRequest { + enabled: None, + mode: Some(PermissionsAllowAllMode::On), + model: None, + source: None, + }) + .await + .expect("enable allow-all"); + + let result = session + .rpc() + .extensions() + .list() + .await + .expect("extensions list"); + assert!( + result + .extensions + .iter() + .all(|extension| !extension.id.is_empty() && !extension.name.is_empty()), + "extensions should have ids and names: {:?}", + result.extensions + ); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) .await; } #[tokio::test] async fn should_round_trip_mcp_app_host_context() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_mcp_and_skills", "should_round_trip_mcp_app_host_context", |ctx| { @@ -439,7 +454,8 @@ async fn should_round_trip_mcp_app_host_context() { #[tokio::test] async fn should_diagnose_and_report_mcp_app_capability_errors() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_mcp_and_skills", "should_diagnose_and_report_mcp_app_capability_errors", |ctx| { @@ -503,7 +519,8 @@ async fn should_diagnose_and_report_mcp_app_capability_errors() { #[tokio::test] async fn should_report_error_when_mcp_app_resource_is_not_available() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_mcp_and_skills", "should_report_error_when_mcp_app_resource_is_not_available", |ctx| { @@ -544,7 +561,8 @@ async fn should_report_error_when_mcp_app_resource_is_not_available() { #[tokio::test] async fn should_report_error_when_mcp_host_is_not_initialized() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_mcp_and_skills", "should_report_error_when_mcp_host_is_not_initialized", |ctx| { @@ -600,7 +618,8 @@ async fn should_report_error_when_mcp_host_is_not_initialized() { #[tokio::test] async fn should_report_error_when_mcp_oauth_server_is_not_configured() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_mcp_and_skills", "should_report_error_when_mcp_oauth_server_is_not_configured", |ctx| { @@ -639,7 +658,8 @@ async fn should_report_error_when_mcp_oauth_server_is_not_configured() { #[tokio::test] async fn should_report_error_when_mcp_oauth_server_is_not_remote() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_mcp_and_skills", "should_report_error_when_mcp_oauth_server_is_not_remote", |ctx| { @@ -680,7 +700,8 @@ async fn should_report_error_when_mcp_oauth_server_is_not_remote() { #[tokio::test] async fn should_report_error_when_extensions_are_not_available() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_mcp_and_skills", "should_report_error_when_extensions_are_not_available", |ctx| { @@ -814,3 +835,5 @@ async fn expect_err_contains( "expected error to contain {expected:?}, got {err}" ); } +static E2E: super::support::SharedE2eGroup = + super::support::SharedE2eGroup::standard("rpc_mcp_and_skills", 15); diff --git a/rust/tests/e2e/rpc_mcp_config.rs b/rust/tests/e2e/rpc_mcp_config.rs index 506987fa1..591d7d247 100644 --- a/rust/tests/e2e/rpc_mcp_config.rs +++ b/rust/tests/e2e/rpc_mcp_config.rs @@ -4,11 +4,10 @@ use github_copilot_sdk::rpc::{ }; use serde_json::json; -use super::support::with_e2e_context; - #[tokio::test] async fn should_call_server_mcp_config_rpcs() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_mcp_config", "should_call_server_mcp_config_rpcs", |ctx| { @@ -91,7 +90,8 @@ async fn should_call_server_mcp_config_rpcs() { #[tokio::test] async fn should_round_trip_http_mcp_oauth_config_rpc() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_mcp_config", "should_round_trip_http_mcp_oauth_config_rpc", |ctx| { @@ -209,3 +209,5 @@ async fn should_round_trip_http_mcp_oauth_config_rpc() { ) .await; } +static E2E: super::support::SharedE2eGroup = + super::support::SharedE2eGroup::standard("rpc_mcp_config", 2); diff --git a/rust/tests/e2e/rpc_mcp_lifecycle.rs b/rust/tests/e2e/rpc_mcp_lifecycle.rs index aa3adcf5c..9e135f1e9 100644 --- a/rust/tests/e2e/rpc_mcp_lifecycle.rs +++ b/rust/tests/e2e/rpc_mcp_lifecycle.rs @@ -10,11 +10,12 @@ use github_copilot_sdk::{Error, IndexMap, McpServerConfig, McpStdioServerConfig} use serde::de::DeserializeOwned; use serde_json::{Value, json}; -use super::support::{wait_for_condition, with_e2e_context}; +use super::support::wait_for_condition; #[tokio::test] async fn should_list_tools_and_report_running_status_for_connected_server() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_mcp_lifecycle", "should_list_tools_and_report_running_status_for_connected_server", |ctx| { @@ -61,7 +62,8 @@ async fn should_list_tools_and_report_running_status_for_connected_server() { #[tokio::test] async fn should_throw_when_listing_tools_for_unconnected_server() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_mcp_lifecycle", "should_throw_when_listing_tools_for_unconnected_server", |ctx| { @@ -98,7 +100,8 @@ async fn should_throw_when_listing_tools_for_unconnected_server() { #[tokio::test] async fn should_stop_running_mcp_server() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_mcp_lifecycle", "should_stop_running_mcp_server", |ctx| { @@ -137,7 +140,8 @@ async fn should_stop_running_mcp_server() { #[tokio::test] async fn should_start_and_restart_mcp_server() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_mcp_lifecycle", "should_start_and_restart_mcp_server", |ctx| { @@ -202,7 +206,8 @@ async fn should_start_and_restart_mcp_server() { #[tokio::test] async fn should_reload_mcp_servers_with_config() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_mcp_lifecycle", "should_reload_mcp_servers_with_config", |ctx| { @@ -244,7 +249,8 @@ async fn should_reload_mcp_servers_with_config() { #[tokio::test] async fn should_configure_github_mcp_server() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_mcp_lifecycle", "should_configure_github_mcp_server", |ctx| { @@ -374,3 +380,5 @@ fn assert_error_contains(err: &Error, expected: &str) { "expected error to contain {expected:?}, got {message}" ); } +static E2E: super::support::SharedE2eGroup = + super::support::SharedE2eGroup::standard("rpc_mcp_lifecycle", 6); diff --git a/rust/tests/e2e/rpc_queue.rs b/rust/tests/e2e/rpc_queue.rs index 2c51f9e37..6f4f88165 100644 --- a/rust/tests/e2e/rpc_queue.rs +++ b/rust/tests/e2e/rpc_queue.rs @@ -7,7 +7,7 @@ use github_copilot_sdk::session_events::{CommandQueuedData, SessionEventType}; use serde_json::json; use uuid::Uuid; -use super::support::{wait_for_condition, wait_for_event, with_e2e_context}; +use super::support::{wait_for_condition, wait_for_event}; fn is_pending_command(item: &QueuePendingItems, command: &str) -> bool { item.kind == QueuePendingItemsKind::Command @@ -66,7 +66,8 @@ async fn wait_for_queue_empty(session: &Session) { #[tokio::test] async fn fresh_queue_is_empty_and_empty_mutations_are_noops() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_queue", "fresh_queue_is_empty_and_empty_mutations_are_noops", |ctx| { @@ -115,7 +116,8 @@ async fn fresh_queue_is_empty_and_empty_mutations_are_noops() { #[tokio::test] async fn pendingitems_reports_queued_command_and_remove_and_clear_update_queue() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_queue", "pendingitems_reports_queued_command_and_remove_and_clear_update_queue", |ctx| { @@ -223,3 +225,5 @@ async fn pendingitems_reports_queued_command_and_remove_and_clear_update_queue() ) .await; } +static E2E: super::support::SharedE2eGroup = + super::support::SharedE2eGroup::standard("rpc_queue", 2); diff --git a/rust/tests/e2e/rpc_remote.rs b/rust/tests/e2e/rpc_remote.rs index c34a8d5e5..e98f6c4fa 100644 --- a/rust/tests/e2e/rpc_remote.rs +++ b/rust/tests/e2e/rpc_remote.rs @@ -1,11 +1,12 @@ use github_copilot_sdk::rpc::{RemoteEnableRequest, RemoteSessionMode}; use github_copilot_sdk::session_events::{SessionEventType, SessionRemoteSteerableChangedData}; -use super::support::{wait_for_event, with_e2e_context}; +use super::support::wait_for_event; #[tokio::test] async fn should_treat_remote_off_as_noop_or_implemented_error() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_remote", "should_treat_remote_off_as_noop_or_implemented_error", |ctx| { @@ -45,7 +46,8 @@ async fn should_treat_remote_off_as_noop_or_implemented_error() { #[tokio::test] async fn should_treat_remote_disable_as_noop_or_implemented_error() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_remote", "should_treat_remote_disable_as_noop_or_implemented_error", |ctx| { @@ -74,7 +76,8 @@ async fn should_treat_remote_disable_as_noop_or_implemented_error() { #[tokio::test] async fn should_notify_steerable_changed_event_and_persist_flag() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_remote", "should_notify_steerable_changed_event_and_persist_flag", |ctx| { @@ -112,3 +115,5 @@ async fn should_notify_steerable_changed_event_and_persist_flag() { ) .await; } +static E2E: super::support::SharedE2eGroup = + super::support::SharedE2eGroup::standard("rpc_remote", 3); diff --git a/rust/tests/e2e/rpc_schedule.rs b/rust/tests/e2e/rpc_schedule.rs index fc782fe41..af8f6f59b 100644 --- a/rust/tests/e2e/rpc_schedule.rs +++ b/rust/tests/e2e/rpc_schedule.rs @@ -1,10 +1,9 @@ use github_copilot_sdk::rpc::ScheduleStopRequest; -use super::support::with_e2e_context; - #[tokio::test] async fn should_list_no_schedules_for_fresh_session() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_schedule", "should_list_no_schedules_for_fresh_session", |ctx| { @@ -34,7 +33,8 @@ async fn should_list_no_schedules_for_fresh_session() { #[tokio::test] async fn should_return_null_entry_when_stopping_unknown_schedule() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_schedule", "should_return_null_entry_when_stopping_unknown_schedule", |ctx| { @@ -71,3 +71,5 @@ async fn should_return_null_entry_when_stopping_unknown_schedule() { ) .await; } +static E2E: super::support::SharedE2eGroup = + super::support::SharedE2eGroup::standard("rpc_schedule", 2); diff --git a/rust/tests/e2e/rpc_server.rs b/rust/tests/e2e/rpc_server.rs index d0beab245..caa846ba0 100644 --- a/rust/tests/e2e/rpc_server.rs +++ b/rust/tests/e2e/rpc_server.rs @@ -21,7 +21,8 @@ use super::support::{with_e2e_context, with_e2e_context_no_snapshot}; #[tokio::test] async fn should_call_rpc_ping_with_typed_params_and_result() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_server", "should_call_rpc_ping_with_typed_params_and_result", |ctx| { @@ -118,7 +119,8 @@ async fn should_call_rpc_account_get_quota_when_authenticated() { #[tokio::test] async fn should_call_rpc_tools_list_with_typed_result() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_server", "should_call_rpc_tools_list_with_typed_result", |ctx| { @@ -186,7 +188,8 @@ async fn should_reject_llm_response_frames_for_unknown_request() { #[tokio::test] async fn should_discover_server_mcp_and_skills() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_server", "should_discover_server_mcp_and_skills", |ctx| { @@ -401,35 +404,41 @@ async fn should_call_rpc_sessionfs_setprovider_with_typed_result() { #[tokio::test] async fn should_add_secret_filter_values() { - with_e2e_context("rpc_server", "should_add_secret_filter_values", |ctx| { - Box::pin(async move { - let client = ctx.start_client().await; + super::support::with_shared_e2e_context( + &E2E, + "rpc_server", + "should_add_secret_filter_values", + |ctx| { + Box::pin(async move { + let client = ctx.start_client().await; - let result = client - .rpc() - .secrets() - .add_filter_values(SecretsAddFilterValuesRequest { - values: vec!["rust-secret-value".to_string()], - }) - .await; - match result { - Ok(result) => assert!(result.ok), - Err(err) => { - let message = err.to_string(); - assert!(message.contains("COPILOT_ENABLE_SECRET_FILTERING")); - assert!(!message.contains("Unhandled method secrets.addFilterValues")); + let result = client + .rpc() + .secrets() + .add_filter_values(SecretsAddFilterValuesRequest { + values: vec!["rust-secret-value".to_string()], + }) + .await; + match result { + Ok(response) => assert!(response.ok), + Err(err) => { + let message = err.to_string(); + assert!(message.contains("COPILOT_ENABLE_SECRET_FILTERING")); + assert!(!message.contains("Unhandled method secrets.addFilterValues")); + } } - } - client.stop().await.expect("stop client"); - }) - }) + client.stop().await.expect("stop client"); + }) + }, + ) .await; } #[tokio::test] async fn should_list_find_and_inspect_persisted_session_state() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_server", "should_list_find_and_inspect_persisted_session_state", |ctx| { @@ -550,7 +559,8 @@ async fn should_list_find_and_inspect_persisted_session_state() { #[tokio::test] async fn should_enrich_basic_session_metadata() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_server", "should_enrich_basic_session_metadata", |ctx| { @@ -604,7 +614,8 @@ async fn should_enrich_basic_session_metadata() { #[tokio::test] async fn should_close_active_session_and_release_lock() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_server", "should_close_active_session_and_release_lock", |ctx| { @@ -655,7 +666,8 @@ async fn should_close_active_session_and_release_lock() { #[tokio::test] async fn should_prune_dryrun_and_bulkdelete_persisted_session() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_server", "should_prune_dryrun_and_bulkdelete_persisted_session", |ctx| { @@ -702,7 +714,8 @@ async fn should_prune_dryrun_and_bulkdelete_persisted_session() { #[tokio::test] async fn should_set_additional_plugins_and_reload_deferred_hooks() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_server", "should_set_additional_plugins_and_reload_deferred_hooks", |ctx| { @@ -752,34 +765,40 @@ async fn should_set_additional_plugins_and_reload_deferred_hooks() { #[tokio::test] async fn should_save_and_get_event_file_path() { - with_e2e_context("rpc_server", "should_save_and_get_event_file_path", |ctx| { - Box::pin(async move { - ctx.set_default_copilot_user(); - let client = ctx.start_client().await; - let session = client - .create_session(ctx.approve_all_session_config()) - .await - .expect("create session"); + super::support::with_shared_e2e_context( + &E2E, + "rpc_server", + "should_save_and_get_event_file_path", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); - client - .rpc() - .sessions() - .save(SessionsSaveRequest { - session_id: session.id().clone(), - }) - .await - .expect("save session"); + client + .rpc() + .sessions() + .save(SessionsSaveRequest { + session_id: session.id().clone(), + }) + .await + .expect("save session"); - session.disconnect().await.expect("disconnect session"); - client.stop().await.expect("stop client"); - }) - }) + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) .await; } #[tokio::test] async fn should_report_implemented_error_when_connecting_unknown_remote_session() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_server", "should_report_implemented_error_when_connecting_unknown_remote_session", |ctx| { @@ -861,3 +880,5 @@ fn paths_equal(left: &str, right: &str) -> bool { normalize(left) == normalize(right) } +static E2E: super::support::SharedE2eGroup = + super::support::SharedE2eGroup::standard("rpc_server", 11); diff --git a/rust/tests/e2e/rpc_server_misc.rs b/rust/tests/e2e/rpc_server_misc.rs index b9e5cdf5c..47ae4ecbd 100644 --- a/rust/tests/e2e/rpc_server_misc.rs +++ b/rust/tests/e2e/rpc_server_misc.rs @@ -9,27 +9,33 @@ use super::support::{wait_for_condition, with_e2e_context}; #[tokio::test] async fn should_reload_user_settings() { - with_e2e_context("rpc_server_misc", "should_reload_user_settings", |ctx| { - Box::pin(async move { - let client = ctx.start_client().await; + super::support::with_shared_e2e_context( + &E2E, + "rpc_server_misc", + "should_reload_user_settings", + |ctx| { + Box::pin(async move { + let client = ctx.start_client().await; - client - .rpc() - .user() - .settings() - .reload() - .await - .expect("reload user settings"); + client + .rpc() + .user() + .settings() + .reload() + .await + .expect("reload user settings"); - client.stop().await.expect("stop client"); - }) - }) + client.stop().await.expect("stop client"); + }) + }, + ) .await; } #[tokio::test] async fn should_get_set_and_clear_user_settings() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_server_misc", "should_get_set_and_clear_user_settings", |ctx| { @@ -206,7 +212,8 @@ async fn should_login_list_getcurrentauth_and_logout_account() { #[tokio::test] async fn should_report_agent_registry_spawn_gate_closed() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_server_misc", "should_report_agent_registry_spawn_gate_closed", |ctx| { @@ -279,7 +286,8 @@ async fn should_shut_down_owned_runtime() { #[tokio::test] async fn should_report_not_found_when_opening_session_without_context() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_server_misc", "should_report_not_found_when_opening_session_without_context", |ctx| { @@ -305,7 +313,8 @@ async fn should_report_not_found_when_opening_session_without_context() { #[tokio::test] async fn should_reject_send_attachments_from_non_extension_connection() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_server_misc", "should_reject_send_attachments_from_non_extension_connection", |ctx| { @@ -353,3 +362,5 @@ fn setting_patch(key: &str, value: Value) -> Value { settings.insert(key.to_string(), value); Value::Object(settings) } +static E2E: super::support::SharedE2eGroup = + super::support::SharedE2eGroup::standard("rpc_server_misc", 5); diff --git a/rust/tests/e2e/rpc_server_plugins.rs b/rust/tests/e2e/rpc_server_plugins.rs index 054ffa359..df6072253 100644 --- a/rust/tests/e2e/rpc_server_plugins.rs +++ b/rust/tests/e2e/rpc_server_plugins.rs @@ -8,15 +8,14 @@ use github_copilot_sdk::rpc::{ PluginsUpdateRequest, }; -use super::support::with_e2e_context; - const MARKETPLACE_NAME: &str = "csharp-e2e-marketplace"; const PLUGIN_NAME: &str = "csharp-e2e-plugin"; const DIRECT_PLUGIN_NAME: &str = "csharp-e2e-direct"; #[tokio::test] async fn should_install_and_list_plugin_from_local_marketplace() { - with_e2e_context( + super::support::with_dedicated_group_e2e_context( + &E2E, "rpc_server_plugins", "should_install_and_list_plugin_from_local_marketplace", |ctx| { @@ -65,7 +64,8 @@ async fn should_install_and_list_plugin_from_local_marketplace() { #[tokio::test] async fn should_enable_and_disable_marketplace_plugin() { - with_e2e_context( + super::support::with_dedicated_group_e2e_context( + &E2E, "rpc_server_plugins", "should_enable_and_disable_marketplace_plugin", |ctx| { @@ -135,7 +135,8 @@ async fn should_enable_and_disable_marketplace_plugin() { #[tokio::test] async fn should_update_single_marketplace_plugin() { - with_e2e_context( + super::support::with_dedicated_group_e2e_context( + &E2E, "rpc_server_plugins", "should_update_single_marketplace_plugin", |ctx| { @@ -184,7 +185,8 @@ async fn should_update_single_marketplace_plugin() { #[tokio::test] async fn should_update_all_installed_plugins() { - with_e2e_context( + super::support::with_dedicated_group_e2e_context( + &E2E, "rpc_server_plugins", "should_update_all_installed_plugins", |ctx| { @@ -241,7 +243,8 @@ async fn should_update_all_installed_plugins() { #[tokio::test] async fn should_install_direct_local_plugin_with_deprecation_warning() { - with_e2e_context( + super::support::with_dedicated_group_e2e_context( + &E2E, "rpc_server_plugins", "should_install_direct_local_plugin_with_deprecation_warning", |ctx| { @@ -316,7 +319,8 @@ async fn should_install_direct_local_plugin_with_deprecation_warning() { #[tokio::test] async fn should_list_browse_refresh_and_remove_local_marketplace() { - with_e2e_context( + super::support::with_dedicated_group_e2e_context( + &E2E, "rpc_server_plugins", "should_list_browse_refresh_and_remove_local_marketplace", |ctx| { @@ -434,7 +438,8 @@ async fn should_list_browse_refresh_and_remove_local_marketplace() { #[tokio::test] async fn should_reload_mcp_config_cache() { - with_e2e_context( + super::support::with_dedicated_group_e2e_context( + &E2E, "rpc_server_plugins", "should_reload_mcp_config_cache", |ctx| { @@ -538,3 +543,5 @@ fn single_plugin<'a>( assert_eq!(matches.len(), 1, "expected one plugin in {list:?}"); matches[0] } +static E2E: super::support::SharedE2eGroup = + super::support::SharedE2eGroup::standard("rpc_server_plugins", 7); diff --git a/rust/tests/e2e/rpc_server_remote_control.rs b/rust/tests/e2e/rpc_server_remote_control.rs index a49f1d12a..49809235c 100644 --- a/rust/tests/e2e/rpc_server_remote_control.rs +++ b/rust/tests/e2e/rpc_server_remote_control.rs @@ -6,11 +6,10 @@ use github_copilot_sdk::rpc::{ }; use serde_json::Value; -use super::support::with_e2e_context; - #[tokio::test] async fn should_report_remote_control_status_as_off() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_server_remote_control", "should_report_remote_control_status_as_off", |ctx| { @@ -34,7 +33,8 @@ async fn should_report_remote_control_status_as_off() { #[tokio::test] async fn should_treat_set_steering_as_no_op_when_off() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_server_remote_control", "should_treat_set_steering_as_no_op_when_off", |ctx| { @@ -60,7 +60,8 @@ async fn should_treat_set_steering_as_no_op_when_off() { #[tokio::test] async fn should_report_not_stopped_when_remote_control_is_off() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_server_remote_control", "should_report_not_stopped_when_remote_control_is_off", |ctx| { @@ -85,7 +86,8 @@ async fn should_report_not_stopped_when_remote_control_is_off() { #[tokio::test] async fn should_reject_transfer_when_off_with_compare_and_swap() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_server_remote_control", "should_reject_transfer_when_off_with_compare_and_swap", |ctx| { @@ -116,7 +118,8 @@ async fn should_reject_transfer_when_off_with_compare_and_swap() { #[tokio::test] async fn should_reach_runtime_when_starting_remote_control_for_unknown_session() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_server_remote_control", "should_reach_runtime_when_starting_remote_control_for_unknown_session", |ctx| { @@ -177,3 +180,5 @@ fn assert_not_unhandled(message: &str) { "{message}" ); } +static E2E: super::support::SharedE2eGroup = + super::support::SharedE2eGroup::standard("rpc_server_remote_control", 5); diff --git a/rust/tests/e2e/rpc_session_state.rs b/rust/tests/e2e/rpc_session_state.rs index 629252190..c705d231c 100644 --- a/rust/tests/e2e/rpc_session_state.rs +++ b/rust/tests/e2e/rpc_session_state.rs @@ -17,15 +17,14 @@ use github_copilot_sdk::session_events::{ }; use serde_json::json; -use super::support::{ - assistant_message_content, wait_for_condition, wait_for_event, with_e2e_context, -}; +use super::support::{assistant_message_content, wait_for_condition, wait_for_event}; const MODEL_ID: &str = "claude-sonnet-4.5"; #[tokio::test] async fn should_call_session_rpc_model_getcurrent() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_session_state", "should_call_session_rpc_model_getcurrent", |ctx| { @@ -55,7 +54,8 @@ async fn should_call_session_rpc_model_getcurrent() { #[tokio::test] async fn should_call_session_rpc_model_switchto() { - with_e2e_context( + super::support::with_dedicated_group_e2e_context( + &E2E, "rpc_session_state", "should_call_session_rpc_model_switchto", |ctx| { @@ -107,7 +107,8 @@ async fn should_call_session_rpc_model_switchto() { #[tokio::test] async fn should_get_and_set_session_mode() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_session_state", "should_get_and_set_session_mode", |ctx| { @@ -146,7 +147,8 @@ async fn should_get_and_set_session_mode() { #[tokio::test] async fn should_shutdown_session_with_routine_type() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_session_state", "should_shutdown_session_with_routine_type", |ctx| { @@ -184,7 +186,8 @@ async fn should_shutdown_session_with_routine_type() { #[tokio::test] async fn should_set_and_get_each_session_mode_value() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_session_state", "should_set_and_get_each_session_mode_value", |ctx| { @@ -220,7 +223,8 @@ async fn should_set_and_get_each_session_mode_value() { #[tokio::test] async fn should_read_update_and_delete_plan() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_session_state", "should_read_update_and_delete_plan", |ctx| { @@ -285,7 +289,8 @@ async fn should_read_update_and_delete_plan() { #[tokio::test] async fn should_call_workspace_file_rpc_methods() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_session_state", "should_call_workspace_file_rpc_methods", |ctx| { @@ -342,7 +347,8 @@ async fn should_call_workspace_file_rpc_methods() { #[tokio::test] async fn should_reject_workspace_file_path_traversal() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_session_state", "should_reject_workspace_file_path_traversal", |ctx| { @@ -386,7 +392,8 @@ async fn should_reject_workspace_file_path_traversal() { #[tokio::test] async fn should_create_workspace_file_with_nested_path_auto_creating_dirs() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_session_state", "should_create_workspace_file_with_nested_path_auto_creating_dirs", |ctx| { @@ -428,7 +435,8 @@ async fn should_create_workspace_file_with_nested_path_auto_creating_dirs() { #[tokio::test] async fn should_report_error_reading_nonexistent_workspace_file() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_session_state", "should_report_error_reading_nonexistent_workspace_file", |ctx| { @@ -461,7 +469,8 @@ async fn should_report_error_reading_nonexistent_workspace_file() { #[tokio::test] async fn should_update_existing_workspace_file_with_update_operation() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_session_state", "should_update_existing_workspace_file_with_update_operation", |ctx| { @@ -516,7 +525,8 @@ async fn should_update_existing_workspace_file_with_update_operation() { #[tokio::test] async fn should_reject_empty_or_whitespace_session_name() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_session_state", "should_reject_empty_or_whitespace_session_name", |ctx| { @@ -551,7 +561,8 @@ async fn should_reject_empty_or_whitespace_session_name() { #[tokio::test] async fn should_emit_title_changed_event_each_time_name_set_is_called() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_session_state", "should_emit_title_changed_event_each_time_name_set_is_called", |ctx| { @@ -602,7 +613,8 @@ async fn should_emit_title_changed_event_each_time_name_set_is_called() { #[tokio::test] async fn should_get_and_set_session_metadata() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_session_state", "should_call_metadata_snapshot_setworkingdirectory_and_recordcontextchange", |ctx| { @@ -651,7 +663,8 @@ async fn should_get_and_set_session_metadata() { #[tokio::test] async fn should_call_metadata_snapshot_setworkingdirectory_and_recordcontextchange() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_session_state", "should_get_and_set_session_metadata", |ctx| { @@ -731,7 +744,8 @@ async fn should_call_metadata_snapshot_setworkingdirectory_and_recordcontextchan #[tokio::test] async fn should_update_options_and_initialize_session_services() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_session_state", "should_update_options_and_initialize_session_services", |ctx| { @@ -796,7 +810,8 @@ async fn should_update_options_and_initialize_session_services() { #[tokio::test] async fn should_set_reasoningeffort_and_auto_name() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_session_state", "should_set_reasoningeffort_and_auto_name", |ctx| { @@ -854,51 +869,57 @@ async fn should_set_reasoningeffort_and_auto_name() { #[tokio::test] async fn should_set_auth_credentials() { - with_e2e_context("rpc_session_state", "should_set_auth_credentials", |ctx| { - Box::pin(async move { - ctx.set_default_copilot_user(); - let token = "rpc-session-auth-token"; - ctx.set_copilot_user_by_token_with_login(token, "rpc-session-user"); - let client = ctx.start_client().await; - let session = client - .create_session(ctx.approve_all_session_config()) - .await - .expect("create session"); - - let set = session - .rpc() - .git_hub_auth() - .set_credentials(SessionSetCredentialsParams { - credentials: Some(json!({ - "type": "user", - "host": "github.com", - "login": "rpc-session-user" - })), - }) - .await - .expect("set credentials"); - assert!(set.success); - let status = session - .rpc() - .git_hub_auth() - .get_status() - .await - .expect("auth status"); - assert!(status.is_authenticated); - assert_eq!(status.auth_type, Some(AuthInfoType::User)); - assert_eq!(status.host.as_deref(), Some("github.com")); - assert_eq!(status.login.as_deref(), Some("rpc-session-user")); - - session.disconnect().await.expect("disconnect session"); - client.stop().await.expect("stop client"); - }) - }) + super::support::with_shared_e2e_context( + &E2E, + "rpc_session_state", + "should_set_auth_credentials", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let token = "rpc-session-auth-token"; + ctx.set_copilot_user_by_token_with_login(token, "rpc-session-user"); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + + let set = session + .rpc() + .git_hub_auth() + .set_credentials(SessionSetCredentialsParams { + credentials: Some(json!({ + "type": "user", + "host": "github.com", + "login": "rpc-session-user" + })), + }) + .await + .expect("set credentials"); + assert!(set.success); + let status = session + .rpc() + .git_hub_auth() + .get_status() + .await + .expect("auth status"); + assert!(status.is_authenticated); + assert_eq!(status.auth_type, Some(AuthInfoType::User)); + assert_eq!(status.host.as_deref(), Some("github.com")); + assert_eq!(status.login.as_deref(), Some("rpc-session-user")); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) .await; } #[tokio::test] async fn should_fork_session_with_persisted_messages() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_session_state", "should_fork_session_with_persisted_messages", |ctx| { @@ -956,7 +977,8 @@ async fn should_fork_session_with_persisted_messages() { #[tokio::test] async fn should_report_error_when_forking_session_to_unknown_event_id() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_session_state", "should_report_error_when_forking_session_to_unknown_event_id", |ctx| { @@ -992,7 +1014,8 @@ async fn should_report_error_when_forking_session_to_unknown_event_id() { #[tokio::test] async fn should_call_session_usage_and_permission_rpcs() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_session_state", "should_call_session_usage_and_permission_rpcs", |ctx| { @@ -1039,7 +1062,8 @@ async fn should_call_session_usage_and_permission_rpcs() { #[tokio::test] async fn should_report_implemented_errors_for_unsupported_session_rpc_paths() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_session_state", "should_report_implemented_errors_for_unsupported_session_rpc_paths", |ctx| { @@ -1074,10 +1098,11 @@ async fn should_report_implemented_errors_for_unsupported_session_rpc_paths() { } #[tokio::test] -async fn should_compact_session_history_after_messages() { - with_e2e_context( +async fn should_report_processing_and_context_metadata() { + super::support::with_shared_e2e_context( + &E2E, "rpc_session_state", - "should_compact_session_history_after_messages", + "should_report_processing_and_context_metadata", |ctx| { Box::pin(async move { ctx.set_default_copilot_user(); @@ -1181,3 +1206,5 @@ fn assistant_message_content_if_present( None } } +static E2E: super::support::SharedE2eGroup = + super::support::SharedE2eGroup::standard("rpc_session_state", 22); diff --git a/rust/tests/e2e/rpc_session_state_extras.rs b/rust/tests/e2e/rpc_session_state_extras.rs index 749e9706d..f43359f0b 100644 --- a/rust/tests/e2e/rpc_session_state_extras.rs +++ b/rust/tests/e2e/rpc_session_state_extras.rs @@ -54,7 +54,8 @@ async fn should_list_models_for_session() { #[tokio::test] async fn should_report_session_activity_when_idle() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_session_state_extras", "should_report_session_activity_when_idle", |ctx| { @@ -86,7 +87,8 @@ async fn should_report_session_activity_when_idle() { #[tokio::test] async fn should_get_and_set_allowall_permissions() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_session_state_extras", "should_get_and_set_allowall_permissions", |ctx| { @@ -162,7 +164,8 @@ async fn should_get_and_set_allowall_permissions() { #[tokio::test] async fn should_read_empty_sql_todos_for_fresh_session() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_session_state_extras", "should_read_empty_sql_todos_for_fresh_session", |ctx| { @@ -193,7 +196,8 @@ async fn should_read_empty_sql_todos_for_fresh_session() { #[tokio::test] async fn should_get_telemetry_engagement_id() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_session_state_extras", "should_get_telemetry_engagement_id", |ctx| { @@ -222,7 +226,8 @@ async fn should_get_telemetry_engagement_id() { #[tokio::test] async fn should_get_current_tool_metadata_after_initialization() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_session_state_extras", "should_get_current_tool_metadata_after_initialization", |ctx| { @@ -262,7 +267,8 @@ async fn should_get_current_tool_metadata_after_initialization() { #[tokio::test] async fn should_add_byok_provider_and_model_at_runtime() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_session_state_extras", "should_add_byok_provider_and_model_at_runtime", |ctx| { @@ -342,7 +348,8 @@ async fn should_add_byok_provider_and_model_at_runtime() { #[tokio::test] async fn should_return_empty_completions_when_host_does_not_provide_them() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_session_state_extras", "should_return_empty_completions_when_host_does_not_provide_them", |ctx| { @@ -375,7 +382,8 @@ async fn should_return_empty_completions_when_host_does_not_provide_them() { #[tokio::test] async fn should_report_visibility_as_unsynced_for_local_session() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_session_state_extras", "should_report_visibility_as_unsynced_for_local_session", |ctx| { @@ -418,7 +426,8 @@ async fn should_report_visibility_as_unsynced_for_local_session() { #[tokio::test] async fn should_get_context_attribution_and_heaviest_messages_after_turn() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_session_state_extras", "should_get_context_attribution_and_heaviest_messages_after_turn", |ctx| { @@ -464,7 +473,8 @@ async fn should_get_context_attribution_and_heaviest_messages_after_turn() { #[tokio::test] async fn should_update_and_clear_live_subagent_settings() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_session_state_extras", "should_update_and_clear_live_subagent_settings", |ctx| { @@ -515,7 +525,8 @@ async fn should_update_and_clear_live_subagent_settings() { #[tokio::test] async fn should_reload_session_plugins() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_session_state_extras", "should_reload_session_plugins", |ctx| { @@ -554,3 +565,5 @@ async fn should_reload_session_plugins() { ) .await; } +static E2E: super::support::SharedE2eGroup = + super::support::SharedE2eGroup::standard("rpc_session_state_extras", 11); diff --git a/rust/tests/e2e/rpc_shell_and_fleet.rs b/rust/tests/e2e/rpc_shell_and_fleet.rs index 219929c44..968d51147 100644 --- a/rust/tests/e2e/rpc_shell_and_fleet.rs +++ b/rust/tests/e2e/rpc_shell_and_fleet.rs @@ -1,10 +1,11 @@ use github_copilot_sdk::rpc::{ShellExecRequest, ShellKillRequest}; -use super::support::{wait_for_condition, with_e2e_context}; +use super::support::wait_for_condition; #[tokio::test] async fn should_execute_shell_command() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_shell_and_fleet", "should_execute_shell_command", |ctx| { @@ -41,42 +42,47 @@ async fn should_execute_shell_command() { #[tokio::test] async fn should_kill_shell_process() { - with_e2e_context("rpc_shell_and_fleet", "should_kill_shell_process", |ctx| { - Box::pin(async move { - ctx.set_default_copilot_user(); - let client = ctx.start_client().await; - let session = client - .create_session(ctx.approve_all_session_config()) - .await - .expect("create session"); + super::support::with_shared_e2e_context( + &E2E, + "rpc_shell_and_fleet", + "should_kill_shell_process", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); - let exec = session - .rpc() - .shell() - .exec(ShellExecRequest { - command: long_running_command(), - cwd: Some(ctx.work_dir().display().to_string()), - timeout: None, - }) - .await - .expect("start shell process"); - assert!(!exec.process_id.trim().is_empty()); + let exec = session + .rpc() + .shell() + .exec(ShellExecRequest { + command: long_running_command(), + cwd: Some(ctx.work_dir().display().to_string()), + timeout: None, + }) + .await + .expect("start shell process"); + assert!(!exec.process_id.trim().is_empty()); - let killed = session - .rpc() - .shell() - .kill(ShellKillRequest { - process_id: exec.process_id, - signal: None, - }) - .await - .expect("kill shell process"); - assert!(killed.killed); + let killed = session + .rpc() + .shell() + .kill(ShellKillRequest { + process_id: exec.process_id, + signal: None, + }) + .await + .expect("kill shell process"); + assert!(killed.killed); - session.disconnect().await.expect("disconnect session"); - client.stop().await.expect("stop client"); - }) - }) + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) .await; } @@ -113,3 +119,5 @@ fn long_running_command() -> String { fn long_running_command() -> String { "sleep 30".to_string() } +static E2E: super::support::SharedE2eGroup = + super::support::SharedE2eGroup::standard("rpc_shell_and_fleet", 2); diff --git a/rust/tests/e2e/rpc_shell_edge_cases.rs b/rust/tests/e2e/rpc_shell_edge_cases.rs index 318a7e500..df5ddb1dc 100644 --- a/rust/tests/e2e/rpc_shell_edge_cases.rs +++ b/rust/tests/e2e/rpc_shell_edge_cases.rs @@ -3,11 +3,12 @@ use std::time::Duration; use github_copilot_sdk::rpc::{ShellExecRequest, ShellKillRequest, ShellKillSignal}; -use super::support::{wait_for_condition, with_e2e_context}; +use super::support::wait_for_condition; #[tokio::test] async fn shell_exec_with_timeout_kills_long_running_command() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_shell_edge_cases", "shell_exec_with_timeout_kills_long_running_command", |ctx| { @@ -58,7 +59,8 @@ async fn shell_exec_with_timeout_kills_long_running_command() { #[tokio::test] async fn shell_exec_with_custom_cwd_honors_override() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_shell_edge_cases", "shell_exec_with_custom_cwd_honors_override", |ctx| { @@ -97,7 +99,8 @@ async fn shell_exec_with_custom_cwd_honors_override() { #[tokio::test] async fn shell_exec_with_nonexistent_command_returns_processid_and_cleans_up() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_shell_edge_cases", "shell_exec_with_nonexistent_command_returns_processid_and_cleans_up", |ctx| { @@ -133,7 +136,8 @@ async fn shell_exec_with_nonexistent_command_returns_processid_and_cleans_up() { #[tokio::test] async fn shell_kill_unknown_processid_returns_false() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_shell_edge_cases", "shell_kill_unknown_processid_returns_false", |ctx| { @@ -167,7 +171,8 @@ async fn shell_kill_unknown_processid_returns_false() { #[tokio::test] async fn shell_kill_cleans_up_after_terminating_signal() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_shell_edge_cases", "shell_kill_cleans_up_after_terminating_signal", |ctx| { @@ -212,7 +217,8 @@ async fn shell_kill_cleans_up_after_terminating_signal() { #[tokio::test] async fn shell_exec_with_stderr_output_cleans_up() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_shell_edge_cases", "shell_exec_with_stderr_output_cleans_up", |ctx| { @@ -249,7 +255,8 @@ async fn shell_exec_with_stderr_output_cleans_up() { #[tokio::test] async fn shell_exec_with_large_stdout_cleans_up() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_shell_edge_cases", "shell_exec_with_large_stdout_cleans_up", |ctx| { @@ -407,3 +414,5 @@ fn large_stdout_command(marker_path: &Path) -> String { marker_path.display() ) } +static E2E: super::support::SharedE2eGroup = + super::support::SharedE2eGroup::standard("rpc_shell_edge_cases", 7); diff --git a/rust/tests/e2e/rpc_shell_user_requested.rs b/rust/tests/e2e/rpc_shell_user_requested.rs index 7bd52ae9f..43de1c2cc 100644 --- a/rust/tests/e2e/rpc_shell_user_requested.rs +++ b/rust/tests/e2e/rpc_shell_user_requested.rs @@ -5,11 +5,12 @@ use std::time::Duration; use github_copilot_sdk::RequestId; use github_copilot_sdk::rpc::{ShellCancelUserRequestedRequest, ShellExecuteUserRequestedRequest}; -use super::support::{wait_for_condition, with_e2e_context}; +use super::support::wait_for_condition; #[tokio::test] async fn should_execute_user_requested_shell_command() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_shell_user_requested", "should_execute_user_requested_shell_command", |ctx| { @@ -52,7 +53,8 @@ async fn should_execute_user_requested_shell_command() { #[tokio::test] async fn should_cancel_user_requested_shell_command() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_shell_user_requested", "should_cancel_user_requested_shell_command", |ctx| { @@ -171,3 +173,5 @@ fn powershell_quote(path: &Path) -> String { fn posix_shell_quote(path: &Path) -> String { format!("'{}'", path.display().to_string().replace('\'', "'\\''")) } +static E2E: super::support::SharedE2eGroup = + super::support::SharedE2eGroup::standard("rpc_shell_user_requested", 2); diff --git a/rust/tests/e2e/rpc_tasks_and_handlers.rs b/rust/tests/e2e/rpc_tasks_and_handlers.rs index 6d15d75b4..b3010ab78 100644 --- a/rust/tests/e2e/rpc_tasks_and_handlers.rs +++ b/rust/tests/e2e/rpc_tasks_and_handlers.rs @@ -27,11 +27,10 @@ use github_copilot_sdk::rpc::{ UIUnregisterDirectAutoModeSwitchHandlerRequest, UIUserInputResponse, }; -use super::support::with_e2e_context; - #[tokio::test] async fn should_list_task_state_and_return_false_for_missing_task_operations() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_tasks_and_handlers", "should_list_task_state_and_return_false_for_missing_task_operations", |ctx| { @@ -145,7 +144,8 @@ async fn should_list_task_state_and_return_false_for_missing_task_operations() { #[tokio::test] async fn should_report_implemented_error_for_missing_task_agent_type() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_tasks_and_handlers", "should_report_implemented_error_for_missing_task_agent_type", |ctx| { @@ -182,7 +182,8 @@ async fn should_report_implemented_error_for_missing_task_agent_type() { #[tokio::test] async fn should_report_implemented_error_for_invalid_task_agent_model() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_tasks_and_handlers", "should_report_implemented_error_for_invalid_task_agent_model", |ctx| { @@ -229,7 +230,7 @@ async fn should_report_implemented_error_for_invalid_task_agent_model() { #[tokio::test] async fn should_return_expected_results_for_missing_pending_handler_requestids() { - with_e2e_context( + super::support::with_shared_e2e_context(&E2E, "rpc_tasks_and_handlers", "should_return_expected_results_for_missing_pending_handler_requestids", |ctx| { @@ -443,7 +444,8 @@ async fn should_return_expected_results_for_missing_pending_handler_requestids() #[tokio::test] async fn should_register_and_unregister_direct_auto_mode_switch_handler() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_tasks_and_handlers", "should_register_and_unregister_direct_auto_mode_switch_handler", |ctx| { @@ -503,3 +505,5 @@ fn assert_implemented_error(result: Result, met "expected implemented error for {method}, got {message}" ); } +static E2E: super::support::SharedE2eGroup = + super::support::SharedE2eGroup::standard("rpc_tasks_and_handlers", 5); diff --git a/rust/tests/e2e/rpc_ui_ephemeral_query.rs b/rust/tests/e2e/rpc_ui_ephemeral_query.rs index 83852d092..2fa421cc6 100644 --- a/rust/tests/e2e/rpc_ui_ephemeral_query.rs +++ b/rust/tests/e2e/rpc_ui_ephemeral_query.rs @@ -1,10 +1,9 @@ use github_copilot_sdk::rpc::UIEphemeralQueryRequest; -use super::support::with_e2e_context; - #[tokio::test] async fn should_answer_ephemeral_query() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_ui_ephemeral_query", "should_answer_ephemeral_query", |ctx| { @@ -36,3 +35,5 @@ async fn should_answer_ephemeral_query() { ) .await; } +static E2E: super::support::SharedE2eGroup = + super::support::SharedE2eGroup::standard("rpc_ui_ephemeral_query", 1); diff --git a/rust/tests/e2e/rpc_workspace_checkpoints.rs b/rust/tests/e2e/rpc_workspace_checkpoints.rs index 0a8bf5615..48145970c 100644 --- a/rust/tests/e2e/rpc_workspace_checkpoints.rs +++ b/rust/tests/e2e/rpc_workspace_checkpoints.rs @@ -6,11 +6,10 @@ use github_copilot_sdk::rpc::{ WorkspacesReadCheckpointRequest, WorkspacesReadFileRequest, WorkspacesSaveLargePasteRequest, }; -use super::support::with_e2e_context; - #[tokio::test] async fn should_list_no_checkpoints_for_fresh_session() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_workspace_checkpoints", "should_list_no_checkpoints_for_fresh_session", |ctx| { @@ -40,13 +39,16 @@ async fn should_list_no_checkpoints_for_fresh_session() { #[tokio::test] async fn should_return_null_or_empty_content_for_unknown_checkpoint() { - // In-process, session.workspaces.readCheckpoint is answered by the native runtime, - // which decodes the checkpoint number as a u32 and rejects the i64::MAX sentinel this - // test uses. Covered by the default (stdio) transport. See issue #1934. - if super::support::skip_inprocess("readCheckpoint decodes the id as u32 in-process") { + if super::support::skip_shared_e2e_inprocess( + &E2E, + "readCheckpoint decodes the id as u32 in-process", + ) + .await + { return; } - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_workspace_checkpoints", "should_return_null_or_empty_content_for_unknown_checkpoint", |ctx| { @@ -76,7 +78,8 @@ async fn should_return_null_or_empty_content_for_unknown_checkpoint() { #[tokio::test] async fn should_return_typed_workspace_diff_result() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_workspace_checkpoints", "should_return_typed_workspace_diff_result", |ctx| { @@ -128,7 +131,8 @@ async fn should_return_typed_workspace_diff_result() { #[tokio::test] async fn should_save_large_paste_and_expose_readable_content() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "rpc_workspace_checkpoints", "should_save_large_paste_and_expose_readable_content", |ctx| { @@ -188,3 +192,5 @@ fn init_git_repository(path: &Path) { .expect("run git init"); assert!(status.success(), "git init should succeed"); } +static E2E: super::support::SharedE2eGroup = + super::support::SharedE2eGroup::standard("rpc_workspace_checkpoints", 4); diff --git a/rust/tests/e2e/session.rs b/rust/tests/e2e/session.rs index f66c7e772..e2ca76c47 100644 --- a/rust/tests/e2e/session.rs +++ b/rust/tests/e2e/session.rs @@ -21,39 +21,44 @@ use serde_json::json; use super::support::{ assert_uuid_like, assistant_message_content, collect_until_idle, event_types, - get_system_message, get_tool_names, wait_for_condition, wait_for_event, with_e2e_context, + get_system_message, get_tool_names, wait_for_condition, wait_for_event, }; #[tokio::test] async fn shouldcreateanddisconnectsessions() { - with_e2e_context("session", "shouldcreateanddisconnectsessions", |ctx| { - Box::pin(async move { - ctx.set_default_copilot_user(); - let client = ctx.start_client().await; - let session = client - .create_session( - ctx.approve_all_session_config() - .with_model("claude-sonnet-4.5"), - ) - .await - .expect("create session"); + super::support::with_shared_e2e_context( + &E2E, + "session", + "shouldcreateanddisconnectsessions", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session( + ctx.approve_all_session_config() + .with_model("claude-sonnet-4.5"), + ) + .await + .expect("create session"); - assert_uuid_like(session.id()); - let messages = session.get_events().await.expect("get messages"); - assert!(!messages.is_empty(), "expected initial session events"); - let start = messages[0] - .typed_data::() - .expect("session.start data"); - assert_eq!(start.session_id, session.id().clone()); + assert_uuid_like(session.id()); + let messages = session.get_events().await.expect("get messages"); + assert!(!messages.is_empty(), "expected initial session events"); + let start = messages[0] + .typed_data::() + .expect("session.start data"); + assert_eq!(start.session_id, session.id().clone()); - session.disconnect().await.expect("disconnect session"); - assert!( - session.get_events().await.is_err(), - "disconnected session should no longer serve message history" - ); - client.stop().await.expect("stop client"); - }) - }) + session.disconnect().await.expect("disconnect session"); + assert!( + session.get_events().await.is_err(), + "disconnected session should no longer serve message history" + ); + client.stop().await.expect("stop client"); + }) + }, + ) .await; } @@ -88,39 +93,45 @@ async fn disposeasync_from_handler_does_not_deadlock() { #[tokio::test] async fn should_have_stateful_conversation() { - with_e2e_context("session", "should_have_stateful_conversation", |ctx| { - Box::pin(async move { - ctx.set_default_copilot_user(); - let client = ctx.start_client().await; - let session = client - .create_session(ctx.approve_all_session_config()) - .await - .expect("create session"); + super::support::with_shared_e2e_context( + &E2E, + "session", + "should_have_stateful_conversation", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); - let first = session - .send_and_wait("What is 1+1?") - .await - .expect("first send") - .expect("first assistant message"); - assert!(assistant_message_content(&first).contains('2')); + let first = session + .send_and_wait("What is 1+1?") + .await + .expect("first send") + .expect("first assistant message"); + assert!(assistant_message_content(&first).contains('2')); - let second = session - .send_and_wait("Now if you double that, what do you get?") - .await - .expect("second send") - .expect("second assistant message"); - assert!(assistant_message_content(&second).contains('4')); + let second = session + .send_and_wait("Now if you double that, what do you get?") + .await + .expect("second send") + .expect("second assistant message"); + assert!(assistant_message_content(&second).contains('4')); - session.disconnect().await.expect("disconnect session"); - client.stop().await.expect("stop client"); - }) - }) + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) .await; } #[tokio::test] async fn should_create_a_session_with_appended_systemmessage_config() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "session", "should_create_a_session_with_appended_systemmessage_config", |ctx| { @@ -164,7 +175,8 @@ async fn should_create_a_session_with_appended_systemmessage_config() { #[tokio::test] async fn should_create_a_session_with_replaced_systemmessage_config() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "session", "should_create_a_session_with_replaced_systemmessage_config", |ctx| { @@ -206,7 +218,8 @@ async fn should_create_a_session_with_replaced_systemmessage_config() { #[tokio::test] async fn should_create_a_session_with_customized_systemmessage_config() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "session", "should_create_a_session_with_customized_systemmessage_config", |ctx| { @@ -260,7 +273,8 @@ async fn should_create_a_session_with_customized_systemmessage_config() { #[tokio::test] async fn should_create_a_session_with_availabletools() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "session", "should_create_a_session_with_availabletools", |ctx| { @@ -296,7 +310,8 @@ async fn should_create_a_session_with_availabletools() { #[tokio::test] async fn should_create_a_session_with_excludedtools() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "session", "should_create_a_session_with_excludedtools", |ctx| { @@ -332,7 +347,8 @@ async fn should_create_a_session_with_excludedtools() { #[tokio::test] async fn should_create_a_session_with_defaultagent_excludedtools() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "session", "should_create_a_session_with_defaultagent_excludedtools", |ctx| { @@ -371,37 +387,43 @@ async fn should_create_a_session_with_defaultagent_excludedtools() { #[tokio::test] async fn should_create_session_with_custom_tool() { - with_e2e_context("session", "should_create_session_with_custom_tool", |ctx| { - Box::pin(async move { - ctx.set_default_copilot_user(); - let client = ctx.start_client().await; - let session = client - .create_session( - SessionConfig::default() - .with_github_token(super::support::DEFAULT_TEST_TOKEN) - .with_permission_handler(Arc::new(ApproveAllHandler)) - .with_tools(vec![secret_number_tool()]), - ) - .await - .expect("create session"); + super::support::with_shared_e2e_context( + &E2E, + "session", + "should_create_session_with_custom_tool", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session( + SessionConfig::default() + .with_github_token(super::support::DEFAULT_TEST_TOKEN) + .with_permission_handler(Arc::new(ApproveAllHandler)) + .with_tools(vec![secret_number_tool()]), + ) + .await + .expect("create session"); - let answer = session - .send_and_wait("What is the secret number for key ALPHA?") - .await - .expect("send") - .expect("assistant message"); - assert!(assistant_message_content(&answer).contains("54321")); + let answer = session + .send_and_wait("What is the secret number for key ALPHA?") + .await + .expect("send") + .expect("assistant message"); + assert!(assistant_message_content(&answer).contains("54321")); - session.disconnect().await.expect("disconnect session"); - client.stop().await.expect("stop client"); - }) - }) + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) .await; } #[tokio::test] async fn should_throw_error_when_resuming_non_existent_session() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "session", "should_throw_error_when_resuming_non_existent_session", |ctx| { @@ -425,7 +447,7 @@ async fn should_throw_error_when_resuming_non_existent_session() { #[tokio::test] async fn should_abort_a_session() { - with_e2e_context("session", "should_abort_a_session", |ctx| { + super::support::with_shared_e2e_context(&E2E, "session", "should_abort_a_session", |ctx| { Box::pin(async move { ctx.set_default_copilot_user(); let client = ctx.start_client().await; @@ -479,7 +501,8 @@ async fn should_abort_a_session() { #[tokio::test] async fn should_resume_a_session_using_the_same_client() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "session", "should_resume_a_session_using_the_same_client", |ctx| { @@ -535,7 +558,7 @@ async fn should_resume_a_session_using_the_same_client() { #[tokio::test] async fn should_resume_a_session_using_a_new_client() { - with_e2e_context( + super::support::with_dedicated_e2e_context( "session", "should_resume_a_session_using_a_new_client", |ctx| { @@ -607,7 +630,7 @@ async fn should_resume_a_session_using_a_new_client() { #[tokio::test] async fn resumes_a_persisted_session_from_a_new_client_when_an_mcp_oauth_handler_is_configured() { - with_e2e_context( + super::support::with_dedicated_e2e_context( "session", "resumes_a_persisted_session_from_a_new_client_when_an_mcp_oauth_handler_is_configured", |ctx| { @@ -661,38 +684,44 @@ async fn resumes_a_persisted_session_from_a_new_client_when_an_mcp_oauth_handler #[tokio::test] async fn should_receive_session_events() { - with_e2e_context("session", "should_receive_session_events", |ctx| { - Box::pin(async move { - ctx.set_default_copilot_user(); - let client = ctx.start_client().await; - let session = client - .create_session(ctx.approve_all_session_config()) - .await - .expect("create session"); + super::support::with_shared_e2e_context( + &E2E, + "session", + "should_receive_session_events", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); - let events = session.subscribe(); - let answer = session - .send_and_wait("What is 100+200?") - .await - .expect("send") - .expect("assistant message"); - assert!(assistant_message_content(&answer).contains("300")); - let observed = collect_until_idle(events).await; - let types = event_types(&observed); - assert!(types.contains(&"user.message")); - assert!(types.contains(&"assistant.message")); - assert!(types.contains(&"session.idle")); + let events = session.subscribe(); + let answer = session + .send_and_wait("What is 100+200?") + .await + .expect("send") + .expect("assistant message"); + assert!(assistant_message_content(&answer).contains("300")); + let observed = collect_until_idle(events).await; + let types = event_types(&observed); + assert!(types.contains(&"user.message")); + assert!(types.contains(&"assistant.message")); + assert!(types.contains(&"session.idle")); - session.disconnect().await.expect("disconnect session"); - client.stop().await.expect("stop client"); - }) - }) + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) .await; } #[tokio::test] async fn send_returns_immediately_while_events_stream_in_background() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "session", "send_returns_immediately_while_events_stream_in_background", |ctx| { @@ -731,7 +760,8 @@ async fn send_returns_immediately_while_events_stream_in_background() { #[tokio::test] async fn sendandwait_blocks_until_session_idle_and_returns_final_assistant_message() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "session", "sendandwait_blocks_until_session_idle_and_returns_final_assistant_message", |ctx| { @@ -767,127 +797,143 @@ async fn sendandwait_blocks_until_session_idle_and_returns_final_assistant_messa #[tokio::test] async fn should_list_sessions_with_context() { - with_e2e_context("session", "should_list_sessions_with_context", |ctx| { - Box::pin(async move { - ctx.set_default_copilot_user(); - let client = ctx.start_client().await; - let session = client - .create_session(ctx.approve_all_session_config()) - .await - .expect("create session"); - let session_id = session.id().clone(); - - session.send_and_wait("Say OK.").await.expect("send"); - wait_for_condition("session to appear in list", || { - let client = client.clone(); - let session_id = session_id.clone(); - async move { - client.list_sessions(None).await.is_ok_and(|sessions| { - sessions - .iter() - .any(|session| session.session_id == session_id) - }) - } - }) - .await; + super::support::with_shared_e2e_context( + &E2E, + "session", + "should_list_sessions_with_context", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + let session_id = session.id().clone(); - let all_sessions = client.list_sessions(None).await.expect("list sessions"); - assert!(!all_sessions.is_empty()); + session.send_and_wait("Say OK.").await.expect("send"); + wait_for_condition("session to appear in list", || { + let client = client.clone(); + let session_id = session_id.clone(); + async move { + client.list_sessions(None).await.is_ok_and(|sessions| { + sessions + .iter() + .any(|session| session.session_id == session_id) + }) + } + }) + .await; - session.disconnect().await.expect("disconnect session"); - client.stop().await.expect("stop client"); - }) - }) + let all_sessions = client.list_sessions(None).await.expect("list sessions"); + assert!(!all_sessions.is_empty()); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) .await; } #[tokio::test] async fn should_get_session_metadata_by_id() { - with_e2e_context("session", "should_get_session_metadata_by_id", |ctx| { - Box::pin(async move { - ctx.set_default_copilot_user(); - let client = ctx.start_client().await; - let session = client - .create_session(ctx.approve_all_session_config()) - .await - .expect("create session"); - let session_id = session.id().clone(); + super::support::with_shared_e2e_context( + &E2E, + "session", + "should_get_session_metadata_by_id", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + let session_id = session.id().clone(); + + session.send_and_wait("Say hello").await.expect("send"); + wait_for_condition("session metadata to persist", || { + let client = client.clone(); + let session_id = session_id.clone(); + async move { + client + .get_session_metadata(&session_id) + .await + .is_ok_and(|metadata| metadata.is_some()) + } + }) + .await; - session.send_and_wait("Say hello").await.expect("send"); - wait_for_condition("session metadata to persist", || { - let client = client.clone(); - let session_id = session_id.clone(); - async move { + let metadata = client + .get_session_metadata(&session_id) + .await + .expect("get metadata") + .expect("session metadata"); + assert_eq!(metadata.session_id, session_id); + assert!(!metadata.start_time.is_empty()); + assert!(!metadata.modified_time.is_empty()); + assert!( client - .get_session_metadata(&session_id) + .get_session_metadata(&github_copilot_sdk::SessionId::new( + "non-existent-session-id" + )) .await - .is_ok_and(|metadata| metadata.is_some()) - } - }) - .await; - - let metadata = client - .get_session_metadata(&session_id) - .await - .expect("get metadata") - .expect("session metadata"); - assert_eq!(metadata.session_id, session_id); - assert!(!metadata.start_time.is_empty()); - assert!(!metadata.modified_time.is_empty()); - assert!( - client - .get_session_metadata(&github_copilot_sdk::SessionId::new( - "non-existent-session-id" - )) - .await - .expect("get missing metadata") - .is_none() - ); + .expect("get missing metadata") + .is_none() + ); - session.disconnect().await.expect("disconnect session"); - client.stop().await.expect("stop client"); - }) - }) + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) .await; } #[tokio::test] async fn sendandwait_throws_on_timeout() { - with_e2e_context("session", "sendandwait_throws_on_timeout", |ctx| { - Box::pin(async move { - ctx.set_default_copilot_user(); - let client = ctx.start_client().await; - let session = client - .create_session(ctx.approve_all_session_config()) - .await - .expect("create session"); - let idle = tokio::spawn(wait_for_event( - session.subscribe(), - "session.idle after timeout abort", - |event| event.parsed_type() == SessionEventType::SessionIdle, - )); - - let error = session - .send_and_wait( - MessageOptions::new("Run 'sleep 2 && echo done'") - .with_wait_timeout(Duration::from_millis(100)), - ) - .await - .expect_err("send_and_wait should time out"); - assert!(error.to_string().contains("timed out")); + super::support::with_shared_e2e_context( + &E2E, + "session", + "sendandwait_throws_on_timeout", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + let idle = tokio::spawn(wait_for_event( + session.subscribe(), + "session.idle after timeout abort", + |event| event.parsed_type() == SessionEventType::SessionIdle, + )); + + let error = session + .send_and_wait( + MessageOptions::new("Run 'sleep 2 && echo done'") + .with_wait_timeout(Duration::from_millis(100)), + ) + .await + .expect_err("send_and_wait should time out"); + assert!(error.to_string().contains("timed out")); - session.abort().await.expect("abort session"); - idle.await.expect("idle task"); - session.disconnect().await.expect("disconnect session"); - client.stop().await.expect("stop client"); - }) - }) + session.abort().await.expect("abort session"); + idle.await.expect("idle task"); + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) .await; } #[tokio::test] async fn should_create_session_with_custom_config_dir() { - with_e2e_context( + super::support::with_dedicated_group_e2e_context( + &E2E, "session", "should_create_session_with_custom_config_dir", |ctx| { @@ -921,183 +967,198 @@ async fn should_create_session_with_custom_config_dir() { #[tokio::test] async fn should_set_model_on_existing_session() { - with_e2e_context("session", "should_set_model_on_existing_session", |ctx| { - Box::pin(async move { - ctx.set_default_copilot_user(); - let client = ctx.start_client().await; - let session = client - .create_session(ctx.approve_all_session_config()) - .await - .expect("create session"); - let model_changed = tokio::spawn(wait_for_event( - session.subscribe(), - "session.model_change", - |event| event.parsed_type() == SessionEventType::SessionModelChange, - )); - - session.set_model("gpt-4.1", None).await.expect("set model"); - let event = model_changed.await.expect("model change task"); - let data = event - .typed_data::() - .expect("session.model_change data"); - assert_eq!(data.new_model, "gpt-4.1"); - - session.disconnect().await.expect("disconnect session"); - client.stop().await.expect("stop client"); - }) - }) + super::support::with_shared_e2e_context( + &E2E, + "session", + "should_set_model_on_existing_session", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + let model_changed = tokio::spawn(wait_for_event( + session.subscribe(), + "session.model_change", + |event| event.parsed_type() == SessionEventType::SessionModelChange, + )); + + session.set_model("gpt-4.1", None).await.expect("set model"); + let event = model_changed.await.expect("model change task"); + let data = event + .typed_data::() + .expect("session.model_change data"); + assert_eq!(data.new_model, "gpt-4.1"); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) .await; } #[tokio::test] async fn should_set_model_with_reasoningeffort() { - with_e2e_context("session", "should_set_model_with_reasoningeffort", |ctx| { - Box::pin(async move { - ctx.set_default_copilot_user(); - let client = ctx.start_client().await; - let session = client - .create_session(ctx.approve_all_session_config()) - .await - .expect("create session"); - let model_changed = tokio::spawn(wait_for_event( - session.subscribe(), - "session.model_change with reasoning effort", - |event| event.parsed_type() == SessionEventType::SessionModelChange, - )); + super::support::with_dedicated_group_e2e_context( + &E2E, + "session", + "should_set_model_with_reasoningeffort", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + let model_changed = tokio::spawn(wait_for_event( + session.subscribe(), + "session.model_change with reasoning effort", + |event| event.parsed_type() == SessionEventType::SessionModelChange, + )); - session - .set_model( - "gpt-5.4", - Some(SetModelOptions::default().with_reasoning_effort("high")), - ) - .await - .expect("set model"); - let event = model_changed.await.expect("model change task"); - let data = event - .typed_data::() - .expect("session.model_change data"); - assert_eq!(data.new_model, "gpt-5.4"); - assert_eq!(data.reasoning_effort.as_deref(), Some("high")); + session + .set_model( + "gpt-5.4", + Some(SetModelOptions::default().with_reasoning_effort("high")), + ) + .await + .expect("set model"); + let event = model_changed.await.expect("model change task"); + let data = event + .typed_data::() + .expect("session.model_change data"); + assert_eq!(data.new_model, "gpt-5.4"); + assert_eq!(data.reasoning_effort.as_deref(), Some("high")); - session.disconnect().await.expect("disconnect session"); - client.stop().await.expect("stop client"); - }) - }) + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) .await; } #[tokio::test] async fn should_log_messages_at_various_levels() { - with_e2e_context("session", "should_log_messages_at_various_levels", |ctx| { - Box::pin(async move { - ctx.set_default_copilot_user(); - let client = ctx.start_client().await; - let session = client - .create_session(ctx.approve_all_session_config()) - .await - .expect("create session"); - let mut events = session.subscribe(); + super::support::with_shared_e2e_context( + &E2E, + "session", + "should_log_messages_at_various_levels", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + let mut events = session.subscribe(); - session.log("Info message", None).await.expect("info log"); - session - .log( - "Warning message", - Some(LogOptions::default().with_level(SessionLogLevel::Warning)), - ) - .await - .expect("warning log"); - session - .log( - "Error message", - Some(LogOptions::default().with_level(SessionLogLevel::Error)), - ) - .await - .expect("error log"); - session - .log( - "Ephemeral message", - Some(LogOptions::default().with_ephemeral(true)), - ) - .await - .expect("ephemeral log"); - - let mut observed = Vec::new(); - tokio::time::timeout(Duration::from_secs(10), async { - while observed.len() < 4 { - let event = events.recv().await.expect("session event"); - if matches!( - event.parsed_type(), - SessionEventType::SessionInfo - | SessionEventType::SessionWarning - | SessionEventType::SessionError - ) { - observed.push(event); + session.log("Info message", None).await.expect("info log"); + session + .log( + "Warning message", + Some(LogOptions::default().with_level(SessionLogLevel::Warning)), + ) + .await + .expect("warning log"); + session + .log( + "Error message", + Some(LogOptions::default().with_level(SessionLogLevel::Error)), + ) + .await + .expect("error log"); + session + .log( + "Ephemeral message", + Some(LogOptions::default().with_ephemeral(true)), + ) + .await + .expect("ephemeral log"); + + let mut observed = Vec::new(); + tokio::time::timeout(Duration::from_secs(10), async { + while observed.len() < 4 { + let event = events.recv().await.expect("session event"); + if matches!( + event.parsed_type(), + SessionEventType::SessionInfo + | SessionEventType::SessionWarning + | SessionEventType::SessionError + ) { + observed.push(event); + } } - } - }) - .await - .expect("log events"); - - let info = observed - .iter() - .find(|event| { - event - .typed_data::() - .is_some_and(|data| data.message == "Info message") }) - .expect("info message"); - assert_eq!( - info.typed_data::() - .expect("info data") - .info_type, - "notification" - ); - let warning = observed - .iter() - .find(|event| { - event + .await + .expect("log events"); + + let info = observed + .iter() + .find(|event| { + event + .typed_data::() + .is_some_and(|data| data.message == "Info message") + }) + .expect("info message"); + assert_eq!( + info.typed_data::() + .expect("info data") + .info_type, + "notification" + ); + let warning = observed + .iter() + .find(|event| { + event + .typed_data::() + .is_some_and(|data| data.message == "Warning message") + }) + .expect("warning message"); + assert_eq!( + warning .typed_data::() - .is_some_and(|data| data.message == "Warning message") - }) - .expect("warning message"); - assert_eq!( - warning - .typed_data::() - .expect("warning data") - .warning_type, - "notification" - ); - let error = observed - .iter() - .find(|event| { - event + .expect("warning data") + .warning_type, + "notification" + ); + let error = observed + .iter() + .find(|event| { + event + .typed_data::() + .is_some_and(|data| data.message == "Error message") + }) + .expect("error message"); + assert_eq!( + error .typed_data::() - .is_some_and(|data| data.message == "Error message") - }) - .expect("error message"); - assert_eq!( - error - .typed_data::() - .expect("error data") - .error_type, - "notification" - ); - assert!(observed.iter().any(|event| { - event - .typed_data::() - .is_some_and(|data| data.message == "Ephemeral message") - })); + .expect("error data") + .error_type, + "notification" + ); + assert!(observed.iter().any(|event| { + event + .typed_data::() + .is_some_and(|data| data.message == "Ephemeral message") + })); - session.disconnect().await.expect("disconnect session"); - client.stop().await.expect("stop client"); - }) - }) + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) .await; } #[tokio::test] async fn should_accept_blob_attachments() { - with_e2e_context("session", "should_accept_blob_attachments", |ctx| { + super::support::with_shared_e2e_context(&E2E, "session", "should_accept_blob_attachments", |ctx| { Box::pin(async move { ctx.set_default_copilot_user(); let png_base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="; @@ -1139,197 +1200,213 @@ async fn should_accept_blob_attachments() { #[tokio::test] async fn should_send_with_file_attachment() { - with_e2e_context("session", "should_send_with_file_attachment", |ctx| { - Box::pin(async move { - ctx.set_default_copilot_user(); - let file_path = ctx.work_dir().join("attached-file.txt"); - std::fs::write(&file_path, "FILE_ATTACHMENT_SENTINEL").expect("write attached file"); - let client = ctx.start_client().await; - let session = client - .create_session(ctx.approve_all_session_config()) - .await - .expect("create session"); + super::support::with_shared_e2e_context( + &E2E, + "session", + "should_send_with_file_attachment", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let file_path = ctx.work_dir().join("attached-file.txt"); + std::fs::write(&file_path, "FILE_ATTACHMENT_SENTINEL") + .expect("write attached file"); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); - session - .send_and_wait( - MessageOptions::new("Read the attached file and reply with its contents.") - .with_attachments(vec![Attachment::File { - path: file_path.clone(), - display_name: Some("attached-file.txt".to_string()), - line_range: Some(AttachmentLineRange { start: 1, end: 1 }), - }]), - ) - .await - .expect("send"); + session + .send_and_wait( + MessageOptions::new("Read the attached file and reply with its contents.") + .with_attachments(vec![Attachment::File { + path: file_path.clone(), + display_name: Some("attached-file.txt".to_string()), + line_range: Some(AttachmentLineRange { start: 1, end: 1 }), + }]), + ) + .await + .expect("send"); - let user = latest_user_message(&session).await; - let attachments = user - .typed_data::() - .expect("user message data") - .attachments - .expect("attachments"); - assert_eq!(attachments.len(), 1); - assert_eq!( - attachments[0] - .get("displayName") - .and_then(serde_json::Value::as_str), - Some("attached-file.txt") - ); - assert_eq!( - attachments[0] - .get("path") - .and_then(serde_json::Value::as_str), - Some(file_path.to_string_lossy().as_ref()) - ); - assert_eq!( - attachments[0] - .get("lineRange") - .and_then(|value| value.get("start")) - .and_then(serde_json::Value::as_u64), - Some(1) - ); + let user = latest_user_message(&session).await; + let attachments = user + .typed_data::() + .expect("user message data") + .attachments + .expect("attachments"); + assert_eq!(attachments.len(), 1); + assert_eq!( + attachments[0] + .get("displayName") + .and_then(serde_json::Value::as_str), + Some("attached-file.txt") + ); + assert_eq!( + attachments[0] + .get("path") + .and_then(serde_json::Value::as_str), + Some(file_path.to_string_lossy().as_ref()) + ); + assert_eq!( + attachments[0] + .get("lineRange") + .and_then(|value| value.get("start")) + .and_then(serde_json::Value::as_u64), + Some(1) + ); - session.disconnect().await.expect("disconnect session"); - client.stop().await.expect("stop client"); - }) - }) + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) .await; } #[tokio::test] async fn should_send_with_directory_attachment() { - with_e2e_context("session", "should_send_with_directory_attachment", |ctx| { - Box::pin(async move { - ctx.set_default_copilot_user(); - let directory_path = ctx.work_dir().join("attached-directory"); - std::fs::create_dir(&directory_path).expect("create attached directory"); - std::fs::write( - directory_path.join("readme.txt"), - "DIRECTORY_ATTACHMENT_SENTINEL", - ) - .expect("write attached directory file"); - let client = ctx.start_client().await; - let session = client - .create_session(ctx.approve_all_session_config()) - .await - .expect("create session"); - - session - .send_and_wait( - MessageOptions::new("List the attached directory.").with_attachments(vec![ - Attachment::Directory { - path: directory_path.clone(), - display_name: Some("attached-directory".to_string()), - }, - ]), + super::support::with_shared_e2e_context( + &E2E, + "session", + "should_send_with_directory_attachment", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let directory_path = ctx.work_dir().join("attached-directory"); + std::fs::create_dir(&directory_path).expect("create attached directory"); + std::fs::write( + directory_path.join("readme.txt"), + "DIRECTORY_ATTACHMENT_SENTINEL", ) - .await - .expect("send"); + .expect("write attached directory file"); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); - let user = latest_user_message(&session).await; - let attachments = user - .typed_data::() - .expect("user message data") - .attachments - .expect("attachments"); - assert_eq!(attachments.len(), 1); - assert_eq!( - attachments[0] - .get("displayName") - .and_then(serde_json::Value::as_str), - Some("attached-directory") - ); - assert_eq!( - attachments[0] - .get("path") - .and_then(serde_json::Value::as_str), - Some(directory_path.to_string_lossy().as_ref()) - ); + session + .send_and_wait( + MessageOptions::new("List the attached directory.").with_attachments(vec![ + Attachment::Directory { + path: directory_path.clone(), + display_name: Some("attached-directory".to_string()), + }, + ]), + ) + .await + .expect("send"); - session.disconnect().await.expect("disconnect session"); - client.stop().await.expect("stop client"); - }) - }) + let user = latest_user_message(&session).await; + let attachments = user + .typed_data::() + .expect("user message data") + .attachments + .expect("attachments"); + assert_eq!(attachments.len(), 1); + assert_eq!( + attachments[0] + .get("displayName") + .and_then(serde_json::Value::as_str), + Some("attached-directory") + ); + assert_eq!( + attachments[0] + .get("path") + .and_then(serde_json::Value::as_str), + Some(directory_path.to_string_lossy().as_ref()) + ); + + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) .await; } #[tokio::test] async fn should_send_with_selection_attachment() { - with_e2e_context("session", "should_send_with_selection_attachment", |ctx| { - Box::pin(async move { - ctx.set_default_copilot_user(); - let file_path = std::path::PathBuf::from("selected-file.cs"); - let absolute_file_path = ctx.work_dir().join(&file_path); - std::fs::write( - &absolute_file_path, - "class C { string Value = \"SELECTION_SENTINEL\"; }", - ) - .expect("write selection file"); - let client = ctx.start_client().await; - let session = client - .create_session(ctx.approve_all_session_config()) - .await - .expect("create session"); + super::support::with_shared_e2e_context( + &E2E, + "session", + "should_send_with_selection_attachment", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let file_path = std::path::PathBuf::from("selected-file.cs"); + let absolute_file_path = ctx.work_dir().join(&file_path); + std::fs::write( + &absolute_file_path, + "class C { string Value = \"SELECTION_SENTINEL\"; }", + ) + .expect("write selection file"); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); - session - .send_and_wait( - MessageOptions::new("Summarize the selected code.").with_attachments(vec![ - Attachment::Selection { - file_path: file_path.clone(), - text: "string Value = \"SELECTION_SENTINEL\";".to_string(), - display_name: Some("selected-file.cs".to_string()), - selection: AttachmentSelectionRange { - start: AttachmentSelectionPosition { - line: 1, - character: 10, - }, - end: AttachmentSelectionPosition { - line: 1, - character: 45, + session + .send_and_wait( + MessageOptions::new("Summarize the selected code.").with_attachments(vec![ + Attachment::Selection { + file_path: file_path.clone(), + text: "string Value = \"SELECTION_SENTINEL\";".to_string(), + display_name: Some("selected-file.cs".to_string()), + selection: AttachmentSelectionRange { + start: AttachmentSelectionPosition { + line: 1, + character: 10, + }, + end: AttachmentSelectionPosition { + line: 1, + character: 45, + }, }, }, - }, - ]), - ) - .await - .expect("send"); + ]), + ) + .await + .expect("send"); - let user = latest_user_message(&session).await; - let attachment = user - .typed_data::() - .expect("user message data") - .attachments - .expect("attachments") - .into_iter() - .next() - .expect("attachment"); - assert_eq!( - attachment - .get("displayName") - .and_then(serde_json::Value::as_str), - Some("selected-file.cs") - ); - assert_eq!( - attachment - .get("filePath") - .and_then(serde_json::Value::as_str), - Some(file_path.to_string_lossy().as_ref()) - ); - assert_eq!( - attachment.get("text").and_then(serde_json::Value::as_str), - Some("string Value = \"SELECTION_SENTINEL\";") - ); + let user = latest_user_message(&session).await; + let attachment = user + .typed_data::() + .expect("user message data") + .attachments + .expect("attachments") + .into_iter() + .next() + .expect("attachment"); + assert_eq!( + attachment + .get("displayName") + .and_then(serde_json::Value::as_str), + Some("selected-file.cs") + ); + assert_eq!( + attachment + .get("filePath") + .and_then(serde_json::Value::as_str), + Some(file_path.to_string_lossy().as_ref()) + ); + assert_eq!( + attachment.get("text").and_then(serde_json::Value::as_str), + Some("string Value = \"SELECTION_SENTINEL\";") + ); - session.disconnect().await.expect("disconnect session"); - client.stop().await.expect("stop client"); - }) - }) + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) .await; } #[tokio::test] async fn should_send_with_github_reference_attachment() { - with_e2e_context( + super::support::with_shared_e2e_context(&E2E, "session", "should_send_with_github_reference_attachment", |ctx| { @@ -1394,101 +1471,114 @@ async fn should_send_with_github_reference_attachment() { #[tokio::test] async fn should_send_with_custom_requestheaders() { - with_e2e_context("session", "should_send_with_custom_requestheaders", |ctx| { - Box::pin(async move { - ctx.set_default_copilot_user(); - let client = ctx.start_client().await; - let session = client - .create_session(ctx.approve_all_session_config()) - .await - .expect("create session"); - let mut headers = HashMap::new(); - headers.insert( - "x-copilot-sdk-test-header".to_string(), - "csharp-request-headers".to_string(), - ); + super::support::with_shared_e2e_context( + &E2E, + "session", + "should_send_with_custom_requestheaders", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); + let mut headers = HashMap::new(); + headers.insert( + "x-copilot-sdk-test-header".to_string(), + "csharp-request-headers".to_string(), + ); - session - .send_and_wait(MessageOptions::new("What is 1+1?").with_request_headers(headers)) - .await - .expect("send"); + session + .send_and_wait( + MessageOptions::new("What is 1+1?").with_request_headers(headers), + ) + .await + .expect("send"); - let exchanges = ctx.exchanges(); - assert!(!exchanges.is_empty(), "expected captured CAPI exchange"); - let request_headers = exchanges - .last() - .and_then(|exchange| exchange.get("requestHeaders")) - .and_then(serde_json::Value::as_object) - .expect("request headers"); - let header = request_headers - .iter() - .find(|(key, _)| key.eq_ignore_ascii_case("x-copilot-sdk-test-header")) - .and_then(|(_, value)| value.as_str()) - .expect("test header"); - assert!(header.contains("csharp-request-headers")); + let exchanges = ctx.exchanges(); + assert!(!exchanges.is_empty(), "expected captured CAPI exchange"); + let request_headers = exchanges + .last() + .and_then(|exchange| exchange.get("requestHeaders")) + .and_then(serde_json::Value::as_object) + .expect("request headers"); + let header = request_headers + .iter() + .find(|(key, _)| key.eq_ignore_ascii_case("x-copilot-sdk-test-header")) + .and_then(|(_, value)| value.as_str()) + .expect("test header"); + assert!(header.contains("csharp-request-headers")); - session.disconnect().await.expect("disconnect session"); - client.stop().await.expect("stop client"); - }) - }) + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) .await; } #[tokio::test] async fn should_send_with_mode_property() { - with_e2e_context("session", "should_send_with_mode_property", |ctx| { - Box::pin(async move { - ctx.set_default_copilot_user(); - let client = ctx.start_client().await; - let session = client - .create_session(ctx.approve_all_session_config()) - .await - .expect("create session"); + super::support::with_shared_e2e_context( + &E2E, + "session", + "should_send_with_mode_property", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let session = client + .create_session(ctx.approve_all_session_config()) + .await + .expect("create session"); - session - .client() - .call( - "session.send", - Some(json!({ - "sessionId": session.id().as_str(), - "prompt": "Say mode ok.", - "mode": "plan", - })), - ) - .await - .expect("send with agent mode"); - wait_for_event(session.subscribe(), "session.idle", |event| { - event.parsed_type() == SessionEventType::SessionIdle - }) - .await; + session + .client() + .call( + "session.send", + Some(json!({ + "sessionId": session.id().as_str(), + "prompt": "Say mode ok.", + "mode": "plan", + })), + ) + .await + .expect("send with agent mode"); + wait_for_event(session.subscribe(), "session.idle", |event| { + event.parsed_type() == SessionEventType::SessionIdle + }) + .await; - let user_message = session - .get_events() - .await - .expect("get messages") - .into_iter() - .rev() - .find(|event| event.parsed_type() == SessionEventType::UserMessage) - .expect("user.message"); - let data = user_message - .typed_data::() - .expect("user.message data"); - assert_eq!(data.content, "Say mode ok."); - assert!( - data.agent_mode.is_none(), - "runtime should accept but not echo per-message mode" - ); + let user_message = session + .get_events() + .await + .expect("get messages") + .into_iter() + .rev() + .find(|event| event.parsed_type() == SessionEventType::UserMessage) + .expect("user.message"); + let data = user_message + .typed_data::() + .expect("user.message data"); + assert_eq!(data.content, "Say mode ok."); + assert!( + data.agent_mode.is_none(), + "runtime should accept but not echo per-message mode" + ); - session.disconnect().await.expect("disconnect session"); - client.stop().await.expect("stop client"); - }) - }) + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) .await; } #[tokio::test] async fn should_create_session_with_custom_provider() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "session", "should_create_session_with_custom_provider", |ctx| { @@ -1515,7 +1605,8 @@ async fn should_create_session_with_custom_provider() { #[tokio::test] async fn should_create_session_with_azure_provider() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "session", "should_create_session_with_azure_provider", |ctx| { @@ -1545,7 +1636,8 @@ async fn should_create_session_with_azure_provider() { #[tokio::test] async fn should_resume_session_with_custom_provider() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "session", "should_resume_session_with_custom_provider", |ctx| { @@ -1659,3 +1751,5 @@ fn secret_number_tool() -> Tool { })) .with_handler(Arc::new(SecretNumberTool)) } +static E2E: super::support::SharedE2eGroup = + super::support::SharedE2eGroup::standard("session", 30); diff --git a/rust/tests/e2e/session_config.rs b/rust/tests/e2e/session_config.rs index dd498e376..c3f6b57ae 100644 --- a/rust/tests/e2e/session_config.rs +++ b/rust/tests/e2e/session_config.rs @@ -15,9 +15,10 @@ use http::{HeaderMap, HeaderValue}; use parking_lot::Mutex; use serde_json::{Value, json}; -use super::support::{ - DEFAULT_TEST_TOKEN, E2eContext, with_e2e_context, with_e2e_context_no_snapshot, -}; +use super::support::{DEFAULT_TEST_TOKEN, E2eContext, with_e2e_context_no_snapshot}; + +static E2E: super::support::SharedE2eGroup = + super::support::SharedE2eGroup::standard("session_config", 4); const SYNTHETIC_TEXT: &str = "OK from the synthetic stream."; const CITATION_PROMPT: &str = "Summarize the attached PDF with citations enabled."; @@ -90,7 +91,8 @@ fn task_agent_types(exchange: &Value) -> Vec { #[tokio::test] async fn should_apply_session_limits_on_create() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "session_config", "should_apply_session_limits_on_create", |ctx| { @@ -123,7 +125,8 @@ async fn should_apply_session_limits_on_create() { #[tokio::test] async fn should_apply_session_limits_on_resume() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "session_config", "should_apply_session_limits_on_resume", |ctx| { @@ -169,7 +172,8 @@ async fn should_apply_session_limits_on_resume() { #[tokio::test] async fn should_apply_excluded_built_in_agents_on_create() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "session_config", "should_apply_excluded_built_in_agents_on_create", |ctx| { @@ -222,7 +226,8 @@ async fn should_apply_excluded_built_in_agents_on_create() { #[tokio::test] async fn should_apply_excluded_built_in_agents_on_resume() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "session_config", "should_apply_excluded_built_in_agents_on_resume", |ctx| { diff --git a/rust/tests/e2e/session_fs_sqlite.rs b/rust/tests/e2e/session_fs_sqlite.rs index 595a2c6b0..8ba712bb4 100644 --- a/rust/tests/e2e/session_fs_sqlite.rs +++ b/rust/tests/e2e/session_fs_sqlite.rs @@ -4,14 +4,14 @@ use std::sync::{Arc, Mutex}; use async_trait::async_trait; use github_copilot_sdk::session_fs::{FsError, FsErrorKind}; use github_copilot_sdk::{ - Client, DirEntry, DirEntryKind, FileInfo, SessionConfig, SessionFsCapabilities, - SessionFsConfig, SessionFsConventions, SessionFsProvider, SessionFsSqliteProvider, - SessionFsSqliteQueryResult, SessionFsSqliteQueryType, SessionFsSqliteTransactionError, - SessionFsSqliteTransactionStatement, + DirEntry, DirEntryKind, FileInfo, SessionConfig, SessionFsCapabilities, SessionFsConfig, + SessionFsConventions, SessionFsProvider, SessionFsSqliteProvider, SessionFsSqliteQueryResult, + SessionFsSqliteQueryType, SessionFsSqliteTransactionError, SessionFsSqliteTransactionStatement, }; use rusqlite::Connection; -use super::support::with_e2e_context; +static E2E: super::support::SharedE2eGroup = + super::support::SharedE2eGroup::new("session_fs_sqlite", sqlite_client_options, 2); #[derive(Debug)] struct SqliteCall { @@ -391,13 +391,12 @@ fn sqlite_session_fs_config() -> SessionFsConfig { .with_capabilities(SessionFsCapabilities::new().with_sqlite(true)) } -async fn start_sqlite_client(ctx: &super::support::E2eContext) -> Client { - Client::start( - ctx.client_options() - .with_session_fs(sqlite_session_fs_config()), - ) - .await - .expect("start sqlite client") +fn sqlite_client_options( + context: &super::support::E2eContext, +) -> github_copilot_sdk::ClientOptions { + context + .client_options() + .with_session_fs(sqlite_session_fs_config()) } fn sqlite_session_config( @@ -410,7 +409,8 @@ fn sqlite_session_config( #[tokio::test] async fn should_route_sql_queries_through_the_sessionfs_sqlite_handler() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "session_fs_sqlite", "should_route_sql_queries_through_the_sessionfs_sqlite_handler", |ctx| { @@ -422,7 +422,7 @@ async fn should_route_sql_queries_through_the_sessionfs_sqlite_handler() { session_id, sqlite_calls.clone(), )); - let client = start_sqlite_client(ctx).await; + let client = ctx.start_client().await; let session = client .create_session( sqlite_session_config(ctx, provider).with_session_id(session_id), @@ -480,7 +480,8 @@ async fn should_route_sql_queries_through_the_sessionfs_sqlite_handler() { #[tokio::test] async fn should_allow_subagents_to_use_sql_tool_via_inherited_sessionfs() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "session_fs_sqlite", "should_allow_subagents_to_use_sql_tool_via_inherited_sessionfs", |ctx| { @@ -490,7 +491,7 @@ async fn should_allow_subagents_to_use_sql_tool_via_inherited_sessionfs() { let sqlite_calls = Arc::new(Mutex::new(Vec::new())); let provider = Arc::new(InMemorySqliteProvider::new(session_id, sqlite_calls.clone())); let provider_ref = provider.clone(); - let client = start_sqlite_client(ctx).await; + let client = ctx.start_client().await; let session = client .create_session( sqlite_session_config(ctx, provider).with_session_id(session_id), diff --git a/rust/tests/e2e/session_lifecycle.rs b/rust/tests/e2e/session_lifecycle.rs index 24938776f..545bb4988 100644 --- a/rust/tests/e2e/session_lifecycle.rs +++ b/rust/tests/e2e/session_lifecycle.rs @@ -2,12 +2,12 @@ use github_copilot_sdk::session_events::SessionEventType; use super::support::{ assistant_message_content, collect_until_idle, event_types, wait_for_condition, - with_e2e_context, }; #[tokio::test] async fn should_list_created_sessions_after_sending_a_message() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "session_lifecycle", "should_list_created_sessions_after_sending_a_message", |ctx| { @@ -59,7 +59,8 @@ async fn should_list_created_sessions_after_sending_a_message() { #[tokio::test] async fn should_delete_session_permanently() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "session_lifecycle", "should_delete_session_permanently", |ctx| { @@ -103,7 +104,8 @@ async fn should_delete_session_permanently() { #[tokio::test] async fn should_return_events_via_getmessages_after_conversation() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "session_lifecycle", "should_return_events_via_getmessages_after_conversation", |ctx| { @@ -136,7 +138,8 @@ async fn should_return_events_via_getmessages_after_conversation() { #[tokio::test] async fn should_support_multiple_concurrent_sessions() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "session_lifecycle", "should_support_multiple_concurrent_sessions", |ctx| { @@ -180,7 +183,8 @@ async fn should_support_multiple_concurrent_sessions() { #[tokio::test] async fn should_isolate_events_between_concurrent_sessions() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "session_lifecycle", "should_isolate_events_between_concurrent_sessions", |ctx| { @@ -255,3 +259,5 @@ async fn should_isolate_events_between_concurrent_sessions() { ) .await; } +static E2E: super::support::SharedE2eGroup = + super::support::SharedE2eGroup::standard("session_lifecycle", 5); diff --git a/rust/tests/e2e/session_todos_changed.rs b/rust/tests/e2e/session_todos_changed.rs index ebace39b3..4b6245206 100644 --- a/rust/tests/e2e/session_todos_changed.rs +++ b/rust/tests/e2e/session_todos_changed.rs @@ -1,6 +1,6 @@ use github_copilot_sdk::session_events::SessionEventType; -use super::support::{wait_for_event, with_e2e_context}; +use super::support::wait_for_event; const PROMPT: &str = concat!( "Use the sql tool exactly once to execute all three of the following statements ", @@ -14,7 +14,8 @@ const PROMPT: &str = concat!( #[tokio::test] async fn fires_session_todos_changed_and_exposes_rows_and_dependencies() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "session_todos_changed", "fires_session_todos_changed_and_exposes_rows_and_dependencies", |ctx| { @@ -59,3 +60,5 @@ async fn fires_session_todos_changed_and_exposes_rows_and_dependencies() { ) .await; } +static E2E: super::support::SharedE2eGroup = + super::support::SharedE2eGroup::standard("session_todos_changed", 1); diff --git a/rust/tests/e2e/skills.rs b/rust/tests/e2e/skills.rs index e0005ddf0..769b28b5f 100644 --- a/rust/tests/e2e/skills.rs +++ b/rust/tests/e2e/skills.rs @@ -2,13 +2,14 @@ use std::path::{Path, PathBuf}; use github_copilot_sdk::CustomAgentConfig; -use super::support::{assert_uuid_like, assistant_message_content, with_e2e_context}; +use super::support::{assert_uuid_like, assistant_message_content}; const SKILL_MARKER: &str = "PINEAPPLE_COCONUT_42"; #[tokio::test] async fn should_load_and_apply_skill_from_skilldirectories() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "skills", "should_load_and_apply_skill_from_skilldirectories", |ctx| { @@ -42,7 +43,8 @@ async fn should_load_and_apply_skill_from_skilldirectories() { #[tokio::test] async fn should_not_apply_skill_when_disabled_via_disabledskills() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "skills", "should_not_apply_skill_when_disabled_via_disabledskills", |ctx| { @@ -77,7 +79,8 @@ async fn should_not_apply_skill_when_disabled_via_disabledskills() { #[tokio::test] async fn should_allow_agent_with_skills_to_invoke_skill() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "skills", "should_allow_agent_with_skills_to_invoke_skill", |ctx| { @@ -118,7 +121,8 @@ async fn should_allow_agent_with_skills_to_invoke_skill() { #[tokio::test] async fn should_not_provide_skills_to_agent_without_skills_field() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "skills", "should_not_provide_skills_to_agent_without_skills_field", |ctx| { @@ -176,3 +180,4 @@ fn create_skill_dir(work_dir: &Path) -> PathBuf { .expect("write skill file"); skills_dir } +static E2E: super::support::SharedE2eGroup = super::support::SharedE2eGroup::standard("skills", 4); diff --git a/rust/tests/e2e/streaming_fidelity.rs b/rust/tests/e2e/streaming_fidelity.rs index 5a21a31d6..a48177174 100644 --- a/rust/tests/e2e/streaming_fidelity.rs +++ b/rust/tests/e2e/streaming_fidelity.rs @@ -7,11 +7,12 @@ use github_copilot_sdk::session_events::{ SessionStartData, }; -use super::support::{collect_until_idle, event_types, with_e2e_context}; +use super::support::{collect_until_idle, event_types}; #[tokio::test] async fn should_produce_delta_events_when_streaming_is_enabled() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "streaming_fidelity", "should_produce_delta_events_when_streaming_is_enabled", |ctx| { @@ -65,7 +66,7 @@ async fn should_produce_delta_events_when_streaming_is_enabled() { #[tokio::test] async fn should_not_produce_deltas_when_streaming_is_disabled() { - with_e2e_context( + super::support::with_shared_e2e_context(&E2E, "streaming_fidelity", "should_not_produce_deltas_when_streaming_is_disabled", |ctx| { @@ -107,7 +108,7 @@ async fn should_not_produce_deltas_when_streaming_is_disabled() { #[tokio::test] async fn should_produce_deltas_after_session_resume() { - with_e2e_context( + super::support::with_dedicated_e2e_context( "streaming_fidelity", "should_produce_deltas_after_session_resume", |ctx| { @@ -164,8 +165,7 @@ async fn should_produce_deltas_after_session_resume() { #[tokio::test] async fn should_not_produce_deltas_after_session_resume_with_streaming_disabled() { - with_e2e_context( - "streaming_fidelity", + super::support::with_dedicated_e2e_context("streaming_fidelity", "should_not_produce_deltas_after_session_resume_with_streaming_disabled", |ctx| { Box::pin(async move { @@ -227,7 +227,8 @@ async fn should_not_produce_deltas_after_session_resume_with_streaming_disabled( #[tokio::test] async fn should_emit_streaming_deltas_with_reasoning_effort_configured() { - with_e2e_context( + super::support::with_dedicated_group_e2e_context( + &E2E, "streaming_fidelity", "should_emit_streaming_deltas_with_reasoning_effort_configured", |ctx| { @@ -280,7 +281,8 @@ async fn should_emit_streaming_deltas_with_reasoning_effort_configured() { #[tokio::test] async fn should_emit_assistantmessage_start_before_deltas_with_matching_messageid() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "streaming_fidelity", "should_emit_assistantmessagestart_before_deltas_with_matching_messageid", |ctx| { @@ -362,3 +364,5 @@ fn assert_has_content_deltas(events: &[github_copilot_sdk::SessionEvent]) { assert!(!data.delta_content.is_empty()); } } +static E2E: super::support::SharedE2eGroup = + super::support::SharedE2eGroup::standard("streaming_fidelity", 3); diff --git a/rust/tests/e2e/support.rs b/rust/tests/e2e/support.rs index 4d9de5536..d65b049f9 100644 --- a/rust/tests/e2e/support.rs +++ b/rust/tests/e2e/support.rs @@ -1,13 +1,17 @@ use std::ffi::{OsStr, OsString}; use std::future::Future; use std::io::{BufRead, BufReader, Read, Write}; -use std::net::TcpStream; +use std::net::{TcpStream, ToSocketAddrs}; +use std::ops::Deref; +use std::panic::AssertUnwindSafe; use std::path::{Path, PathBuf}; use std::pin::Pin; use std::process::{Child, Command, Stdio}; use std::sync::LazyLock; -use std::time::Duration; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::{Duration, Instant}; +use futures_util::FutureExt; use github_copilot_sdk::handler::ApproveAllHandler; use github_copilot_sdk::session::Session; use github_copilot_sdk::subscription::{EventSubscription, LifecycleSubscription}; @@ -16,15 +20,280 @@ use github_copilot_sdk::{ SessionId, SessionLifecycleEvent, Transport, }; use serde_json::json; -use tokio::sync::Semaphore; +use tokio::sync::{Mutex, Semaphore}; static E2E_CONCURRENCY: LazyLock = LazyLock::new(|| Semaphore::new(e2e_concurrency())); +static SHARED_E2E_RUNTIME: LazyLock = LazyLock::new(|| { + tokio::runtime::Builder::new_multi_thread() + .enable_all() + .thread_name("rust-e2e-shared") + .build() + .expect("create shared E2E runtime") +}); +const SHARED_E2E_CLEANUP_TIMEOUT: Duration = Duration::from_secs(10); pub const DEFAULT_TEST_TOKEN: &str = "rust-e2e-token"; type TestFuture<'a> = Pin + 'a>>; -pub async fn with_e2e_context(category: &str, snapshot_name: &str, test: F) +/// Fixed client options for one explicitly declared shared E2E group. +pub type SharedClientOptions = fn(&E2eContext) -> ClientOptions; + +/// A file- or group-scoped shared E2E runtime. +/// +/// This deliberately has no options-keyed registry: every Rust source group owns +/// its own static instance and selects its options at that declaration site. +pub struct SharedE2eGroup { + category: &'static str, + client_options: SharedClientOptions, + expected_invocations: usize, + completed_invocations: AtomicUsize, + state: Mutex>, +} + +struct SharedE2eState { + context: E2eContext, + client: Client, +} + +/// Test facade over a group's shared context and client. +/// +/// It dereferences to [`E2eContext`] for proxy and fixture helpers, while +/// [`Self::start_client`] returns a clone of the group's already-started client. +pub struct SharedE2eContext<'a> { + context: &'a mut E2eContext, + client: Client, +} + +/// A clone of a group's shared client. +/// +/// `stop` is deliberately a no-op: tests retain their existing local teardown +/// shape without shutting down the next test's runtime. The group stops the +/// actual client after its final expected invocation. Tests that verify +/// stopping or force-stopping a client stay on the dedicated helper. +#[derive(Clone)] +pub struct SharedE2eClient(Client); + +impl Deref for SharedE2eClient { + type Target = Client; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl SharedE2eClient { + pub async fn stop(&self) -> std::result::Result<(), github_copilot_sdk::StopErrors> { + Ok(()) + } +} + +impl Deref for SharedE2eContext<'_> { + type Target = E2eContext; + + fn deref(&self) -> &Self::Target { + self.context + } +} + +impl SharedE2eContext<'_> { + /// Clone the group client. Shared tests must not call `Client::stop`; the + /// group tears it down after its final expected test invocation. + pub async fn start_client(&self) -> SharedE2eClient { + SharedE2eClient(self.client.clone()) + } +} + +impl SharedE2eGroup { + pub const fn new( + category: &'static str, + client_options: SharedClientOptions, + expected_invocations: usize, + ) -> Self { + Self { + category, + client_options, + expected_invocations, + completed_invocations: AtomicUsize::new(0), + state: Mutex::const_new(None), + } + } + + pub const fn standard(category: &'static str, expected_invocations: usize) -> Self { + Self::new( + category, + standard_shared_client_options, + expected_invocations, + ) + } +} + +/// The standard stdio/default-transport options used by most shared groups. +pub fn standard_shared_client_options(context: &E2eContext) -> ClientOptions { + context.client_options() +} + +/// Run a test against an explicitly declared, file/group-scoped shared client. +/// +/// Calls using one group serialize, while different groups still use the suite +/// concurrency limit. Before and after every test, sessions are disconnected and +/// deleted, the work directory is emptied, and the proxy is reconfigured for the +/// test's snapshot so exchanges cannot bleed across tests. After the declared +/// number of invocations completes, the group's client and proxy are stopped. +pub async fn with_shared_e2e_context( + group: &'static SharedE2eGroup, + category: &str, + snapshot_name: &str, + test: F, +) where + F: for<'a> FnOnce(&'a mut SharedE2eContext<'a>) -> TestFuture<'a>, +{ + assert_eq!( + category, group.category, + "shared E2E group category must match the test's snapshots" + ); + let mut state = group.state.lock().await; + let _permit = E2E_CONCURRENCY + .acquire() + .await + .expect("E2E concurrency semaphore should stay open"); + let completed = group.completed_invocations.fetch_add(1, Ordering::Relaxed) + 1; + if state.is_none() { + let context = E2eContext::new(group.category, snapshot_name) + .await + .unwrap_or_else(|err| panic!("create shared E2E context: {err}")); + let _env_guard = InProcessEnvGuard::activate(&context); + let options = (group.client_options)(&context); + let mut startup = SHARED_E2E_RUNTIME.spawn(async move { + let client = Client::start(options).await?; + client.start_router_for_test(); + Ok::<_, github_copilot_sdk::Error>(client) + }); + let client = match tokio::time::timeout(default_test_timeout(), &mut startup).await { + Ok(result) => result + .expect("join shared E2E client startup") + .expect("start shared E2E client"), + Err(_) => { + startup.abort(); + let _ = tokio::time::timeout(SHARED_E2E_CLEANUP_TIMEOUT, startup).await; + panic!( + "timed out after {:?} starting shared E2E client", + default_test_timeout() + ); + } + }; + *state = Some(SharedE2eState { context, client }); + } + + let _env_guard = InProcessEnvGuard::activate( + &state + .as_ref() + .expect("shared E2E state initialized") + .context, + ); + let (result, cleanup_result) = { + let state = state.as_mut().expect("shared E2E state initialized"); + let result = match tokio::time::timeout( + SHARED_E2E_CLEANUP_TIMEOUT, + state.prepare_test(group.category, snapshot_name), + ) + .await + { + Ok(Ok(())) => Ok({ + let mut context = SharedE2eContext { + context: &mut state.context, + client: state.client.clone(), + }; + AssertUnwindSafe(tokio::time::timeout( + default_test_timeout(), + test(&mut context), + )) + .catch_unwind() + .await + }), + Ok(Err(error)) => Err(error), + Err(_) => Err(std::io::Error::other(format!( + "timed out after {SHARED_E2E_CLEANUP_TIMEOUT:?} preparing shared E2E test" + ))), + }; + let cleanup_result = match tokio::time::timeout( + SHARED_E2E_CLEANUP_TIMEOUT, + state.cleanup_after_test(), + ) + .await + { + Ok(result) => result, + Err(_) => { + state.client.force_stop(); + Err(std::io::Error::other(format!( + "timed out after {SHARED_E2E_CLEANUP_TIMEOUT:?} cleaning up shared E2E test" + ))) + } + }; + (result, cleanup_result) + }; + + let test_succeeded = matches!(&result, Ok(Ok(Ok(())))); + let skip_writing_cache = !test_succeeded || cleanup_result.is_err(); + let teardown_result = if !test_succeeded + || cleanup_result.is_err() + || is_filtered_test_run() + || completed == group.expected_invocations + { + state + .take() + .expect("shared E2E state initialized") + .shutdown_bounded(skip_writing_cache) + .await + } else { + Ok(()) + }; + + match result { + Ok(Ok(Ok(()))) => { + cleanup_result.unwrap_or_else(|error| panic!("clean up shared E2E test: {error}")); + teardown_result.unwrap_or_else(|error| panic!("tear down shared E2E group: {error}")); + } + Ok(Ok(Err(_))) => { + if let Err(error) = cleanup_result { + eprintln!("failed to clean up timed-out shared E2E test: {error}"); + } + if let Err(error) = teardown_result { + eprintln!("failed to tear down shared E2E group after timeout: {error}"); + } + panic!( + "timed out after {:?} running shared E2E test {}/{}", + default_test_timeout(), + group.category, + snapshot_name + ); + } + Ok(Err(payload)) => { + if let Err(error) = cleanup_result { + eprintln!("failed to clean up shared E2E test after panic: {error}"); + } + if let Err(error) = teardown_result { + eprintln!("failed to tear down shared E2E group after panic: {error}"); + } + std::panic::resume_unwind(payload); + } + Err(error) => { + if let Err(cleanup_error) = cleanup_result { + eprintln!( + "failed to clean up shared E2E test after setup failure: {cleanup_error}" + ); + } + if let Err(teardown_error) = teardown_result { + eprintln!( + "failed to tear down shared E2E group after setup failure: {teardown_error}" + ); + } + panic!("prepare shared E2E test: {error}"); + } + } +} + +pub async fn with_dedicated_e2e_context(category: &str, snapshot_name: &str, test: F) where F: for<'a> FnOnce(&'a mut E2eContext) -> TestFuture<'a>, { @@ -56,11 +325,56 @@ where ); } -/// Like [`with_e2e_context`] but starts the CapiProxy without loading a +pub async fn with_dedicated_group_e2e_context( + _group: &'static SharedE2eGroup, + category: &str, + snapshot_name: &str, + test: F, +) where + F: for<'a> FnOnce(&'a mut E2eContext) -> TestFuture<'a>, +{ + with_dedicated_e2e_context(category, snapshot_name, test).await; +} + +pub async fn skip_shared_e2e_inprocess(group: &'static SharedE2eGroup, reason: &str) -> bool { + if !skip_inprocess(reason) { + return false; + } + + let mut state = group.state.lock().await; + let _permit = E2E_CONCURRENCY + .acquire() + .await + .expect("E2E concurrency semaphore should stay open"); + let completed = group.completed_invocations.fetch_add(1, Ordering::Relaxed) + 1; + if completed == group.expected_invocations + && let Some(state) = state.take() + { + state + .shutdown_bounded(false) + .await + .unwrap_or_else(|error| panic!("tear down shared E2E group after skip: {error}")); + } + true +} + +/// Run a dedicated one-client E2E test. +/// +/// New tests should call [`with_dedicated_e2e_context`] to make the lifecycle +/// choice visible at the call site. This name remains for existing dedicated +/// tests while they are migrated group by group. +pub async fn with_e2e_context(category: &str, snapshot_name: &str, test: F) +where + F: for<'a> FnOnce(&'a mut E2eContext) -> TestFuture<'a>, +{ + with_dedicated_e2e_context(category, snapshot_name, test).await; +} + +/// Like [`with_dedicated_e2e_context`] but starts the CapiProxy without loading a /// recorded snapshot. Used by the LLM inference callback tests, whose /// registered provider fabricates every model-layer response so no CAPI /// replay is needed — only the auth/user endpoints are served by the proxy. -pub async fn with_e2e_context_no_snapshot(test: F) +pub async fn with_dedicated_e2e_context_no_snapshot(test: F) where F: for<'a> FnOnce(&'a mut E2eContext) -> TestFuture<'a>, { @@ -89,6 +403,15 @@ where ); } +/// Dedicated no-snapshot compatibility helper. See +/// [`with_dedicated_e2e_context_no_snapshot`]. +pub async fn with_e2e_context_no_snapshot(test: F) +where + F: for<'a> FnOnce(&'a mut E2eContext) -> TestFuture<'a>, +{ + with_dedicated_e2e_context_no_snapshot(test).await; +} + pub struct E2eContext { repo_root: PathBuf, cli_path: PathBuf, @@ -193,6 +516,7 @@ impl E2eContext { /// `node --embedded-host` argv itself and loads the sibling /// runtime cdylib), so a `.js` entrypoint is not split into node + /// prefix_args here. + #[cfg_attr(not(feature = "bundled-in-process"), allow(dead_code))] pub async fn start_inprocess_client(&self) -> Client { let options = ClientOptions::new().with_transport(Transport::InProcess); Client::start(options) @@ -350,6 +674,130 @@ impl E2eContext { } } +impl SharedE2eState { + async fn prepare_test(&mut self, category: &str, snapshot_name: &str) -> std::io::Result<()> { + self.cleanup_sessions().await?; + clear_directory_contents(self.context.work_dir())?; + self.context.configure(category, snapshot_name)?; + self.context.set_default_copilot_user(); + Ok(()) + } + + async fn cleanup_after_test(&mut self) -> std::io::Result<()> { + self.cleanup_sessions().await?; + clear_directory_contents(self.context.work_dir()) + } + + async fn cleanup_sessions(&self) -> std::io::Result<()> { + self.client + .cleanup_sessions_for_test() + .await + .map_err(|err| { + std::io::Error::other(format!("clean up shared E2E sessions failed: {err}")) + }) + } + + async fn shutdown_bounded(mut self, skip_writing_cache: bool) -> std::io::Result<()> { + let client_result = + match tokio::time::timeout(SHARED_E2E_CLEANUP_TIMEOUT, self.client.stop()).await { + Ok(result) => result.map_err(|err| { + std::io::Error::other(format!("stop shared E2E client failed: {err}")) + }), + Err(_) => { + self.client.force_stop(); + Err(std::io::Error::other(format!( + "timed out after {SHARED_E2E_CLEANUP_TIMEOUT:?} stopping shared E2E client" + ))) + } + }; + let proxy_result = self.context.cleanup(skip_writing_cache).await; + + match (client_result, proxy_result) { + (Ok(()), Ok(())) => Ok(()), + (Err(error), Ok(())) | (Ok(()), Err(error)) => Err(error), + (Err(client_error), Err(proxy_error)) => Err(std::io::Error::other(format!( + "{client_error}; stop shared E2E proxy failed: {proxy_error}" + ))), + } + } +} + +fn wait_for_child_exit(child: &mut Child) -> std::io::Result<()> { + let deadline = Instant::now() + SHARED_E2E_CLEANUP_TIMEOUT; + loop { + if child.try_wait()?.is_some() { + return Ok(()); + } + if Instant::now() >= deadline { + kill_and_wait_child(child); + return Err(std::io::Error::other(format!( + "timed out after {SHARED_E2E_CLEANUP_TIMEOUT:?} waiting for child process" + ))); + } + std::thread::sleep(Duration::from_millis(25)); + } +} + +fn kill_and_wait_child(child: &mut Child) { + if let Err(error) = child.kill() { + eprintln!("failed to kill E2E child process: {error}"); + } + let deadline = Instant::now() + SHARED_E2E_CLEANUP_TIMEOUT; + loop { + match child.try_wait() { + Ok(Some(_)) => return, + Ok(None) => {} + Err(error) => { + eprintln!("failed to inspect E2E child process after kill: {error}"); + return; + } + } + if Instant::now() >= deadline { + eprintln!( + "timed out after {SHARED_E2E_CLEANUP_TIMEOUT:?} waiting for killed E2E child process" + ); + return; + } + std::thread::sleep(Duration::from_millis(25)); + } +} + +fn connect_with_timeout(host: &str, port: u16) -> std::io::Result { + let mut last_error = None; + for address in (host, port).to_socket_addrs()? { + match TcpStream::connect_timeout(&address, SHARED_E2E_CLEANUP_TIMEOUT) { + Ok(stream) => { + stream.set_read_timeout(Some(SHARED_E2E_CLEANUP_TIMEOUT))?; + stream.set_write_timeout(Some(SHARED_E2E_CLEANUP_TIMEOUT))?; + return Ok(stream); + } + Err(error) => last_error = Some(error), + } + } + Err(last_error.unwrap_or_else(|| { + std::io::Error::other(format!("no socket addresses resolved for {host}:{port}")) + })) +} + +fn is_filtered_test_run() -> bool { + std::env::args().skip(1).any(|arg| { + !arg.starts_with('-') || matches!(arg.as_str(), "--ignored" | "--include-ignored") + }) +} + +fn clear_directory_contents(directory: &Path) -> std::io::Result<()> { + for entry in std::fs::read_dir(directory)? { + let entry = entry?; + let path = entry.path(); + if entry.file_type()?.is_dir() { + std::fs::remove_dir_all(path)?; + } else { + std::fs::remove_file(path)?; + } + } + Ok(()) +} + impl Drop for E2eContext { fn drop(&mut self) { if let Some(mut proxy) = self.proxy.take() { @@ -819,38 +1267,85 @@ impl CapiProxy { .spawn()?; let stdout = child.stdout.take().expect("proxy stdout"); - let reader = BufReader::new(stdout); + let (line_tx, line_rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + for line in BufReader::new(stdout).lines() { + let failed = line.is_err(); + if line_tx.send(line).is_err() || failed { + break; + } + } + }); let re = regex::Regex::new(r"Listening: (http://[^\s]+)\s+(\{.*\})$").unwrap(); - for line in reader.lines() { - let line = line?; + let deadline = Instant::now() + SHARED_E2E_CLEANUP_TIMEOUT; + while let Some(remaining) = deadline.checked_duration_since(Instant::now()) { + let line = match line_rx.recv_timeout(remaining) { + Ok(Ok(line)) => line, + Ok(Err(error)) => { + kill_and_wait_child(&mut child); + return Err(error); + } + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => { + kill_and_wait_child(&mut child); + return Err(std::io::Error::other("proxy exited before startup")); + } + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => break, + }; if let Some(captures) = re.captures(&line) { - let metadata: serde_json::Value = - serde_json::from_str(captures.get(2).unwrap().as_str())?; - let connect_proxy_url = metadata - .get("connectProxyUrl") - .and_then(|value| value.as_str()) - .expect("connectProxyUrl") - .to_string(); - let ca_file_path = metadata - .get("caFilePath") - .and_then(|value| value.as_str()) - .expect("caFilePath") - .to_string(); + let parsed = (|| { + let proxy_url = captures + .get(1) + .ok_or_else(|| { + std::io::Error::other("proxy startup line missing URL capture") + })? + .as_str() + .to_string(); + let metadata_text = captures.get(2).ok_or_else(|| { + std::io::Error::other("proxy startup line missing metadata capture") + })?; + let metadata: serde_json::Value = serde_json::from_str(metadata_text.as_str())?; + let connect_proxy_url = metadata + .get("connectProxyUrl") + .and_then(|value| value.as_str()) + .ok_or_else(|| { + std::io::Error::other("proxy startup metadata missing connectProxyUrl") + })? + .to_string(); + let ca_file_path = metadata + .get("caFilePath") + .and_then(|value| value.as_str()) + .ok_or_else(|| { + std::io::Error::other("proxy startup metadata missing caFilePath") + })? + .to_string(); + Ok::<_, std::io::Error>((proxy_url, connect_proxy_url, ca_file_path)) + })(); + let (proxy_url, connect_proxy_url, ca_file_path) = match parsed { + Ok(metadata) => metadata, + Err(error) => { + kill_and_wait_child(&mut child); + return Err(error); + } + }; return Ok(Self { child: Some(child), - proxy_url: captures.get(1).unwrap().as_str().to_string(), + proxy_url, connect_proxy_url, ca_file_path, }); } if line.contains("Listening: ") { + kill_and_wait_child(&mut child); return Err(std::io::Error::other(format!( "proxy startup line missing metadata: {line}" ))); } } - Err(std::io::Error::other("proxy exited before startup")) + kill_and_wait_child(&mut child); + Err(std::io::Error::other(format!( + "timed out after {SHARED_E2E_CLEANUP_TIMEOUT:?} waiting for proxy startup" + ))) } fn url(&self) -> &str { @@ -891,7 +1386,7 @@ impl CapiProxy { }; let result = self.post_json(path, ""); if let Some(mut child) = self.child.take() { - let _ = child.wait(); + wait_for_child_exit(&mut child)?; } result } @@ -943,7 +1438,7 @@ impl CapiProxy { fn request(&self, method: &str, path: &str, body: &str) -> std::io::Result { let (host, port) = parse_http_url(&self.proxy_url)?; - let mut stream = TcpStream::connect((host.as_str(), port))?; + let mut stream = connect_with_timeout(&host, port)?; write!( stream, "{method} {path} HTTP/1.1\r\nHost: {host}:{port}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", diff --git a/rust/tests/e2e/system_message_sections.rs b/rust/tests/e2e/system_message_sections.rs index e582d3846..f13336752 100644 --- a/rust/tests/e2e/system_message_sections.rs +++ b/rust/tests/e2e/system_message_sections.rs @@ -2,11 +2,12 @@ use std::collections::HashMap; use github_copilot_sdk::{SectionOverride, SystemMessageConfig}; -use super::support::{assistant_message_content, with_e2e_context}; +use super::support::assistant_message_content; #[tokio::test] async fn should_use_replaced_identity_section_in_response() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "system_message_sections", "should_use_replaced_identity_section_in_response", |ctx| { @@ -60,7 +61,8 @@ async fn should_use_replaced_identity_section_in_response() { #[tokio::test] async fn should_use_replaced_preamble_section_in_response() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "system_message_sections", "should_use_replaced_preamble_section_in_response", |ctx| { @@ -111,3 +113,5 @@ async fn should_use_replaced_preamble_section_in_response() { ) .await; } +static E2E: super::support::SharedE2eGroup = + super::support::SharedE2eGroup::standard("system_message_sections", 2); diff --git a/rust/tests/e2e/tool_results.rs b/rust/tests/e2e/tool_results.rs index e6e62643f..c46cacbf3 100644 --- a/rust/tests/e2e/tool_results.rs +++ b/rust/tests/e2e/tool_results.rs @@ -10,11 +10,12 @@ use github_copilot_sdk::{ use serde_json::json; use tokio::sync::mpsc; -use super::support::{assistant_message_content, collect_until_idle, with_e2e_context}; +use super::support::{assistant_message_content, collect_until_idle}; #[tokio::test] async fn should_handle_structured_toolresultobject_from_custom_tool() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "tool_results", "should_handle_structured_toolresultobject_from_custom_tool", |ctx| { @@ -41,7 +42,7 @@ async fn should_handle_structured_toolresultobject_from_custom_tool() { #[tokio::test] async fn should_handle_tool_result_with_failure_resulttype() { - with_e2e_context( + super::support::with_shared_e2e_context(&E2E, "tool_results", "should_handle_tool_result_with_failure_resulttype", |ctx| { @@ -69,7 +70,8 @@ async fn should_handle_tool_result_with_failure_resulttype() { #[tokio::test] async fn should_preserve_tooltelemetry_and_not_stringify_structured_results_for_llm() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "tool_results", "should_preserve_tooltelemetry_and_not_stringify_structured_results_for_llm", |ctx| { @@ -116,7 +118,7 @@ async fn should_preserve_tooltelemetry_and_not_stringify_structured_results_for_ #[tokio::test] async fn should_handle_tool_result_with_rejected_resulttype() { - with_e2e_context( + super::support::with_shared_e2e_context(&E2E, "tool_results", "should_handle_tool_result_with_rejected_resulttype", |ctx| { @@ -153,7 +155,7 @@ async fn should_handle_tool_result_with_rejected_resulttype() { #[tokio::test] async fn should_handle_tool_result_with_denied_resulttype() { - with_e2e_context( + super::support::with_shared_e2e_context(&E2E, "tool_results", "should_handle_tool_result_with_denied_resulttype", |ctx| { @@ -356,3 +358,5 @@ fn string_tool( "required": [parameter], })) } +static E2E: super::support::SharedE2eGroup = + super::support::SharedE2eGroup::standard("tool_results", 5); diff --git a/rust/tests/e2e/tools.rs b/rust/tests/e2e/tools.rs index 2c474bca1..586a31d3a 100644 --- a/rust/tests/e2e/tools.rs +++ b/rust/tests/e2e/tools.rs @@ -9,11 +9,11 @@ use github_copilot_sdk::{ use serde_json::json; use tokio::sync::{Mutex, mpsc}; -use super::support::{assistant_message_content, recv_with_timeout, with_e2e_context}; +use super::support::{assistant_message_content, recv_with_timeout}; #[tokio::test] async fn invokes_built_in_tools() { - with_e2e_context("tools", "invokes_built_in_tools", |ctx| { + super::support::with_shared_e2e_context(&E2E, "tools", "invokes_built_in_tools", |ctx| { Box::pin(async move { ctx.set_default_copilot_user(); std::fs::write( @@ -43,7 +43,7 @@ async fn invokes_built_in_tools() { #[tokio::test] async fn invokes_custom_tool() { - with_e2e_context("tools", "invokes_custom_tool", |ctx| { + super::support::with_shared_e2e_context(&E2E, "tools", "invokes_custom_tool", |ctx| { Box::pin(async move { ctx.set_default_copilot_user(); let client = ctx.start_client().await; @@ -75,7 +75,7 @@ async fn invokes_custom_tool() { #[tokio::test] async fn low_level_tool_definition() { - with_e2e_context("tools", "low_level_tool_definition", |ctx| { + super::support::with_shared_e2e_context(&E2E, "tools", "low_level_tool_definition", |ctx| { Box::pin(async move { ctx.set_default_copilot_user(); let client = ctx.start_client().await; @@ -124,7 +124,7 @@ async fn low_level_tool_definition() { #[tokio::test] async fn handles_tool_calling_errors() { - with_e2e_context("tools", "handles_tool_calling_errors", |ctx| { + super::support::with_shared_e2e_context(&E2E, "tools", "handles_tool_calling_errors", |ctx| { Box::pin(async move { ctx.set_default_copilot_user(); let client = ctx.start_client().await; @@ -173,7 +173,7 @@ async fn handles_tool_calling_errors() { #[tokio::test] async fn can_receive_and_return_complex_types() { - with_e2e_context("tools", "can_receive_and_return_complex_types", |ctx| { + super::support::with_shared_e2e_context(&E2E, "tools", "can_receive_and_return_complex_types", |ctx| { Box::pin(async move { ctx.set_default_copilot_user(); let client = ctx.start_client().await; @@ -212,76 +212,89 @@ async fn can_receive_and_return_complex_types() { #[tokio::test] async fn overrides_built_in_tool_with_custom_tool() { - with_e2e_context("tools", "overrides_built_in_tool_with_custom_tool", |ctx| { - Box::pin(async move { - ctx.set_default_copilot_user(); - let client = ctx.start_client().await; - let __perm = Arc::new(ApproveAllHandler); - let tools = vec![custom_grep_tool()]; - let session = client - .create_session( - SessionConfig::default() - .with_github_token(super::support::DEFAULT_TEST_TOKEN) - .with_permission_handler(__perm) - .with_tools(tools), - ) - .await - .expect("create session"); + super::support::with_shared_e2e_context( + &E2E, + "tools", + "overrides_built_in_tool_with_custom_tool", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let __perm = Arc::new(ApproveAllHandler); + let tools = vec![custom_grep_tool()]; + let session = client + .create_session( + SessionConfig::default() + .with_github_token(super::support::DEFAULT_TEST_TOKEN) + .with_permission_handler(__perm) + .with_tools(tools), + ) + .await + .expect("create session"); - let answer = session - .send_and_wait("Use grep to search for the word 'hello'") - .await - .expect("send") - .expect("assistant message"); - assert!(assistant_message_content(&answer).contains("CUSTOM_GREP_RESULT")); + let answer = session + .send_and_wait("Use grep to search for the word 'hello'") + .await + .expect("send") + .expect("assistant message"); + assert!(assistant_message_content(&answer).contains("CUSTOM_GREP_RESULT")); - session.disconnect().await.expect("disconnect session"); - client.stop().await.expect("stop client"); - }) - }) + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) .await; } #[tokio::test] async fn skippermission_sent_in_tool_definition() { - with_e2e_context("tools", "skippermission_sent_in_tool_definition", |ctx| { - Box::pin(async move { - ctx.set_default_copilot_user(); - let client = ctx.start_client().await; - let (permission_tx, mut permission_rx) = mpsc::unbounded_channel(); - let handler = Arc::new(RecordingPermissionHandler { - permission_tx, - decision: PermissionResult::reject(None), - }); - let __perm = handler; - let tools = vec![safe_lookup_tool()]; - let session = client - .create_session( - SessionConfig::default() - .with_github_token(super::support::DEFAULT_TEST_TOKEN) - .with_permission_handler(__perm) - .with_tools(tools), - ) - .await - .expect("create session"); + super::support::with_shared_e2e_context( + &E2E, + "tools", + "skippermission_sent_in_tool_definition", + |ctx| { + Box::pin(async move { + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let (permission_tx, mut permission_requests) = mpsc::unbounded_channel(); + let handler = Arc::new(RecordingPermissionHandler { + permission_tx, + decision: PermissionResult::reject(None), + }); + let __perm = handler; + let tools = vec![safe_lookup_tool()]; + let session = client + .create_session( + SessionConfig::default() + .with_github_token(super::support::DEFAULT_TEST_TOKEN) + .with_permission_handler(__perm) + .with_tools(tools), + ) + .await + .expect("create session"); - let answer = session - .send_and_wait("Use safe_lookup to look up 'test123'") - .await - .expect("send") - .expect("assistant message"); - assert!(assistant_message_content(&answer).contains("RESULT")); - assert!( - tokio::time::timeout(std::time::Duration::from_millis(100), permission_rx.recv()) + let answer = session + .send_and_wait("Use safe_lookup to look up 'test123'") + .await + .expect("send") + .expect("assistant message"); + assert!(assistant_message_content(&answer).contains("RESULT")); + assert!( + tokio::time::timeout( + std::time::Duration::from_millis(100), + permission_requests.recv() + ) .await .is_err(), - "skip_permission tool should not request permission" - ); + "skip_permission tool should not request permission" + ); - session.disconnect().await.expect("disconnect session"); - client.stop().await.expect("stop client"); - }) - }) + session.disconnect().await.expect("disconnect session"); + client.stop().await.expect("stop client"); + }) + }, + ) .await; } @@ -291,7 +304,8 @@ async fn can_return_binary_result() {} #[tokio::test] async fn invokes_custom_tool_with_permission_handler() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "tools", "invokes_custom_tool_with_permission_handler", |ctx| { @@ -334,7 +348,8 @@ async fn invokes_custom_tool_with_permission_handler() { #[tokio::test] async fn denies_custom_tool_when_permission_denied() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "tools", "denies_custom_tool_when_permission_denied", |ctx| { @@ -380,7 +395,7 @@ async fn denies_custom_tool_when_permission_denied() { #[tokio::test] async fn should_execute_multiple_custom_tools_in_parallel_single_turn() { - with_e2e_context( + super::support::with_shared_e2e_context(&E2E, "tools", "should_execute_multiple_custom_tools_in_parallel_single_turn", |ctx| { @@ -428,7 +443,8 @@ async fn should_execute_multiple_custom_tools_in_parallel_single_turn() { #[tokio::test] async fn should_respect_availabletools_and_excludedtools_combined() { - with_e2e_context( + super::support::with_shared_e2e_context( + &E2E, "tools", "should_respect_availabletools_and_excludedtools_combined", |ctx| { @@ -864,3 +880,4 @@ impl ToolHandler for DbQueryTool { )) } } +static E2E: super::support::SharedE2eGroup = super::support::SharedE2eGroup::standard("tools", 11); diff --git a/scripts/corrections/package-lock.json b/scripts/corrections/package-lock.json index 60559d62d..a975812af 100644 --- a/scripts/corrections/package-lock.json +++ b/scripts/corrections/package-lock.json @@ -1107,9 +1107,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "version": "3.3.17", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz", + "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==", "dev": true, "funding": [ { @@ -1164,9 +1164,9 @@ } }, "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "version": "8.5.25", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", + "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", "dev": true, "funding": [ { @@ -1184,7 +1184,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -1334,9 +1334,9 @@ } }, "node_modules/undici": { - "version": "6.27.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.27.0.tgz", - "integrity": "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==", + "version": "6.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz", + "integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==", "dev": true, "license": "MIT", "engines": { diff --git a/test/harness/package-lock.json b/test/harness/package-lock.json index da6e36ffb..4702dbba6 100644 --- a/test/harness/package-lock.json +++ b/test/harness/package-lock.json @@ -1691,9 +1691,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", - "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", "dev": true, "funding": [ { @@ -1926,9 +1926,9 @@ "license": "ISC" }, "node_modules/ip-address": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", - "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz", + "integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==", "dev": true, "license": "MIT", "engines": { @@ -2322,9 +2322,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "version": "3.3.17", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz", + "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==", "dev": true, "funding": [ { @@ -2508,9 +2508,9 @@ } }, "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "version": "8.5.25", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", + "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", "dev": true, "funding": [ { @@ -2528,7 +2528,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, diff --git a/test/harness/replayingCapiProxy.ts b/test/harness/replayingCapiProxy.ts index 47ebda9f7..4c1be59f2 100644 --- a/test/harness/replayingCapiProxy.ts +++ b/test/harness/replayingCapiProxy.ts @@ -416,6 +416,51 @@ export class ReplayingCapiProxy extends CapturingHttpProxy { return; } + // Keep GitHub MCP tests hermetic while still capturing the request at + // the CAPI proxy. The tests only need a successful transport handshake; + // no fake tools are exposed. + if (options.requestOptions.path === "/mcp") { + if (options.requestOptions.method !== "POST") { + options.onResponseStart(200, commonResponseHeaders); + options.onResponseEnd(); + return; + } + + const request = JSON.parse(options.body ?? "{}") as { + id?: string | number; + method?: string; + params?: { protocolVersion?: string }; + }; + if (request.id === undefined) { + options.onResponseStart(202, commonResponseHeaders); + options.onResponseEnd(); + return; + } + + const result = + request.method === "initialize" + ? { + protocolVersion: + request.params?.protocolVersion ?? "2025-03-26", + capabilities: { tools: {} }, + serverInfo: { name: "e2e-github-mcp", version: "1.0.0" }, + } + : request.method === "tools/list" + ? { tools: [] } + : {}; + options.onResponseStart(200, { + "content-type": "application/json", + ...commonResponseHeaders, + }); + options.onData( + Buffer.from( + JSON.stringify({ jsonrpc: "2.0", id: request.id, result }), + ), + ); + options.onResponseEnd(); + return; + } + // Handle memory endpoints - return stub responses in tests // Matches: /agents/*/memory/*/enabled, /agents/*/memory/*/recent, etc. if (options.requestOptions.path?.match(/\/agents\/.*\/memory\//)) { diff --git a/test/harness/test-mcp-server.mjs b/test/harness/test-mcp-server.mjs index b2b32606d..a3a84b42b 100644 --- a/test/harness/test-mcp-server.mjs +++ b/test/harness/test-mcp-server.mjs @@ -13,9 +13,17 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { appendFile } from "node:fs/promises"; import { z } from "zod"; -const server = new McpServer({ name: "env-echo", version: "1.0.0" }); +function getArgument(name) { + const index = process.argv.indexOf(name); + return index === -1 ? undefined : process.argv[index + 1]; +} + +const startupMarkerPath = getArgument("--startup-marker"); +const serverName = getArgument("--server-name") ?? "env-echo"; +const server = new McpServer({ name: serverName, version: "1.0.0" }); server.tool( "get_env", @@ -27,5 +35,7 @@ server.tool( ); const transport = new StdioServerTransport(); +if (startupMarkerPath) { + await appendFile(startupMarkerPath, `${serverName}\n`); +} await server.connect(transport); - diff --git a/test/snapshots/hooks_extended/should_invoke_userprompttransformed_hook_and_modify_transformed_prompt.yaml b/test/snapshots/hooks_extended/should_invoke_userprompttransformed_hook_and_modify_transformed_prompt.yaml new file mode 100644 index 000000000..db2b02968 --- /dev/null +++ b/test/snapshots/hooks_extended/should_invoke_userprompttransformed_hook_and_modify_transformed_prompt.yaml @@ -0,0 +1,10 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: "Reply with exactly: HOOKED_TRANSFORMED_PROMPT" + - role: assistant + content: HOOKED_TRANSFORMED_PROMPT diff --git a/test/snapshots/rpc_session_state/should_report_processing_and_context_metadata.yaml b/test/snapshots/rpc_session_state/should_report_processing_and_context_metadata.yaml new file mode 100644 index 000000000..6760888d7 --- /dev/null +++ b/test/snapshots/rpc_session_state/should_report_processing_and_context_metadata.yaml @@ -0,0 +1,10 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: "Reply with exactly: RUST_CONTEXT_INFO" + - role: assistant + content: RUST_CONTEXT_INFO