From 7a55f5fdb9b56eecbf7b860b91b19b1150291733 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Tue, 28 Jul 2026 11:55:56 -0400 Subject: [PATCH 1/5] Bound Python e2e tests with a per-test timeout pytest-timeout has been a declared dev dependency for a while but was never configured, and the Python job sets no timeout-minutes either. When CLI 1.0.76-0 introduced a session.destroy deadlock, five of the six Python jobs sat in_progress for hours instead of failing, producing no diagnostic signal at all and holding runners until GitHub's 6-hour job limit. Setting timeout = 300 makes a deadlocked test fail in five minutes with a full stack dump pointing at the blocked call, which is how the other SDKs already behave (Node uses 30s/60s/180s per test, Go bounds the package at 20m). The value is deliberately generous: the whole Python suite completes in about 10 minutes, so no individual test comes close to 300s. Verified locally that pytest honours the setting from pyproject.toml and that it interrupts a hanging test with a traceback. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e615d062-bcb7-431e-aa9c-d3e47405723a (cherry picked from commit c33d8e2979350a58d585062359946ed161cb79b2) --- python/pyproject.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/python/pyproject.toml b/python/pyproject.toml index a867fe51d..7e6274d9c 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -92,3 +92,7 @@ python_files = "test_*.py" python_classes = "Test*" python_functions = "test_*" asyncio_mode = "auto" +# Bound every test so a deadlock fails fast with a stack dump instead of occupying the +# whole CI leg until GitHub's 6-hour job limit. The full suite runs in ~10 minutes, so no +# individual test legitimately approaches this. +timeout = 300 From b1259c7257ebfed1e66b10f9b7274fc0e39d2768 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Tue, 28 Jul 2026 10:26:00 -0400 Subject: [PATCH 2/5] Close Go/.NET shell-test teardown gap The "should kill shell process" e2e test exists in all five SDKs, but only Node, Python and Rust destroy the session at the end of it. Go tears the whole client down with client.ForceStop() and .NET simply ended at the last assert, so neither ever exercised session.destroy after a session.shell.exec. That gap is why Go and .NET stayed green on CLI 1.0.76-0 while Node, Python and Rust hang: the hang is in session.destroy after a shell exec, and the two green SDKs never made the call. Adding the teardown makes all five cover the same sequence. Verified locally against the same CLI binary (1.0.76-0, sha256 b8bfad2c...): with the added Disconnect(), the Go test hangs past a 3-minute timeout where it previously passed in seconds. The same test passes on CLI 1.0.73. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e615d062-bcb7-431e-aa9c-d3e47405723a (cherry picked from commit 2e54b4718398a01a56b9f73ccf9161ccd2c5775a) --- dotnet/test/E2E/RpcShellAndFleetE2ETests.cs | 2 ++ go/internal/e2e/rpc_shell_and_fleet_e2e_test.go | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/dotnet/test/E2E/RpcShellAndFleetE2ETests.cs b/dotnet/test/E2E/RpcShellAndFleetE2ETests.cs index a51fc7dae..13daff67f 100644 --- a/dotnet/test/E2E/RpcShellAndFleetE2ETests.cs +++ b/dotnet/test/E2E/RpcShellAndFleetE2ETests.cs @@ -41,6 +41,8 @@ public async Task Should_Kill_Shell_Process() var killResult = await session.Rpc.Shell.KillAsync(execResult.ProcessId); Assert.True(killResult.Killed); + + await session.DisposeAsync(); } [Fact] diff --git a/go/internal/e2e/rpc_shell_and_fleet_e2e_test.go b/go/internal/e2e/rpc_shell_and_fleet_e2e_test.go index d7ffd725d..3a1b7c362 100644 --- a/go/internal/e2e/rpc_shell_and_fleet_e2e_test.go +++ b/go/internal/e2e/rpc_shell_and_fleet_e2e_test.go @@ -81,6 +81,10 @@ func TestRPCShellAndFleetE2E(t *testing.T) { if !kill.Killed { t.Errorf("Expected shell.kill to report Killed=true, got %+v", kill) } + + if err := session.Disconnect(); err != nil { + t.Fatalf("Failed to disconnect session: %v", err) + } }) t.Run("should start fleet and complete custom tool task", func(t *testing.T) { From 6563591a26593cd717047034800c7381f3ea007d Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Tue, 28 Jul 2026 10:33:32 -0400 Subject: [PATCH 3/5] Remove Rust e2e test for session.mcp.registerExternalClient The runtime team confirmed this method is marked `visibility: internal` in the shared API contract: its `client` and `transport` fields are live in-process MCP SDK instances that cannot be serialized over JSON-RPC. No SDK exposes it as a typed method, and the Rust test drove it through a raw `call_session_rpc` with placeholder JSON objects that could never satisfy that contract. It passed on CLI 1.0.73 only because the old TypeScript dispatch happened to route internal methods generically. 1.0.76-0 routes the method through the Rust native registry, where the host-effect switch has no case for it, so it now returns -32603 "Unsupported native session host effect". That is correct behaviour, not a regression, so the test is removed along with its now-orphaned snapshot and a note recording why the gap is deliberate. Both `call_session_rpc` and `is_mcp_server_running` remain in use by other tests in the file, so no helper became dead. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e615d062-bcb7-431e-aa9c-d3e47405723a (cherry picked from commit f0c846c727314d06b9e970dff1dafa419838ec8f) --- rust/tests/e2e/rpc_mcp_lifecycle.rs | 57 ++----------------- ...er_and_unregister_external_mcp_client.yaml | 3 - 2 files changed, 5 insertions(+), 55 deletions(-) delete mode 100644 test/snapshots/rpc_mcp_lifecycle/should_register_and_unregister_external_mcp_client.yaml diff --git a/rust/tests/e2e/rpc_mcp_lifecycle.rs b/rust/tests/e2e/rpc_mcp_lifecycle.rs index 028b5a527..aa3adcf5c 100644 --- a/rust/tests/e2e/rpc_mcp_lifecycle.rs +++ b/rust/tests/e2e/rpc_mcp_lifecycle.rs @@ -194,58 +194,11 @@ async fn should_start_and_restart_mcp_server() { .await; } -#[tokio::test] -async fn should_register_and_unregister_external_mcp_client() { - with_e2e_context( - "rpc_mcp_lifecycle", - "should_register_and_unregister_external_mcp_client", - |ctx| { - Box::pin(async move { - ctx.set_default_copilot_user(); - let host_server = "rpc-lifecycle-extclient-host"; - let client = ctx.start_client().await; - let session = - client - .create_session(ctx.approve_all_session_config().with_mcp_servers( - create_test_mcp_servers(ctx.repo_root(), host_server), - )) - .await - .expect("create session"); - wait_for_mcp_server_status(&session, host_server, McpServerStatus::Connected).await; - - let external_name = "rpc-lifecycle-external-client"; - assert!(!is_mcp_server_running(&session, external_name).await); - - call_session_rpc( - &session, - "session.mcp.registerExternalClient", - json!({ - "serverName": external_name, - "client": { "id": external_name }, - "transport": { "kind": "in-process" }, - "config": { "command": "noop" } - }), - ) - .await - .expect("register external MCP client"); - assert!(is_mcp_server_running(&session, external_name).await); - - call_session_rpc( - &session, - "session.mcp.unregisterExternalClient", - json!({ "serverName": external_name }), - ) - .await - .expect("unregister external MCP client"); - assert!(!is_mcp_server_running(&session, external_name).await); - - session.disconnect().await.expect("disconnect session"); - client.stop().await.expect("stop client"); - }) - }, - ) - .await; -} +// There is deliberately no e2e test for `session.mcp.registerExternalClient`. That method is +// marked `visibility: internal` in the shared API contract: its `client` and `transport` fields +// are live in-process MCP SDK instances, so it cannot be driven over JSON-RPC, and no SDK +// exposes it as a typed method. A raw-RPC test used to pass only because older CLIs routed +// internal methods generically; it never exercised a supported wire API. #[tokio::test] async fn should_reload_mcp_servers_with_config() { diff --git a/test/snapshots/rpc_mcp_lifecycle/should_register_and_unregister_external_mcp_client.yaml b/test/snapshots/rpc_mcp_lifecycle/should_register_and_unregister_external_mcp_client.yaml deleted file mode 100644 index 056351ddb..000000000 --- a/test/snapshots/rpc_mcp_lifecycle/should_register_and_unregister_external_mcp_client.yaml +++ /dev/null @@ -1,3 +0,0 @@ -models: - - claude-sonnet-4.5 -conversations: [] From 25cad09cb40361b567f5eeb7c2a67f5cbcd98bdf Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Tue, 28 Jul 2026 12:05:13 -0400 Subject: [PATCH 4/5] codegen: emit Rust type aliases for scalar RPC result schemas The Rust codegen only emitted a named type for result schemas that were an enum, array, map, or object. A method whose result is an inline primitive (e.g. {"type":"integer"}) produced a reference to a type that was never defined, so the generated crate failed to compile with E0425. No RPC method had a primitive result until session.cancelAllBackgroundAgents was added, so this latent gap only surfaces on a schema update. Add rustScalarType()/emitRustScalarAlias() so scalar results emit a plain type alias alongside the existing shapes. This is a no-op against the current pinned schema and prevents the next dependency bump from hitting the same build break. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e615d062-bcb7-431e-aa9c-d3e47405723a --- scripts/codegen/rust.ts | 43 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/scripts/codegen/rust.ts b/scripts/codegen/rust.ts index a04f5a21c..127dc2e48 100644 --- a/scripts/codegen/rust.ts +++ b/scripts/codegen/rust.ts @@ -575,6 +575,45 @@ function emitRustMapAlias( ); } +/** + * Map a primitive JSON Schema type to its Rust equivalent, or `undefined` when + * the schema is not a plain scalar. Mirrors the primitive branches of + * {@link resolveRustType}. + */ +function rustScalarType(schema: JSONSchema7): string | undefined { + if (schema.enum || schema.const !== undefined) return undefined; + switch (schema.type) { + case "string": + return "String"; + case "number": + return "f64"; + case "integer": + return isIntegerSchemaBoundedToInt32(schema) ? "i32" : "i64"; + case "boolean": + return "bool"; + default: + return undefined; + } +} + +/** + * Emit a type alias for a named schema that resolves to a primitive scalar + * (e.g. an RPC result declared as `{ "type": "integer" }`). Without this the + * generated RPC surface would reference a `*Result` type that was never + * defined. + */ +function emitRustScalarAlias( + typeName: string, + schema: JSONSchema7, + ctx: RustCodegenCtx, + description?: string, +): void { + if (ctx.generatedNames.has(typeName)) return; + const scalarType = rustScalarType(schema); + if (!scalarType) return; + emitRustTypeAlias(typeName, schema, scalarType, ctx, description); +} + function rustRpcResultDescription( method: RpcMethod, resultSchema: JSONSchema7 | undefined, @@ -1527,6 +1566,8 @@ function generateApiTypesCode( } else { tryEmitRustUnion(schema, name, "", ctx); } + } else { + emitRustScalarAlias(name, schema, ctx, schema.description); } } @@ -1576,6 +1617,8 @@ function generateApiTypesCode( emitRustMapAlias(resultName, resolved, ctx, resolved.description); } else if (isObjectSchema(resolved)) { emitRustStruct(resultName, resolved, ctx, resolved.description); + } else { + emitRustScalarAlias(resultName, resolved, ctx, resolved.description); } } } From 8d1d12e9cdf0437fec5ff0fc054cf9600091ebb9 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Tue, 28 Jul 2026 12:50:14 -0400 Subject: [PATCH 5/5] Ensure shell test teardown on failure Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b565403c-57ed-4368-84f1-f04bfe39e46e --- dotnet/test/E2E/RpcShellAndFleetE2ETests.cs | 4 +--- go/internal/e2e/rpc_shell_and_fleet_e2e_test.go | 5 +---- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/dotnet/test/E2E/RpcShellAndFleetE2ETests.cs b/dotnet/test/E2E/RpcShellAndFleetE2ETests.cs index 13daff67f..2946b7bbe 100644 --- a/dotnet/test/E2E/RpcShellAndFleetE2ETests.cs +++ b/dotnet/test/E2E/RpcShellAndFleetE2ETests.cs @@ -28,7 +28,7 @@ public async Task Should_Execute_Shell_Command() [Fact] public async Task Should_Kill_Shell_Process() { - var session = await CreateSessionAsync(); + await using var session = await CreateSessionAsync(); var command = OperatingSystem.IsWindows() ? "powershell -NoLogo -NoProfile -Command \"Start-Sleep -Seconds 30\"" : "sleep 30"; @@ -41,8 +41,6 @@ public async Task Should_Kill_Shell_Process() var killResult = await session.Rpc.Shell.KillAsync(execResult.ProcessId); Assert.True(killResult.Killed); - - await session.DisposeAsync(); } [Fact] diff --git a/go/internal/e2e/rpc_shell_and_fleet_e2e_test.go b/go/internal/e2e/rpc_shell_and_fleet_e2e_test.go index 3a1b7c362..81b8471da 100644 --- a/go/internal/e2e/rpc_shell_and_fleet_e2e_test.go +++ b/go/internal/e2e/rpc_shell_and_fleet_e2e_test.go @@ -55,6 +55,7 @@ func TestRPCShellAndFleetE2E(t *testing.T) { if err != nil { t.Fatalf("Failed to create session: %v", err) } + t.Cleanup(func() { _ = session.Disconnect() }) var command string if runtime.GOOS == "windows" { @@ -81,10 +82,6 @@ func TestRPCShellAndFleetE2E(t *testing.T) { if !kill.Killed { t.Errorf("Expected shell.kill to report Killed=true, got %+v", kill) } - - if err := session.Disconnect(); err != nil { - t.Fatalf("Failed to disconnect session: %v", err) - } }) t.Run("should start fleet and complete custom tool task", func(t *testing.T) {