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