diff --git a/.github/workflows/python-sdk-tests.yml b/.github/workflows/python-sdk-tests.yml
index e51ee3f3c8..3b0c5adb49 100644
--- a/.github/workflows/python-sdk-tests.yml
+++ b/.github/workflows/python-sdk-tests.yml
@@ -99,6 +99,16 @@ jobs:
# running independent modules concurrently in isolated workers.
run: uv run pytest -v -s -n 2 --dist=loadfile
+ - name: Upload Python test diagnostics
+ if: failure()
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
+ with:
+ name: python-test-diagnostics-${{ matrix.os }}-${{ matrix.python-version }}-${{ matrix.transport }}-${{ github.run_attempt }}
+ path: python/.pytest-diagnostics/
+ include-hidden-files: true
+ if-no-files-found: warn
+ retention-days: 7
+
# JavaScript actions use a glibc-linked Node runtime, so Alpine runs through Docker.
test-musl-arm64:
name: "Python SDK Tests (Alpine ARM64, ${{ matrix.transport }})"
@@ -136,3 +146,13 @@ jobs:
cd python
uv sync --all-extras --dev
uv run pytest -v -s -n 2 --dist=loadfile
+
+ - name: Upload Python test diagnostics
+ if: failure()
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
+ with:
+ name: python-test-diagnostics-alpine-arm64-${{ matrix.transport }}-${{ github.run_attempt }}
+ path: python/.pytest-diagnostics/
+ include-hidden-files: true
+ if-no-files-found: warn
+ retention-days: 7
diff --git a/go/internal/e2e/auto_tier_e2e_test.go b/go/internal/e2e/auto_tier_e2e_test.go
index e974f95927..0e1db0c699 100644
--- a/go/internal/e2e/auto_tier_e2e_test.go
+++ b/go/internal/e2e/auto_tier_e2e_test.go
@@ -62,6 +62,9 @@ func TestAutoTierE2E(t *testing.T) {
}
t.Run("should stage and reset auto tier preference", func(t *testing.T) {
+ if testharness.RunInIsolatedProcess(t) {
+ return
+ }
session := newAutoSession(t)
assertNoPending(t, session)
@@ -107,6 +110,9 @@ func TestAutoTierE2E(t *testing.T) {
})
t.Run("should preserve auto tier when set model omits it", func(t *testing.T) {
+ if testharness.RunInIsolatedProcess(t) {
+ return
+ }
session := newAutoSession(t)
if _, err := session.SetAutoTier(t.Context(), autoTier(copilot.AutoTierBalance)); err != nil {
diff --git a/go/internal/e2e/client_api_e2e_test.go b/go/internal/e2e/client_api_e2e_test.go
index 3b0c888456..5d67a4c567 100644
--- a/go/internal/e2e/client_api_e2e_test.go
+++ b/go/internal/e2e/client_api_e2e_test.go
@@ -62,9 +62,9 @@ func TestClientAPIE2E(t *testing.T) {
})
t.Run("should get null last session id before any sessions exist", func(t *testing.T) {
- // Use a fresh client with isolated COPILOT_HOME so other subtests don't pollute state.
- freshCtx := testharness.NewTestContext(t)
- freshClient := freshCtx.NewClient()
+ freshClient := ctx.NewClient(func(options *copilot.ClientOptions) {
+ options.BaseDirectory = t.TempDir()
+ })
t.Cleanup(func() { freshClient.ForceStop() })
if err := freshClient.Start(t.Context()); err != nil {
diff --git a/go/internal/e2e/client_options_e2e_test.go b/go/internal/e2e/client_options_e2e_test.go
index 54461443ca..0b8423649c 100644
--- a/go/internal/e2e/client_options_e2e_test.go
+++ b/go/internal/e2e/client_options_e2e_test.go
@@ -19,6 +19,9 @@ import (
// in package-level unit tests.
func TestClientOptionsE2E(t *testing.T) {
t.Run("should listen on configured TCP port", func(t *testing.T) {
+ if testharness.RunInIsolatedProcess(t) {
+ return
+ }
ctx := testharness.NewTestContext(t)
port := getAvailableTCPPort(t)
@@ -45,6 +48,9 @@ func TestClientOptionsE2E(t *testing.T) {
})
t.Run("should use client cwd for default workingdirectory", func(t *testing.T) {
+ if testharness.RunInIsolatedProcess(t) {
+ return
+ }
ctx := testharness.NewTestContext(t)
ctx.ConfigureForTest(t)
@@ -85,6 +91,9 @@ func TestClientOptionsE2E(t *testing.T) {
})
t.Run("should propagate process options to spawned cli", func(t *testing.T) {
+ if testharness.RunInIsolatedProcess(t) {
+ return
+ }
// Mirrors: Should_Propagate_Process_Options_To_Spawned_Cli
// Spawns a fake stdio CLI (a Node.js script) so we can assert that the
// SDK passes the right argv / env / cwd / RPC params through to the
@@ -233,6 +242,9 @@ func TestClientOptionsE2E(t *testing.T) {
})
t.Run("should send empty-mode custom agent locality defaults in initial requests", func(t *testing.T) {
+ if testharness.RunInIsolatedProcess(t) {
+ return
+ }
ctx := testharness.NewTestContext(t)
cliPath := filepath.Join(ctx.WorkDir, "fake-cli-empty-"+randomHex(t)+".js")
capturePath := filepath.Join(ctx.WorkDir, "fake-cli-empty-capture-"+randomHex(t)+".json")
@@ -301,6 +313,9 @@ func TestClientOptionsE2E(t *testing.T) {
})
t.Run("should forward advanced session creation options to the CLI", func(t *testing.T) {
+ if testharness.RunInIsolatedProcess(t) {
+ return
+ }
ctx := testharness.NewTestContext(t)
cliPath := filepath.Join(ctx.WorkDir, "fake-cli-"+randomHex(t)+".js")
capturePath := filepath.Join(ctx.WorkDir, "fake-cli-capture-"+randomHex(t)+".json")
@@ -449,6 +464,9 @@ func TestClientOptionsE2E(t *testing.T) {
})
t.Run("should forward singular provider configuration on session creation", func(t *testing.T) {
+ if testharness.RunInIsolatedProcess(t) {
+ return
+ }
ctx := testharness.NewTestContext(t)
cliPath := filepath.Join(ctx.WorkDir, "fake-cli-"+randomHex(t)+".js")
capturePath := filepath.Join(ctx.WorkDir, "fake-cli-capture-"+randomHex(t)+".json")
@@ -510,6 +528,9 @@ func TestClientOptionsE2E(t *testing.T) {
})
t.Run("should forward advanced session resume options to the CLI", func(t *testing.T) {
+ if testharness.RunInIsolatedProcess(t) {
+ return
+ }
ctx := testharness.NewTestContext(t)
cliPath := filepath.Join(ctx.WorkDir, "fake-cli-"+randomHex(t)+".js")
capturePath := filepath.Join(ctx.WorkDir, "fake-cli-capture-"+randomHex(t)+".json")
diff --git a/go/internal/e2e/main_test.go b/go/internal/e2e/main_test.go
new file mode 100644
index 0000000000..e116519b27
--- /dev/null
+++ b/go/internal/e2e/main_test.go
@@ -0,0 +1,12 @@
+package e2e
+
+import (
+ "os"
+ "testing"
+
+ "github.com/github/copilot-sdk/go/internal/e2e/testharness"
+)
+
+func TestMain(m *testing.M) {
+ os.Exit(testharness.RunWithInProcessIsolation(m))
+}
diff --git a/go/internal/e2e/pre_mcp_tool_call_hook_e2e_test.go b/go/internal/e2e/pre_mcp_tool_call_hook_e2e_test.go
index 111cfb86a4..bae12d9b12 100644
--- a/go/internal/e2e/pre_mcp_tool_call_hook_e2e_test.go
+++ b/go/internal/e2e/pre_mcp_tool_call_hook_e2e_test.go
@@ -11,10 +11,6 @@ import (
)
func TestPreMCPToolCallHookE2E(t *testing.T) {
- ctx := testharness.NewTestContext(t)
- client := ctx.NewClient()
- t.Cleanup(func() { client.ForceStop() })
-
testHarnessDir := testharness.RepoPath("test", "harness")
metaEchoServer := filepath.Join(testHarnessDir, "test-mcp-meta-echo-server.mjs")
@@ -30,7 +26,13 @@ func TestPreMCPToolCallHookE2E(t *testing.T) {
}
t.Run("should set meta via preMcpToolCall hook", func(t *testing.T) {
+ if testharness.RunInIsolatedProcess(t) {
+ return
+ }
+ ctx := testharness.NewTestContext(t)
ctx.ConfigureForTest(t)
+ client := ctx.NewClient()
+ t.Cleanup(func() { client.ForceStop() })
var (
mu sync.Mutex
@@ -93,7 +95,13 @@ func TestPreMCPToolCallHookE2E(t *testing.T) {
})
t.Run("should replace meta via preMcpToolCall hook", func(t *testing.T) {
+ if testharness.RunInIsolatedProcess(t) {
+ return
+ }
+ ctx := testharness.NewTestContext(t)
ctx.ConfigureForTest(t)
+ client := ctx.NewClient()
+ t.Cleanup(func() { client.ForceStop() })
var (
mu sync.Mutex
@@ -149,7 +157,13 @@ func TestPreMCPToolCallHookE2E(t *testing.T) {
})
t.Run("should remove meta via preMcpToolCall hook", func(t *testing.T) {
+ if testharness.RunInIsolatedProcess(t) {
+ return
+ }
+ ctx := testharness.NewTestContext(t)
ctx.ConfigureForTest(t)
+ client := ctx.NewClient()
+ t.Cleanup(func() { client.ForceStop() })
var (
mu sync.Mutex
diff --git a/go/internal/e2e/rpc_mcp_config_e2e_test.go b/go/internal/e2e/rpc_mcp_config_e2e_test.go
index 4e950fa3c8..b761c8ee0f 100644
--- a/go/internal/e2e/rpc_mcp_config_e2e_test.go
+++ b/go/internal/e2e/rpc_mcp_config_e2e_test.go
@@ -12,6 +12,9 @@ import (
// Tests server-scoped MCP configuration management via MCP.Config.* RPCs.
func TestRPCMCPConfigE2E(t *testing.T) {
t.Run("should call server MCP config rpcs", func(t *testing.T) {
+ if testharness.RunInIsolatedProcess(t) {
+ return
+ }
ctx := testharness.NewTestContext(t)
client := ctx.NewClient()
t.Cleanup(func() { client.ForceStop() })
@@ -105,6 +108,9 @@ func TestRPCMCPConfigE2E(t *testing.T) {
})
t.Run("should round trip http MCP oauth config rpc", func(t *testing.T) {
+ if testharness.RunInIsolatedProcess(t) {
+ return
+ }
ctx := testharness.NewTestContext(t)
client := ctx.NewClient()
t.Cleanup(func() { client.ForceStop() })
diff --git a/go/internal/e2e/rpc_server_e2e_test.go b/go/internal/e2e/rpc_server_e2e_test.go
index fb24309c1d..7cf7f4b74b 100644
--- a/go/internal/e2e/rpc_server_e2e_test.go
+++ b/go/internal/e2e/rpc_server_e2e_test.go
@@ -18,6 +18,9 @@ import (
// Tests server-scoped (non-session) RPCs.
func TestRPCServerE2E(t *testing.T) {
t.Run("should clear the managed settings cache", func(t *testing.T) {
+ if testharness.RunInIsolatedProcess(t) {
+ return
+ }
ctx := testharness.NewTestContext(t)
ctx.ConfigureForTest(t)
client := ctx.NewClient()
@@ -33,6 +36,9 @@ func TestRPCServerE2E(t *testing.T) {
})
t.Run("should call rpc ping with typed params and result", func(t *testing.T) {
+ if testharness.RunInIsolatedProcess(t) {
+ return
+ }
ctx := testharness.NewTestContext(t)
ctx.ConfigureForTest(t)
client := ctx.NewClient()
@@ -56,6 +62,9 @@ func TestRPCServerE2E(t *testing.T) {
})
t.Run("should call rpc models list with typed result", func(t *testing.T) {
+ if testharness.RunInIsolatedProcess(t) {
+ return
+ }
ctx := testharness.NewTestContext(t)
ctx.ConfigureForTest(t)
const token = "rpc-models-token"
@@ -89,6 +98,9 @@ func TestRPCServerE2E(t *testing.T) {
})
t.Run("should call rpc account get quota when authenticated", func(t *testing.T) {
+ if testharness.RunInIsolatedProcess(t) {
+ return
+ }
ctx := testharness.NewTestContext(t)
ctx.ConfigureForTest(t)
const token = "rpc-quota-token"
@@ -145,6 +157,9 @@ func TestRPCServerE2E(t *testing.T) {
})
t.Run("should call rpc tools list with typed result", func(t *testing.T) {
+ if testharness.RunInIsolatedProcess(t) {
+ return
+ }
ctx := testharness.NewTestContext(t)
ctx.ConfigureForTest(t)
client := ctx.NewClient()
@@ -169,6 +184,9 @@ func TestRPCServerE2E(t *testing.T) {
})
t.Run("should call rpc session fs set provider with typed result", func(t *testing.T) {
+ if testharness.RunInIsolatedProcess(t) {
+ return
+ }
ctx := testharness.NewTestContext(t)
client := ctx.NewClient()
t.Cleanup(func() { client.ForceStop() })
@@ -192,6 +210,9 @@ func TestRPCServerE2E(t *testing.T) {
})
t.Run("should add secret filter values", func(t *testing.T) {
+ if testharness.RunInIsolatedProcess(t) {
+ return
+ }
ctx := testharness.NewTestContext(t)
client := ctx.NewClient(func(opts *copilot.ClientOptions) {
opts.Env = append(opts.Env, "COPILOT_ENABLE_SECRET_FILTERING=true")
@@ -213,6 +234,9 @@ func TestRPCServerE2E(t *testing.T) {
})
t.Run("should return false for missing LLM response frames", func(t *testing.T) {
+ if testharness.RunInIsolatedProcess(t) {
+ return
+ }
ctx := testharness.NewTestContext(t)
client := ctx.NewClient()
t.Cleanup(func() { client.ForceStop() })
@@ -251,6 +275,9 @@ func TestRPCServerE2E(t *testing.T) {
})
t.Run("should list find and inspect persisted session state", func(t *testing.T) {
+ if testharness.RunInIsolatedProcess(t) {
+ return
+ }
ctx := testharness.NewTestContext(t)
token := "rpc-server-list-token-" + randomHex(t)
registerProxyUser(t, ctx, token, "rpc-user", nil)
@@ -344,6 +371,9 @@ func TestRPCServerE2E(t *testing.T) {
})
t.Run("should enrich basic session metadata", func(t *testing.T) {
+ if testharness.RunInIsolatedProcess(t) {
+ return
+ }
ctx := testharness.NewTestContext(t)
token := "rpc-server-enrich-token-" + randomHex(t)
registerProxyUser(t, ctx, token, "rpc-user", nil)
@@ -397,6 +427,9 @@ func TestRPCServerE2E(t *testing.T) {
})
t.Run("should close active session and release lock", func(t *testing.T) {
+ if testharness.RunInIsolatedProcess(t) {
+ return
+ }
ctx := testharness.NewTestContext(t)
token := "rpc-server-close-token-" + randomHex(t)
registerProxyUser(t, ctx, token, "rpc-user", nil)
@@ -434,6 +467,9 @@ func TestRPCServerE2E(t *testing.T) {
})
t.Run("should prune dry run and bulk delete persisted session", func(t *testing.T) {
+ if testharness.RunInIsolatedProcess(t) {
+ return
+ }
ctx := testharness.NewTestContext(t)
token := "rpc-server-delete-token-" + randomHex(t)
registerProxyUser(t, ctx, token, "rpc-user", nil)
@@ -500,6 +536,9 @@ func TestRPCServerE2E(t *testing.T) {
})
t.Run("should set additional plugins and reload deferred hooks", func(t *testing.T) {
+ if testharness.RunInIsolatedProcess(t) {
+ return
+ }
ctx := testharness.NewTestContext(t)
client := ctx.NewClient()
t.Cleanup(func() { client.ForceStop() })
@@ -546,6 +585,9 @@ func TestRPCServerE2E(t *testing.T) {
})
t.Run("should report implemented error when connecting unknown remote session", func(t *testing.T) {
+ if testharness.RunInIsolatedProcess(t) {
+ return
+ }
ctx := testharness.NewTestContext(t)
ctx.ConfigureWithoutSnapshot(t)
client := ctx.NewClient()
@@ -568,6 +610,9 @@ func TestRPCServerE2E(t *testing.T) {
})
t.Run("should discover server mcp and skills", func(t *testing.T) {
+ if testharness.RunInIsolatedProcess(t) {
+ return
+ }
ctx := testharness.NewTestContext(t)
ctx.ConfigureForTest(t)
client := ctx.NewClient()
diff --git a/go/internal/e2e/rpc_server_misc_e2e_test.go b/go/internal/e2e/rpc_server_misc_e2e_test.go
index 9607994676..8565975550 100644
--- a/go/internal/e2e/rpc_server_misc_e2e_test.go
+++ b/go/internal/e2e/rpc_server_misc_e2e_test.go
@@ -12,21 +12,27 @@ import (
func TestRpcServerMisc(t *testing.T) {
ctx := testharness.NewTestContext(t)
- sharedClient := ctx.NewClient()
- t.Cleanup(func() { sharedClient.ForceStop() })
t.Run("should_reload_user_settings", func(t *testing.T) {
+ if testharness.RunInIsolatedProcess(t) {
+ return
+ }
ctx.ConfigureForTest(t)
- if err := sharedClient.Start(t.Context()); err != nil {
+ client := ctx.NewClient()
+ t.Cleanup(func() { client.ForceStop() })
+ if err := client.Start(t.Context()); err != nil {
t.Fatalf("Start failed: %v", err)
}
- if _, err := sharedClient.RPC.User.Settings().Reload(t.Context()); err != nil {
+ if _, err := client.RPC.User.Settings().Reload(t.Context()); err != nil {
t.Fatalf("User.Settings.Reload failed: %v", err)
}
})
t.Run("should_get_set_and_clear_user_settings", func(t *testing.T) {
+ if testharness.RunInIsolatedProcess(t) {
+ return
+ }
ctx.ConfigureForTest(t)
client := newStartedIsolatedPortedClient(t, ctx)
defer client.ForceStop()
@@ -102,6 +108,9 @@ func TestRpcServerMisc(t *testing.T) {
})
t.Run("should_login_list_getcurrentauth_and_logout_account", func(t *testing.T) {
+ if testharness.RunInIsolatedProcess(t) {
+ return
+ }
ctx.ConfigureForTest(t)
if err := ctx.SetCopilotUserByToken("go-account-token", map[string]interface{}{
"login": "go-account-user",
@@ -190,6 +199,9 @@ func TestRpcServerMisc(t *testing.T) {
})
t.Run("should_report_agent_registry_spawn_gate_closed", func(t *testing.T) {
+ if testharness.RunInIsolatedProcess(t) {
+ return
+ }
ctx.ConfigureForTest(t)
client := newStartedIsolatedPortedClient(t, ctx)
defer client.ForceStop()
@@ -207,6 +219,9 @@ func TestRpcServerMisc(t *testing.T) {
})
t.Run("should_shut_down_owned_runtime", func(t *testing.T) {
+ if testharness.RunInIsolatedProcess(t) {
+ return
+ }
ctx.ConfigureForTest(t)
client := newStartedPortedClient(t, ctx)
defer client.ForceStop()
@@ -225,6 +240,9 @@ func TestRpcServerMisc(t *testing.T) {
})
t.Run("should_report_not_found_when_opening_session_without_context", func(t *testing.T) {
+ if testharness.RunInIsolatedProcess(t) {
+ return
+ }
ctx.ConfigureForTest(t)
client := newStartedIsolatedPortedClient(t, ctx)
defer client.ForceStop()
@@ -242,8 +260,13 @@ func TestRpcServerMisc(t *testing.T) {
})
t.Run("should_reject_send_attachments_from_non_extension_connection", func(t *testing.T) {
+ if testharness.RunInIsolatedProcess(t) {
+ return
+ }
ctx.ConfigureForTest(t)
- session := createPortedSession(t, sharedClient, nil)
+ client := ctx.NewClient()
+ t.Cleanup(func() { client.ForceStop() })
+ session := createPortedSession(t, client, nil)
defer session.Disconnect()
_, err := session.RPC.Extensions.SendAttachmentsToMessage(t.Context(), &rpc.SendAttachmentsToMessageParams{Attachments: []rpc.PushAttachment{}})
diff --git a/go/internal/e2e/rpc_server_plugins_e2e_test.go b/go/internal/e2e/rpc_server_plugins_e2e_test.go
index a9d1d243cc..2bea21cc3c 100644
--- a/go/internal/e2e/rpc_server_plugins_e2e_test.go
+++ b/go/internal/e2e/rpc_server_plugins_e2e_test.go
@@ -21,6 +21,9 @@ func TestRpcServerPlugins(t *testing.T) {
ctx := testharness.NewTestContext(t)
t.Run("should_install_and_list_plugin_from_local_marketplace", func(t *testing.T) {
+ if testharness.RunInIsolatedProcess(t) {
+ return
+ }
ctx.ConfigureForTest(t)
marketplaceDir := createPortedLocalMarketplaceFixture(t)
client := newStartedIsolatedPortedClient(t, ctx)
@@ -67,6 +70,9 @@ func TestRpcServerPlugins(t *testing.T) {
})
t.Run("should_enable_and_disable_marketplace_plugin", func(t *testing.T) {
+ if testharness.RunInIsolatedProcess(t) {
+ return
+ }
ctx.ConfigureForTest(t)
marketplaceDir := createPortedLocalMarketplaceFixture(t)
client := newStartedIsolatedPortedClient(t, ctx)
@@ -96,6 +102,9 @@ func TestRpcServerPlugins(t *testing.T) {
})
t.Run("should_update_single_marketplace_plugin", func(t *testing.T) {
+ if testharness.RunInIsolatedProcess(t) {
+ return
+ }
ctx.ConfigureForTest(t)
marketplaceDir := createPortedLocalMarketplaceFixture(t)
client := newStartedIsolatedPortedClient(t, ctx)
@@ -125,6 +134,9 @@ func TestRpcServerPlugins(t *testing.T) {
})
t.Run("should_update_all_installed_plugins", func(t *testing.T) {
+ if testharness.RunInIsolatedProcess(t) {
+ return
+ }
ctx.ConfigureForTest(t)
marketplaceDir := createPortedLocalMarketplaceFixture(t)
client := newStartedIsolatedPortedClient(t, ctx)
@@ -161,6 +173,9 @@ func TestRpcServerPlugins(t *testing.T) {
})
t.Run("should_install_direct_local_plugin_with_deprecation_warning", func(t *testing.T) {
+ if testharness.RunInIsolatedProcess(t) {
+ return
+ }
ctx.ConfigureForTest(t)
pluginDir := createPortedDirectPluginFixture(t)
client := newStartedIsolatedPortedClient(t, ctx)
@@ -210,6 +225,9 @@ func TestRpcServerPlugins(t *testing.T) {
})
t.Run("should_list_browse_refresh_and_remove_local_marketplace", func(t *testing.T) {
+ if testharness.RunInIsolatedProcess(t) {
+ return
+ }
ctx.ConfigureForTest(t)
marketplaceDir := createPortedLocalMarketplaceFixture(t)
client := newStartedIsolatedPortedClient(t, ctx)
@@ -292,6 +310,9 @@ func TestRpcServerPlugins(t *testing.T) {
})
t.Run("should_reload_mcp_config_cache", func(t *testing.T) {
+ if testharness.RunInIsolatedProcess(t) {
+ return
+ }
ctx.ConfigureForTest(t)
client := newStartedIsolatedPortedClient(t, ctx)
defer client.ForceStop()
diff --git a/go/internal/e2e/rpc_session_state_e2e_test.go b/go/internal/e2e/rpc_session_state_e2e_test.go
index f4870dbac6..25852021ed 100644
--- a/go/internal/e2e/rpc_session_state_e2e_test.go
+++ b/go/internal/e2e/rpc_session_state_e2e_test.go
@@ -42,20 +42,23 @@ func TestRPCSessionStateE2E(t *testing.T) {
}
})
- // The runtime caches /models per (auth, base_url) for 30 minutes (see
- // capi_client.rs LIST_MODELS_CACHE). Within this test function all subtests
- // share one CLI subprocess and proxy URL, so the first subtest's snapshot
- // models list is reused by every later one. SwitchTo needs gpt-5.4 in the
- // cache; rather than poison every other snapshot we give this subtest its
- // own dedicated client + proxy → its own cache entry.
+ // The runtime caches /models per (auth, base_url) for 30 minutes. SwitchTo
+ // needs gpt-5.4 in the cache, so use a distinct token to give this client an
+ // independent cache entry without changing the process-wide proxy.
t.Run("should call session rpc model switchTo", func(t *testing.T) {
- switchCtx := testharness.NewTestContext(t)
- switchClient := switchCtx.NewClient()
+ const switchToken = "go-rpc-session-state-switch-token"
+ if err := ctx.SetDefaultCopilotUserByToken(switchToken); err != nil {
+ t.Fatalf("Failed to configure switch client user: %v", err)
+ }
+ switchClient := ctx.NewClient(func(options *copilot.ClientOptions) {
+ options.BaseDirectory = t.TempDir()
+ options.GitHubToken = switchToken
+ })
t.Cleanup(func() { switchClient.ForceStop() })
if err := switchClient.Start(t.Context()); err != nil {
t.Fatalf("Failed to start switch client: %v", err)
}
- switchCtx.ConfigureForTest(t)
+ ctx.ConfigureForTest(t)
session, err := switchClient.CreateSession(t.Context(), &copilot.SessionConfig{
Model: "claude-sonnet-5",
diff --git a/go/internal/e2e/streaming_fidelity_e2e_test.go b/go/internal/e2e/streaming_fidelity_e2e_test.go
index 23b1d91a46..a939933e8a 100644
--- a/go/internal/e2e/streaming_fidelity_e2e_test.go
+++ b/go/internal/e2e/streaming_fidelity_e2e_test.go
@@ -293,9 +293,15 @@ func TestStreamingFidelityE2E(t *testing.T) {
})
t.Run("should emit streaming deltas with reasoning effort configured", func(t *testing.T) {
- reasoningCtx := testharness.NewTestContext(t)
- reasoningCtx.ConfigureForTest(t)
- reasoningClient := reasoningCtx.NewClient()
+ const reasoningToken = "go-streaming-reasoning-token"
+ if err := ctx.SetDefaultCopilotUserByToken(reasoningToken); err != nil {
+ t.Fatalf("Failed to configure reasoning client user: %v", err)
+ }
+ ctx.ConfigureForTest(t)
+ reasoningClient := ctx.NewClient(func(options *copilot.ClientOptions) {
+ options.BaseDirectory = t.TempDir()
+ options.GitHubToken = reasoningToken
+ })
t.Cleanup(func() { reasoningClient.ForceStop() })
// Verifies that setting ReasoningEffort alongside Streaming=true does not break
diff --git a/go/internal/e2e/testharness/context.go b/go/internal/e2e/testharness/context.go
index 56752c2d74..5948112437 100644
--- a/go/internal/e2e/testharness/context.go
+++ b/go/internal/e2e/testharness/context.go
@@ -2,6 +2,7 @@ package testharness
import (
"fmt"
+ "maps"
"os"
"os/exec"
"path/filepath"
@@ -57,21 +58,7 @@ type TestContext struct {
proxy *CapiProxy
- // In-process transport state. When the inprocess CI matrix cell is active the
- // worker inherits this process's ambient env and cwd (per-client env/working
- // directory are rejected in-process), so the isolated test env/cwd are mirrored
- // onto the real process and restored on Close.
- inProcess bool
- restoreEnv []envRestore
- restoreCwd string
-}
-
-// envRestore captures a single environment variable's prior value so the
-// in-process ambient mirror can be undone during teardown.
-type envRestore struct {
- key string
- prev string
- had bool
+ inProcess bool
}
// isInProcessTransport reports whether the in-process (FFI) transport is selected
@@ -175,15 +162,7 @@ func NewTestContext(t *testing.T) *TestContext {
os.RemoveAll(workDir)
t.Fatalf("Failed to initialize proxy: %v", err)
}
- if err := proxy.SetCopilotUserByToken(defaultGitHubToken, map[string]interface{}{
- "login": "e2e-test-user",
- "copilot_plan": "individual_pro",
- "endpoints": map[string]interface{}{
- "api": proxyURL,
- "telemetry": "https://localhost:1/telemetry",
- },
- "analytics_tracking_id": "e2e-test-tracking-id",
- }); err != nil {
+ if err := proxy.SetCopilotUserByToken(defaultGitHubToken, defaultCopilotUser(proxyURL)); err != nil {
if stopErr := proxy.StopWithOptions(true); stopErr != nil {
t.Logf("Failed to stop proxy after configuration error: %v", stopErr)
}
@@ -210,6 +189,18 @@ func NewTestContext(t *testing.T) *TestContext {
return ctx
}
+func defaultCopilotUser(proxyURL string) map[string]interface{} {
+ return map[string]interface{}{
+ "login": "e2e-test-user",
+ "copilot_plan": "individual_pro",
+ "endpoints": map[string]interface{}{
+ "api": proxyURL,
+ "telemetry": "https://localhost:1/telemetry",
+ },
+ "analytics_tracking_id": "e2e-test-tracking-id",
+ }
+}
+
// ConfigureForTest configures the proxy for a specific subtest.
// Call this at the start of each t.Run subtest.
func (c *TestContext) ConfigureForTest(t *testing.T) {
@@ -274,7 +265,6 @@ func (c *TestContext) Close(testFailed bool) error {
return err
}
}
- c.restoreInProcessEnvironment()
var proxyErr error
if c.proxy != nil {
if err := c.proxy.StopWithOptions(testFailed); err != nil {
@@ -290,20 +280,25 @@ func (c *TestContext) Close(testFailed bool) error {
return proxyErr
}
-// applyInProcessEnvironment mirrors the isolated test environment onto the real
-// process for in-process hosting: the worker inherits this process's env and cwd
-// at spawn, so per-test redirects must live on os.Environ and the process cwd.
-// Auth flows via GH_TOKEN/GITHUB_TOKEN (the FFI argv omits the stdio auth-token
-// wiring); the ambient HMAC signing key is removed process-wide at package load
-// (see init) so host-side auth matches the replay snapshots. mergedEnv is the
-// effective per-client env (harness defaults plus any per-test additions); workDir
-// is the effective working directory. Values are restored in Close. Safe to call
-// more than once (restores unwind in reverse).
-func (c *TestContext) applyInProcessEnvironment(mergedEnv []string, workDir string) {
+var inProcessEnvironment struct {
+ sync.Mutex
+ initialized bool
+ values map[string]string
+ workDir string
+}
+
+// initializeInProcessEnvironment configures the fresh test worker before its
+// first native host starts. The environment and working directory are never
+// changed again because native threads may continue reading either after a host
+// is disposed.
+func (c *TestContext) initializeInProcessEnvironment(mergedEnv []string, workDir string) {
inprocessEnv := map[string]string{}
for _, kv := range mergedEnv {
if key, value, ok := strings.Cut(kv, "="); ok {
- inprocessEnv[key] = value
+ key = normalizedEnvironmentKey(key)
+ if key != "" {
+ inprocessEnv[key] = value
+ }
}
}
// Auth flows via GH_TOKEN/GITHUB_TOKEN for the in-process host, overriding any
@@ -314,36 +309,35 @@ func (c *TestContext) applyInProcessEnvironment(mergedEnv []string, workDir stri
delete(inprocessEnv, "COPILOT_HMAC_KEY")
delete(inprocessEnv, "CAPI_HMAC_KEY")
+ inProcessEnvironment.Lock()
+ defer inProcessEnvironment.Unlock()
+ if inProcessEnvironment.initialized {
+ if !maps.Equal(inProcessEnvironment.values, inprocessEnv) || inProcessEnvironment.workDir != workDir {
+ panic("in-process E2E environment changed after native startup; run the test context in a fresh process")
+ }
+ return
+ }
+
for key, value := range inprocessEnv {
- prev, had := os.LookupEnv(key)
- c.restoreEnv = append(c.restoreEnv, envRestore{key: key, prev: prev, had: had})
- os.Setenv(key, value)
+ if err := os.Setenv(key, value); err != nil {
+ panic(fmt.Errorf("set in-process E2E environment variable %s: %w", key, err))
+ }
}
if workDir != "" {
- if c.restoreCwd == "" {
- if cwd, err := os.Getwd(); err == nil {
- c.restoreCwd = cwd
- }
+ if err := os.Chdir(workDir); err != nil {
+ panic(fmt.Errorf("set in-process E2E working directory: %w", err))
}
- os.Chdir(workDir)
}
+ inProcessEnvironment.initialized = true
+ inProcessEnvironment.values = maps.Clone(inprocessEnv)
+ inProcessEnvironment.workDir = workDir
}
-// restoreInProcessEnvironment undoes applyInProcessEnvironment during teardown.
-func (c *TestContext) restoreInProcessEnvironment() {
- for i := len(c.restoreEnv) - 1; i >= 0; i-- {
- r := c.restoreEnv[i]
- if r.had {
- os.Setenv(r.key, r.prev)
- } else {
- os.Unsetenv(r.key)
- }
- }
- c.restoreEnv = nil
- if c.restoreCwd != "" {
- os.Chdir(c.restoreCwd)
- c.restoreCwd = ""
+func normalizedEnvironmentKey(key string) string {
+ if runtime.GOOS == "windows" {
+ return strings.ToUpper(key)
}
+ return key
}
// GetExchanges retrieves the captured HTTP exchanges from the proxy.
@@ -385,6 +379,11 @@ func (c *TestContext) SetCopilotUserByToken(token string, response map[string]in
return c.proxy.SetCopilotUserByToken(token, response)
}
+// SetDefaultCopilotUserByToken registers the standard replay user for token.
+func (c *TestContext) SetDefaultCopilotUserByToken(token string) error {
+ return c.proxy.SetCopilotUserByToken(token, defaultCopilotUser(c.ProxyURL))
+}
+
// Env returns environment variables configured for isolated testing.
func (c *TestContext) Env() []string {
env := os.Environ()
@@ -436,7 +435,7 @@ func (c *TestContext) NewClient(opts ...func(*copilot.ClientOptions)) *copilot.C
// transport (TCP/URI/custom stdio) or configure per-client telemetry are left on
// their transport, mirroring the Node/.NET harnesses.
if c.inProcess && c.shouldUseInProcess(options) {
- c.applyInProcessEnvironment(options.Env, options.WorkingDirectory)
+ c.initializeInProcessEnvironment(options.Env, options.WorkingDirectory)
options.Connection = copilot.InProcessConnection{}
options.Env = nil
options.WorkingDirectory = ""
diff --git a/go/internal/e2e/testharness/inprocess_isolation.go b/go/internal/e2e/testharness/inprocess_isolation.go
new file mode 100644
index 0000000000..945077a6d3
--- /dev/null
+++ b/go/internal/e2e/testharness/inprocess_isolation.go
@@ -0,0 +1,216 @@
+package testharness
+
+import (
+ "context"
+ "flag"
+ "fmt"
+ "os"
+ "os/exec"
+ "regexp"
+ "strings"
+ "testing"
+ "time"
+)
+
+const isolatedInProcessTestEnv = "COPILOT_SDK_ISOLATED_INPROCESS_TEST"
+
+// RunWithInProcessIsolation runs each selected top-level test in a fresh
+// process when the FFI transport is selected.
+func RunWithInProcessIsolation(m *testing.M) int {
+ if !flag.Parsed() {
+ flag.Parse()
+ }
+ if !IsInProcessTransport() || os.Getenv(isolatedInProcessTestEnv) != "" {
+ return m.Run()
+ }
+
+ tests, err := listTopLevelTests()
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "list isolated in-process tests: %v\n", err)
+ return 1
+ }
+
+ runPattern, subtestPattern := selectedTestPatterns()
+ runRegexp, err := regexp.Compile(runPattern)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "compile -test.run pattern %q: %v\n", runPattern, err)
+ return 1
+ }
+
+ ctx := context.Background()
+ cancel := func() {}
+ start := time.Now()
+ if timeout := testTimeout(); timeout > 0 {
+ ctx, cancel = context.WithTimeout(ctx, timeout)
+ }
+ defer cancel()
+
+ failed := false
+ for _, name := range tests {
+ if !runRegexp.MatchString(name) {
+ continue
+ }
+
+ selector := "^" + regexp.QuoteMeta(name) + "$"
+ if subtestPattern != "" {
+ selector += "/" + subtestPattern
+ }
+ if err := runIsolatedProcess(ctx, name, selector, remainingTimeout(start)); err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ failed = true
+ }
+ }
+ if failed {
+ return 1
+ }
+ fmt.Println("PASS")
+ return 0
+}
+
+// RunInIsolatedProcess re-executes a subtest in a fresh process when its
+// top-level worker already owns a different in-process test environment.
+// Call it before creating a TestContext and return when it returns true.
+func RunInIsolatedProcess(t *testing.T) bool {
+ t.Helper()
+ if !IsInProcessTransport() {
+ return false
+ }
+ if isolatedTest := os.Getenv(isolatedInProcessTestEnv); isolatedTest == t.Name() {
+ return false
+ }
+
+ timeout := time.Duration(0)
+ if deadline, ok := t.Deadline(); ok {
+ timeout = time.Until(deadline)
+ if timeout <= 0 {
+ t.Fatal("Test deadline expired before isolated FFI execution")
+ }
+ }
+ selector := exactTestSelector(t.Name())
+ if err := runIsolatedProcess(t.Context(), t.Name(), selector, timeout); err != nil {
+ t.Fatal(err)
+ }
+ return true
+}
+
+func listTopLevelTests() ([]string, error) {
+ executable, err := os.Executable()
+ if err != nil {
+ return nil, err
+ }
+ command := exec.Command(executable, isolatedTestArgs(os.Args[1:], "^Test", true, 0)...)
+ command.Env = setEnvironmentValue(os.Environ(), isolatedInProcessTestEnv, "list")
+ output, err := command.CombinedOutput()
+ if err != nil {
+ return nil, fmt.Errorf("%w\n%s", err, output)
+ }
+
+ var tests []string
+ for _, line := range strings.Split(string(output), "\n") {
+ line = strings.TrimSpace(line)
+ if strings.HasPrefix(line, "Test") {
+ tests = append(tests, line)
+ }
+ }
+ return tests, nil
+}
+
+func runIsolatedProcess(ctx context.Context, name, selector string, timeout time.Duration) error {
+ executable, err := os.Executable()
+ if err != nil {
+ return fmt.Errorf("locate test executable: %w", err)
+ }
+ command := exec.CommandContext(ctx, executable, isolatedTestArgs(os.Args[1:], selector, false, timeout)...)
+ command.Env = setEnvironmentValue(os.Environ(), isolatedInProcessTestEnv, name)
+ command.WaitDelay = 5 * time.Second
+ output, err := command.CombinedOutput()
+ fmt.Print(string(output))
+ if err != nil {
+ return fmt.Errorf("isolated FFI test %s failed: %w", name, err)
+ }
+ if !strings.Contains(string(output), "--- PASS: "+name+" (") &&
+ !strings.Contains(string(output), "--- SKIP: "+name+" (") {
+ return fmt.Errorf("isolated FFI process did not report completing %s", name)
+ }
+ return nil
+}
+
+func isolatedTestArgs(args []string, selector string, list bool, timeout time.Duration) []string {
+ result := make([]string, 0, len(args)+5)
+ for i := 0; i < len(args); i++ {
+ arg := args[i]
+ key := arg
+ if before, _, ok := strings.Cut(arg, "="); ok {
+ key = before
+ }
+ switch key {
+ case "-test.run", "-test.list", "-test.timeout", "-test.count", "-test.v",
+ "-test.coverprofile", "-test.testlogfile":
+ if arg == key && i+1 < len(args) {
+ i++
+ }
+ continue
+ }
+ result = append(result, arg)
+ }
+ if list {
+ return append(result, "-test.list="+selector)
+ }
+ result = append(result, "-test.run="+selector, "-test.count=1", "-test.v=true")
+ if timeout > 0 {
+ result = append(result, "-test.timeout="+timeout.String())
+ }
+ return result
+}
+
+func selectedTestPatterns() (string, string) {
+ run := ""
+ if runFlag := flag.Lookup("test.run"); runFlag != nil {
+ run = runFlag.Value.String()
+ }
+ if run == "" {
+ return ".", ""
+ }
+ topLevel, subtest, _ := strings.Cut(run, "/")
+ return topLevel, subtest
+}
+
+func testTimeout() time.Duration {
+ timeoutFlag := flag.Lookup("test.timeout")
+ if timeoutFlag == nil {
+ return 0
+ }
+ timeout, _ := time.ParseDuration(timeoutFlag.Value.String())
+ return timeout
+}
+
+func remainingTimeout(start time.Time) time.Duration {
+ timeout := testTimeout()
+ if timeout == 0 {
+ return 0
+ }
+ remaining := timeout - time.Since(start)
+ if remaining < 0 {
+ return time.Nanosecond
+ }
+ return remaining
+}
+
+func exactTestSelector(name string) string {
+ parts := strings.Split(name, "/")
+ for i, part := range parts {
+ parts[i] = "^" + regexp.QuoteMeta(part) + "$"
+ }
+ return strings.Join(parts, "/")
+}
+
+func setEnvironmentValue(env []string, key, value string) []string {
+ prefix := key + "="
+ result := make([]string, 0, len(env)+1)
+ for _, entry := range env {
+ if !strings.HasPrefix(entry, prefix) {
+ result = append(result, entry)
+ }
+ }
+ return append(result, prefix+value)
+}
diff --git a/go/internal/e2e/testharness/inprocess_isolation_test.go b/go/internal/e2e/testharness/inprocess_isolation_test.go
new file mode 100644
index 0000000000..e8c773478a
--- /dev/null
+++ b/go/internal/e2e/testharness/inprocess_isolation_test.go
@@ -0,0 +1,122 @@
+package testharness
+
+import (
+ "os"
+ "os/exec"
+ "reflect"
+ "strings"
+ "testing"
+)
+
+func TestIsolatedTestArgs(t *testing.T) {
+ args := []string{
+ "-test.run=original",
+ "-test.count", "3",
+ "-test.gocoverdir=coverage",
+ "-test.coverprofile=parent.out",
+ }
+ got := isolatedTestArgs(args, `^TestExample$/^case_\[1\]$`, false, 0)
+ want := []string{
+ "-test.gocoverdir=coverage",
+ `-test.run=^TestExample$/^case_\[1\]$`,
+ "-test.count=1",
+ "-test.v=true",
+ }
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("Unexpected isolated arguments: %v", got)
+ }
+ if len(args) != 5 || args[0] != "-test.run=original" {
+ t.Fatal("Parent test arguments were modified")
+ }
+}
+
+func TestExactTestSelector(t *testing.T) {
+ got := exactTestSelector("TestExample/case_[1]")
+ want := `^TestExample$/^case_\[1\]$`
+ if got != want {
+ t.Fatalf("Unexpected selector: got %q, want %q", got, want)
+ }
+}
+
+func TestRunInIsolatedProcess(t *testing.T) {
+ const scenarioEnv = "COPILOT_SDK_ISOLATION_HELPER_SCENARIO"
+ if scenario := os.Getenv(scenarioEnv); scenario != "" {
+ if RunInIsolatedProcess(t) {
+ return
+ }
+ if !IsInProcessTransport() || os.Getenv(isolatedInProcessTestEnv) != t.Name() {
+ t.Fatal("Isolated child did not retain the in-process transport")
+ }
+ if scenario == "failure" {
+ t.Fatal("intentional isolated failure")
+ }
+ if scenario == "skip" {
+ t.Skip("intentional isolated skip")
+ }
+ t.Log("isolated assertions executed")
+ return
+ }
+
+ executable, err := os.Executable()
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, scenario := range []string{"success", "failure", "skip"} {
+ t.Run(scenario, func(t *testing.T) {
+ command := exec.CommandContext(t.Context(), executable, isolatedTestArgs(os.Args[1:], "^TestRunInIsolatedProcess$", false, 0)...)
+ command.Env = append(os.Environ(),
+ "COPILOT_SDK_DEFAULT_CONNECTION=inprocess",
+ isolatedInProcessTestEnv+"=",
+ scenarioEnv+"="+scenario,
+ )
+ output, err := command.CombinedOutput()
+ if scenario == "failure" {
+ if err == nil || !strings.Contains(string(output), "intentional isolated failure") {
+ t.Fatalf("Isolated failure was not propagated: %v\n%s", err, output)
+ }
+ } else if scenario == "skip" {
+ if err != nil || !strings.Contains(string(output), "--- SKIP: TestRunInIsolatedProcess") {
+ t.Fatalf("Isolated skip was not preserved: %v\n%s", err, output)
+ }
+ } else if err != nil || !strings.Contains(string(output), "isolated assertions executed") {
+ t.Fatalf("Isolated assertions did not pass: %v\n%s", err, output)
+ }
+ })
+ }
+}
+
+func TestInitializeInProcessEnvironmentRejectsChanges(t *testing.T) {
+ originalCwd, err := os.Getwd()
+ if err != nil {
+ t.Fatal(err)
+ }
+ workDir := t.TempDir()
+ t.Cleanup(func() {
+ if err := os.Chdir(originalCwd); err != nil {
+ t.Errorf("Restore working directory: %v", err)
+ }
+ inProcessEnvironment.Lock()
+ inProcessEnvironment.initialized = false
+ inProcessEnvironment.values = nil
+ inProcessEnvironment.workDir = ""
+ inProcessEnvironment.Unlock()
+ })
+
+ context := &TestContext{CLIPath: os.Args[0]}
+ env := append(os.Environ(), "COPILOT_SDK_ISOLATION_TEST_VALUE=first")
+ context.initializeInProcessEnvironment(env, workDir)
+ context.initializeInProcessEnvironment(env, workDir)
+
+ defer func() {
+ if recover() == nil {
+ t.Error("Changing the environment after initialization must fail")
+ }
+ if os.Getenv("COPILOT_SDK_ISOLATION_TEST_VALUE") != "first" {
+ t.Error("Initialized process environment was changed")
+ }
+ }()
+ context.initializeInProcessEnvironment(
+ append(os.Environ(), "COPILOT_SDK_ISOLATION_TEST_VALUE=second"),
+ workDir,
+ )
+}
diff --git a/python/e2e/timeout_diagnostics.py b/python/e2e/timeout_diagnostics.py
index c02f069246..f72637779c 100644
--- a/python/e2e/timeout_diagnostics.py
+++ b/python/e2e/timeout_diagnostics.py
@@ -17,6 +17,7 @@
import pytest
from copilot._jsonrpc import JsonRpcClient
+from copilot.client import CopilotClient
_TIMEOUT_DIAGNOSTICS = pytest.StashKey[tuple[str, Path | None]]()
@@ -43,7 +44,7 @@ def capture_timeout(signum, frame):
signal.signal(signal.SIGALRM, capture_timeout)
-def _dump_awaitable(awaitable, output, seen=None):
+def _dump_awaitable(awaitable, output, seen=None, clients=None):
if seen is None:
seen = set()
while awaitable is not None and id(awaitable) not in seen:
@@ -62,6 +63,10 @@ def _dump_awaitable(awaitable, output, seen=None):
if frame is not None:
code = frame.f_code
print(f" {code.co_filename}:{frame.f_lineno} in {code.co_qualname}", file=output)
+ if clients is not None:
+ for value in frame.f_locals.values():
+ if isinstance(value, CopilotClient):
+ clients[id(value)] = value
# Do not dump arbitrary locals, RPC payloads, prompts, tokens, or results.
if code is JsonRpcClient.request.__code__:
values = frame.f_locals
@@ -85,11 +90,14 @@ def _dump_awaitable(awaitable, output, seen=None):
if type(awaitable).__name__ in ("async_generator_asend", "async_generator_athrow"):
for referent in gc.get_referents(awaitable):
if inspect.isasyncgen(referent):
- _dump_awaitable(referent, output, seen)
+ _dump_awaitable(referent, output, seen, clients)
awaitable = next_awaitable
def _dump_client(client, output):
+ process = getattr(client, "_cli_process", None)
+ if isinstance(process, subprocess.Popen):
+ print(f"CLI pid={process.pid} returncode={process.returncode}", file=output)
rpc = client._client
if rpc is not None:
reader = rpc._read_thread
@@ -126,10 +134,23 @@ def _dump_client(client, output):
)
-def _sample_native_threads(path: Path, output):
+def _native_process_ids(clients):
+ process_ids = set()
+ for client in clients:
+ process = getattr(client, "_cli_process", None)
+ if isinstance(process, subprocess.Popen) and process.poll() is None:
+ process_ids.add(process.pid)
+ if getattr(client, "_ffi_host", None) is not None:
+ process_ids.add(os.getpid())
+ return process_ids
+
+
+def _sample_native_threads(path: Path, output, process_id=None):
+ if process_id is None:
+ process_id = os.getpid()
try:
result = subprocess.run(
- ["sample", str(os.getpid()), "1", "-file", str(path)],
+ ["sample", str(process_id), "1", "-file", str(path)],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
@@ -147,11 +168,13 @@ def _collect_timeout_diagnostics(item):
output = io.StringIO()
print(f"Test: {item.nodeid}\nPID: {os.getpid()}", file=output)
client = None
+ clients = {}
try:
context = item.funcargs.get("ctx")
client = getattr(context, "_client", None)
loops = set()
if client is not None:
+ clients[id(client)] = client
_dump_client(client, output)
if client._client is not None and client._client._loop is not None:
loops.add(client._client._loop)
@@ -165,7 +188,10 @@ def _collect_timeout_diagnostics(item):
f"Task {task.get_name()} done={task.done()} cancelling={task.cancelling()}",
file=output,
)
- _dump_awaitable(task.get_coro(), output)
+ _dump_awaitable(task.get_coro(), output, clients=clients)
+ for pending_client in clients.values():
+ if pending_client is not client:
+ _dump_client(pending_client, output)
names = {thread.ident: thread.name for thread in threading.enumerate()}
for ident, frame in sys._current_frames().items():
print(f"Thread {ident} ({names.get(ident, 'native')})", file=output)
@@ -180,8 +206,11 @@ def _collect_timeout_diagnostics(item):
stem = f"{os.getpid()}-{uuid.uuid4().hex}"
path = directory / f"{stem}.txt"
path.write_text(output.getvalue(), encoding="utf-8")
- if sys.platform == "darwin" and getattr(client, "_ffi_host", None) is not None:
- _sample_native_threads(directory / f"{stem}.sample.txt", output)
+ if sys.platform == "darwin":
+ for process_id in sorted(_native_process_ids(clients.values())):
+ _sample_native_threads(
+ directory / f"{stem}-{process_id}.sample.txt", output, process_id
+ )
print(f"Diagnostics saved to {path}", file=output)
path.write_text(output.getvalue(), encoding="utf-8")
except Exception as exc:
diff --git a/python/test_timeout_diagnostics.py b/python/test_timeout_diagnostics.py
index 25a57494c7..5ec7fa1a39 100644
--- a/python/test_timeout_diagnostics.py
+++ b/python/test_timeout_diagnostics.py
@@ -12,6 +12,7 @@
import pytest
from copilot._jsonrpc import JsonRpcClient
+from copilot.client import CopilotClient, RuntimeConnection
from copilot.session import CopilotSession
from e2e import timeout_diagnostics
from e2e.timeout_diagnostics import (
@@ -139,7 +140,8 @@ def test_non_timeout_does_not_collect_diagnostics(error):
assert report.sections == []
-def test_native_sample_is_bounded_and_targets_this_worker(tmp_path, monkeypatch):
+@pytest.mark.parametrize("process_id", [None, 12345])
+def test_native_sample_is_bounded_and_targets_requested_process(tmp_path, monkeypatch, process_id):
calls = []
def run(args, **kwargs):
@@ -149,13 +151,64 @@ def run(args, **kwargs):
monkeypatch.setattr(subprocess, "run", run)
path = tmp_path / "native.sample.txt"
output = io.StringIO()
- _sample_native_threads(path, output)
+ _sample_native_threads(path, output, process_id)
args, options = calls[0]
- assert args == ["sample", str(os.getpid()), "1", "-file", str(path)]
+ expected_pid = os.getpid() if process_id is None else process_id
+ assert args == ["sample", str(expected_pid), "1", "-file", str(path)]
assert options["timeout"] == 10
assert "Native sample exit=0" in output.getvalue()
+async def test_timeout_samples_owned_cli_from_pending_test_frame(tmp_path, monkeypatch):
+ rpc = JsonRpcClient(None)
+ rpc._loop = asyncio.get_running_loop()
+ context_client = SimpleNamespace(_client=rpc, _sessions={}, _ffi_host=None)
+ item = SimpleNamespace(
+ nodeid="test_external_resume",
+ config=SimpleNamespace(rootpath=tmp_path),
+ funcargs={"ctx": SimpleNamespace(_client=context_client)},
+ )
+ owner = CopilotClient(connection=RuntimeConnection.for_stdio(path=sys.executable))
+ process = subprocess.Popen(
+ [sys.executable, "-c", "import sys; sys.stdin.read()"],
+ stdin=subprocess.PIPE,
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ )
+ owner._cli_process = process
+ started = asyncio.Event()
+ samples = []
+
+ async def pending_test(server):
+ started.set()
+ await asyncio.Future()
+
+ def sample(path, output, process_id=None):
+ samples.append(process_id)
+ path.write_text("native stack", encoding="utf-8")
+
+ task = asyncio.create_task(pending_test(owner))
+ try:
+ await started.wait()
+ monkeypatch.setattr(sys, "platform", "darwin")
+ monkeypatch.setattr(timeout_diagnostics, "_sample_native_threads", sample)
+ text, path = timeout_diagnostics._collect_timeout_diagnostics(item)
+
+ assert samples == [process.pid]
+ assert f"CLI pid={process.pid}" in text
+ assert path is not None
+ (native_sample,) = path.parent.glob("*.sample.txt")
+ assert native_sample.read_text(encoding="utf-8") == "native stack"
+ finally:
+ task.cancel()
+ await asyncio.gather(task, return_exceptions=True)
+ process.terminate()
+ process.wait(timeout=10)
+ if process.stdin is not None:
+ process.stdin.close()
+ owner._cli_process = None
+
+
@pytest.mark.parametrize(
"error", [FileNotFoundError(), subprocess.TimeoutExpired("sample", timeout=10)]
)
diff --git a/rust/tests/e2e/pending_work_resume.rs b/rust/tests/e2e/pending_work_resume.rs
index f695e7114d..f218d84c9a 100644
--- a/rust/tests/e2e/pending_work_resume.rs
+++ b/rust/tests/e2e/pending_work_resume.rs
@@ -1,5 +1,6 @@
use std::net::TcpListener;
use std::sync::Arc;
+use std::time::Instant;
use async_trait::async_trait;
use github_copilot_sdk::handler::ApproveAllHandler;
@@ -145,32 +146,49 @@ async fn should_resume_successfully_when_no_pending_work_exists() {
"should_resume_successfully_when_no_pending_work_exists",
|ctx| {
Box::pin(async move {
+ // The outer timeout otherwise hides which lifecycle operation stalled.
+ let started = Instant::now();
+ let phase = |name| {
+ eprintln!(
+ "pending_work_resume/should_resume_successfully_when_no_pending_work_exists [{:?}]: {name}",
+ started.elapsed()
+ );
+ };
ctx.set_default_copilot_user();
let port = free_tcp_port();
+ phase("start managed TCP server");
let server = start_tcp_server(ctx, port).await;
+ phase("start first external client");
let first_client = start_external_client(ctx, port).await;
+ phase("create first session");
let session1 = first_client
.create_session(ctx.approve_all_session_config())
.await
.expect("create session");
let session_id = session1.id().clone();
+ phase("send and wait for first turn");
let first = session1
.send_and_wait("Reply with exactly: NO_PENDING_TURN_ONE")
.await
.expect("send first")
.expect("first answer");
assert!(assistant_message_content(&first).contains("NO_PENDING_TURN_ONE"));
+ phase("disconnect first session");
session1
.disconnect()
.await
.expect("disconnect first session");
+ phase("force-stop first external client");
first_client.force_stop();
+ phase("start resumed external client");
let resumed_client = start_external_client(ctx, port).await;
+ phase("resume session with continuePendingWork=true");
let session2 = resumed_client
.resume_session(resume_config(session_id).with_continue_pending_work(true))
.await
.expect("resume session");
+ phase("send and wait for resumed turn");
let follow_up = session2
.send_and_wait("Reply with exactly: NO_PENDING_TURN_TWO")
.await
@@ -178,12 +196,16 @@ async fn should_resume_successfully_when_no_pending_work_exists() {
.expect("follow-up answer");
assert!(assistant_message_content(&follow_up).contains("NO_PENDING_TURN_TWO"));
+ phase("disconnect resumed session");
session2
.disconnect()
.await
.expect("disconnect resumed session");
+ phase("force-stop resumed external client");
resumed_client.force_stop();
+ phase("stop managed TCP server");
server.stop().await.expect("stop server client");
+ phase("completed");
})
},
)
diff --git a/rust/tests/e2e/subagent_hooks.rs b/rust/tests/e2e/subagent_hooks.rs
index fe94c36779..616829ebcc 100644
--- a/rust/tests/e2e/subagent_hooks.rs
+++ b/rust/tests/e2e/subagent_hooks.rs
@@ -1,3 +1,4 @@
+use std::cell::Cell;
use std::sync::Arc;
use async_trait::async_trait;
@@ -5,13 +6,14 @@ use github_copilot_sdk::hooks::{
HookContext, PostToolUseInput, PostToolUseOutput, PreToolUseInput, PreToolUseOutput,
SessionHooks,
};
+use github_copilot_sdk::session_events::SessionEventType;
use github_copilot_sdk::{
CopilotHttpRequest, CopilotHttpResponse, CopilotRequestContext, CopilotRequestError,
CopilotRequestHandler, forward_http,
};
use parking_lot::Mutex;
-use super::support::with_e2e_context;
+use super::support::{assistant_message_content, wait_for_event, with_e2e_context};
#[tokio::test]
async fn should_invoke_pretooluse_and_posttooluse_hooks_for_sub_agent_tool_calls() {
@@ -49,14 +51,30 @@ async fn should_invoke_pretooluse_and_posttooluse_hooks_for_sub_agent_tool_calls
.await
.expect("create session");
- session
- .send_and_wait(
+ let saw_final_response = Cell::new(false);
+ let completion = wait_for_event(
+ session.subscribe(),
+ "parent's subagent result followed by session.idle",
+ |event| {
+ if event.parsed_type() == SessionEventType::AssistantMessage {
+ let content = assistant_message_content(event);
+ if content.contains("Hello from subagent test!") {
+ saw_final_response.set(true);
+ }
+ }
+ event.parsed_type() == SessionEventType::SessionIdle
+ && saw_final_response.get()
+ },
+ );
+ let (send_result, _) = tokio::join!(
+ session.send_and_wait(
"Use the task tool to spawn an explore agent that reads the file \
subagent-test.txt in the current directory and reports its contents. \
You must use the task tool.",
- )
- .await
- .expect("send");
+ ),
+ completion,
+ );
+ send_result.expect("send");
let log = hook_log.lock().clone();
diff --git a/rust/tests/fixtures/host_crash_fixture.rs b/rust/tests/fixtures/host_crash_fixture.rs
index c688cf3e87..8c48379001 100644
--- a/rust/tests/fixtures/host_crash_fixture.rs
+++ b/rust/tests/fixtures/host_crash_fixture.rs
@@ -11,13 +11,16 @@
//! - `HOST_CRASH_FIXTURE_CWD`: working directory for the spawned CLI.
//! - `HOST_CRASH_FIXTURE_ENV_JSON`: JSON array of `[key, value]` pairs to set
//! on the spawned CLI's environment.
-//! - `HOST_CRASH_FIXTURE_PID_FILE`: path this process writes the CLI child's
-//! OS process id to, once the client finishes starting.
+//! - `HOST_CRASH_FIXTURE_PID_FILE`: path this process atomically publishes the
+//! CLI child's OS process id to, once the client finishes starting.
+use std::io::Write;
use std::path::PathBuf;
use github_copilot_sdk::{CliProgram, Client, ClientOptions, Transport};
+mod pid_file;
+
#[tokio::main(flavor = "current_thread")]
async fn main() {
let program = std::env::var("HOST_CRASH_FIXTURE_PROGRAM").expect("HOST_CRASH_FIXTURE_PROGRAM");
@@ -45,7 +48,12 @@ async fn main() {
let client = Client::start(options).await.expect("start CLI client");
let pid = client.pid().expect("client reports spawned CLI pid");
- std::fs::write(&pid_file, pid.to_string()).expect("write pid file");
+ tokio::task::spawn_blocking(move || {
+ pid_file::publish_pid_file(&pid_file, |file| write!(file, "{pid}"))
+ })
+ .await
+ .expect("join PID file writer")
+ .expect("write pid file");
// Deliberately leak the client so nothing in this process — including its
// `Drop` impls — ever runs cleanup code. The external test process
diff --git a/rust/tests/fixtures/pid_file.rs b/rust/tests/fixtures/pid_file.rs
new file mode 100644
index 0000000000..299fa62044
--- /dev/null
+++ b/rust/tests/fixtures/pid_file.rs
@@ -0,0 +1,14 @@
+use std::fs::File;
+use std::io;
+use std::path::Path;
+
+pub fn publish_pid_file(
+ path: &Path,
+ write_pid: impl FnOnce(&mut File) -> io::Result<()>,
+) -> io::Result<()> {
+ let staging_path = path.with_extension("pid.tmp");
+ let mut file = File::create(&staging_path)?;
+ write_pid(&mut file)?;
+ drop(file);
+ std::fs::rename(staging_path, path)
+}
diff --git a/rust/tests/pid_file_test.rs b/rust/tests/pid_file_test.rs
new file mode 100644
index 0000000000..4b33c5a0f4
--- /dev/null
+++ b/rust/tests/pid_file_test.rs
@@ -0,0 +1,38 @@
+use std::io::{self, Write};
+
+#[path = "fixtures/pid_file.rs"]
+mod pid_file;
+
+#[test]
+fn publishes_only_the_complete_pid() {
+ let dir = tempfile::tempdir().expect("create test directory");
+ let path = dir.path().join("cli.pid");
+
+ pid_file::publish_pid_file(&path, |file| {
+ assert!(!path.exists(), "an empty PID must not be visible");
+ file.write_all(b"12")?;
+ assert!(!path.exists(), "a partial PID must not be visible");
+ file.write_all(b"345")
+ })
+ .expect("publish complete PID");
+
+ assert_eq!(
+ std::fs::read_to_string(path).expect("read published PID"),
+ "12345"
+ );
+}
+
+#[test]
+fn does_not_publish_a_failed_write() {
+ let dir = tempfile::tempdir().expect("create test directory");
+ let path = dir.path().join("cli.pid");
+
+ let error = pid_file::publish_pid_file(&path, |file| {
+ file.write_all(b"12")?;
+ Err(io::Error::other("incomplete PID"))
+ })
+ .expect_err("reject incomplete PID write");
+
+ assert_eq!(error.kind(), io::ErrorKind::Other);
+ assert!(!path.exists(), "a failed PID write must not become ready");
+}
diff --git a/test/harness/replayingCapiProxy.test.ts b/test/harness/replayingCapiProxy.test.ts
index 062fe89ae9..c59397ebbe 100644
--- a/test/harness/replayingCapiProxy.test.ts
+++ b/test/harness/replayingCapiProxy.test.ts
@@ -11,7 +11,7 @@ import type {
} from "openai/resources/chat/completions";
import os from "os";
import path from "path";
-import { afterEach, beforeEach, describe, expect, test } from "vitest";
+import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import yaml from "yaml";
import {
NormalizedData,
@@ -803,6 +803,107 @@ Always include PINEAPPLE_COCONUT_42.
});
}
+ test.each([
+ ["should_accept_blob_attachments", "pixel.png"],
+ ["vision_disabled_then_enabled_via_setmodel", "test.png"],
+ ])(
+ "replays only the recorded image histories for %s",
+ async (snapshot, filename) => {
+ process.env.GITHUB_ACTIONS = "true";
+ const cachePath = path.join(
+ import.meta.dirname,
+ "..",
+ "snapshots",
+ "session_config",
+ `${snapshot}.yaml`,
+ );
+ const stored = await readYamlOutput(cachePath);
+ const messages = stored.conversations.at(-1)!.messages;
+ const finalResponse = messages.at(-1)!;
+ expect(finalResponse.role).toBe("assistant");
+ expect(finalResponse.content).toBeTruthy();
+ const imageDescription = `Image file at path ${workDir}/${filename}`;
+ const limitMessage = (limit: number) =>
+ `You've reached the maximum number of images you can view (${limit}) so I can't provide the image for you to see.`;
+ const proxy = new ReplayingCapiProxy(
+ "http://localhost:1",
+ cachePath,
+ workDir,
+ );
+ const proxyUrl = await proxy.start();
+
+ try {
+ for (const imagePart of [
+ {
+ type: "image_url",
+ image_url: {
+ url: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
+ },
+ },
+ { type: "text", text: limitMessage(1) },
+ ]) {
+ const response = await makeRequest(proxyUrl, "/chat/completions", {
+ body: {
+ model: stored.models[0],
+ messages: [
+ ...messages.slice(0, -2),
+ {
+ role: "user",
+ content: [
+ { type: "text", text: imageDescription },
+ imagePart,
+ ],
+ },
+ ],
+ },
+ });
+ expect(response.status).toBe(200);
+ const completion = JSON.parse(response.body) as ChatCompletion;
+ expect(completion.choices[0].message.content).toBe(
+ finalResponse.content,
+ );
+ expect(completion.choices[0].finish_reason).toBe("stop");
+ }
+
+ const stderr = vi
+ .spyOn(process.stderr, "write")
+ .mockReturnValue(true);
+ const consoleError = vi
+ .spyOn(console, "error")
+ .mockImplementation(() => {});
+ try {
+ for (const content of [
+ imageDescription,
+ `${imageDescription}\n${limitMessage(2)}`,
+ ]) {
+ const response = await makeRequest(
+ proxyUrl,
+ "/chat/completions",
+ {
+ body: {
+ model: stored.models[0],
+ messages: [
+ ...messages.slice(0, -2),
+ { role: "user", content },
+ ],
+ },
+ },
+ );
+ expect(response.status).toBe(500);
+ expect(proxy.exchanges.at(-1)?.response?.body).toContain(
+ "No cached response found for POST /chat/completions.",
+ );
+ }
+ } finally {
+ stderr.mockRestore();
+ consoleError.mockRestore();
+ }
+ } finally {
+ await proxy.stop(true);
+ }
+ },
+ );
+
test("returns cached response when request matches prefix", async () => {
const cachePath = path.join(tempDir, "cache.yaml");
const cacheContent = yaml.stringify({
diff --git a/test/harness/subagentHooksReplay.test.ts b/test/harness/subagentHooksReplay.test.ts
new file mode 100644
index 0000000000..65c94731f8
--- /dev/null
+++ b/test/harness/subagentHooksReplay.test.ts
@@ -0,0 +1,207 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ *--------------------------------------------------------------------------------------------*/
+
+import { readFile } from "node:fs/promises";
+import path from "node:path";
+import type {
+ ChatCompletion,
+ ChatCompletionChunk,
+ ChatCompletionMessage,
+ ChatCompletionMessageFunctionToolCall,
+} from "openai/resources/chat/completions";
+import { afterEach, beforeEach, expect, test, vi } from "vitest";
+import yaml from "yaml";
+import { type NormalizedData, ReplayingCapiProxy } from "./replayingCapiProxy";
+
+type NormalizedMessage =
+ NormalizedData["conversations"][number]["messages"][number];
+
+const snapshotPath = path.join(
+ import.meta.dirname,
+ "..",
+ "snapshots",
+ "subagent_hooks",
+ "should_invoke_pretooluse_and_posttooluse_hooks_for_sub_agent_tool_calls.yaml",
+);
+const stored = yaml.parse(
+ await readFile(snapshotPath, "utf8"),
+) as NormalizedData;
+const original = stored.conversations[3].messages;
+const [waiting, notification, readAgent, toolResult, finalAnswer] =
+ original.slice(5);
+const earlyReply = { ...waiting, tool_calls: readAgent.tool_calls };
+const rawNotification: NormalizedMessage = {
+ role: "user",
+ content:
+ '\nAgent "read-file" (explore) has finished processing and is now idle. ' +
+ 'Use read_agent with agent_id "fa1ad5a2-aef9-4cd1-996d-85295154e583" to read the results, ' +
+ "or write_agent to send follow-up messages.\n",
+};
+
+beforeEach(() => {
+ vi.stubEnv("GITHUB_ACTIONS", "true");
+});
+
+afterEach(() => {
+ vi.unstubAllEnvs();
+});
+
+async function readReply(response: Response, streaming: boolean) {
+ if (!streaming) {
+ return ((await response.json()) as ChatCompletion).choices[0].message;
+ }
+ let content = "";
+ const toolCalls: ChatCompletionMessageFunctionToolCall[] = [];
+ for (const line of (await response.text()).split("\n")) {
+ if (!line.startsWith("data: ") || line === "data: [DONE]") continue;
+ const chunk = JSON.parse(line.slice(6)) as ChatCompletionChunk;
+ for (const choice of chunk.choices) {
+ content += choice.delta.content ?? "";
+ for (const call of choice.delta.tool_calls ?? []) {
+ const tool = (toolCalls[call.index] ??= {
+ id: "",
+ type: "function",
+ function: { name: "", arguments: "" },
+ });
+ tool.id += call.id ?? "";
+ tool.function.name += call.function?.name ?? "";
+ tool.function.arguments += call.function?.arguments ?? "";
+ }
+ }
+ }
+ return {
+ content: content || null,
+ tool_calls: toolCalls.length ? toolCalls : undefined,
+ };
+}
+
+function expectReply(
+ actual: Pick,
+ expected: NormalizedMessage,
+) {
+ expect(actual.content).toBe(expected.content ?? null);
+ expect(actual.tool_calls).toEqual(expected.tool_calls);
+}
+
+for (const timing of ["before", "after"] as const) {
+ for (const streaming of [false, true]) {
+ test(`replays completion ${timing} the parent reply, streaming=${streaming}`, async () => {
+ const proxy = new ReplayingCapiProxy(
+ "http://127.0.0.1:1",
+ snapshotPath,
+ import.meta.dirname,
+ );
+ const url = await proxy.start();
+ const messages = original.slice(0, 5);
+ const request = () =>
+ fetch(`${url}/chat/completions`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({
+ model: stored.models[0],
+ messages,
+ stream: streaming,
+ }),
+ });
+ try {
+ if (timing === "before") messages.push(rawNotification);
+ const first = await request();
+ expect(first.status, await first.clone().text()).toBe(200);
+ expectReply(
+ await readReply(first, streaming),
+ timing === "before" ? earlyReply : waiting,
+ );
+ messages.push(timing === "before" ? earlyReply : waiting);
+
+ if (timing === "after") {
+ messages.push(rawNotification);
+ const second = await request();
+ expect(second.status, await second.clone().text()).toBe(200);
+ expectReply(await readReply(second, streaming), readAgent);
+ messages.push(readAgent);
+ }
+
+ messages.push(toolResult);
+ const final = await request();
+ expect(final.status, await final.clone().text()).toBe(200);
+ expectReply(await readReply(final, streaming), finalAnswer);
+ } finally {
+ await proxy.stop(true);
+ }
+ });
+ }
+
+ test(`rejects duplicate notifications and changed tool histories with ${timing} completion`, async () => {
+ const proxy = new ReplayingCapiProxy(
+ "http://127.0.0.1:1",
+ snapshotPath,
+ import.meta.dirname,
+ );
+ const url = await proxy.start();
+ const prefix = [
+ ...original.slice(0, 5),
+ ...(timing === "after" ? [waiting] : []),
+ ];
+ const continuation = [
+ ...prefix,
+ rawNotification,
+ timing === "before" ? earlyReply : readAgent,
+ ];
+ const malformed = [
+ [...prefix, rawNotification, rawNotification],
+ [
+ ...prefix,
+ {
+ ...rawNotification,
+ content: rawNotification.content!.replace("read-file", "other-agent"),
+ },
+ ],
+ [
+ ...prefix.filter((message) => message.tool_call_id !== "toolcall_1"),
+ rawNotification,
+ ],
+ continuation,
+ [
+ ...continuation,
+ {
+ ...toolResult,
+ content: toolResult.content!.replace(
+ "Hello from subagent test!",
+ "Wrong file contents!",
+ ),
+ },
+ ],
+ [...continuation, toolResult, rawNotification],
+ ];
+ const stderr = vi.spyOn(process.stderr, "write").mockReturnValue(true);
+ const consoleError = vi
+ .spyOn(console, "error")
+ .mockImplementation(() => {});
+ try {
+ for (const messages of malformed) {
+ const response = await fetch(`${url}/chat/completions`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ model: stored.models[0], messages }),
+ });
+ expect(response.status, await response.text()).toBe(500);
+ }
+ } finally {
+ stderr.mockRestore();
+ consoleError.mockRestore();
+ await proxy.stop(true);
+ }
+ });
+}
+
+test("the early alternative only moves one notification and retains every captured continuation", () => {
+ expect(stored.conversations).toHaveLength(5);
+ expect(stored.conversations[4].messages).toEqual([
+ ...original.slice(0, 5),
+ notification,
+ earlyReply,
+ toolResult,
+ finalAnswer,
+ ]);
+});
diff --git a/test/snapshots/session_config/should_accept_blob_attachments.yaml b/test/snapshots/session_config/should_accept_blob_attachments.yaml
index 71c7900348..4a2e345dde 100644
--- a/test/snapshots/session_config/should_accept_blob_attachments.yaml
+++ b/test/snapshots/session_config/should_accept_blob_attachments.yaml
@@ -25,3 +25,28 @@ conversations:
[image]
- role: assistant
content: Red
+ # Retain the exact one-image history for the SDK-pinned CLI 1.0.84-8.
+ - messages:
+ - role: system
+ content: ${system}
+ - role: user
+ content: |-
+ What color is this pixel? Reply in one word.
+ pixel.png
+ [image]
+ - role: assistant
+ tool_calls:
+ - id: toolcall_0
+ type: function
+ function:
+ name: view
+ arguments: '{"path":"${workdir}/pixel.png"}'
+ - role: tool
+ tool_call_id: toolcall_0
+ content: Viewed image file successfully.
+ - role: user
+ content: |-
+ Image file at path ${workdir}/pixel.png
+ You've reached the maximum number of images you can view (1) so I can't provide the image for you to see.
+ - role: assistant
+ content: Red
diff --git a/test/snapshots/session_config/vision_disabled_then_enabled_via_setmodel.yaml b/test/snapshots/session_config/vision_disabled_then_enabled_via_setmodel.yaml
index a4000a80e6..9be57a0938 100644
--- a/test/snapshots/session_config/vision_disabled_then_enabled_via_setmodel.yaml
+++ b/test/snapshots/session_config/vision_disabled_then_enabled_via_setmodel.yaml
@@ -173,3 +173,74 @@ conversations:
content: I can see the image now! It shows a simple light blue/cyan colored square or rectangle positioned against a
white background. The shape appears to be a solid colored geometric form - it's a fairly minimal image with
just this single blue-tinted rectangular element.
+ # Retain the exact one-image history for the SDK-pinned CLI 1.0.84-8.
+ - messages:
+ - role: system
+ content: ${system}
+ - role: user
+ content: Use the view tool to look at the file test.png and describe what you see
+ - role: assistant
+ tool_calls:
+ - id: toolcall_0
+ type: function
+ function:
+ name: report_intent
+ arguments: '{"intent":"Viewing image file"}'
+ - id: toolcall_1
+ type: function
+ function:
+ name: view
+ arguments: '{"path":"${workdir}/test.png"}'
+ - role: tool
+ tool_call_id: toolcall_0
+ content: Tool 'report_intent' does not exist.
+ - role: tool
+ tool_call_id: toolcall_1
+ content: Viewed image file successfully.
+ - role: user
+ content: |-
+ Image file at path ${workdir}/test.png
+ [image]
+ - role: assistant
+ content: "I tried to view the image, but I'm not able to see the image content in the response. Let me check if the file
+ exists and try again:"
+ tool_calls:
+ - id: toolcall_2
+ type: function
+ function:
+ name: ${shell}
+ arguments: '{"command":"echo True","description":"Check if file exists"}'
+ - role: tool
+ tool_call_id: toolcall_2
+ content: |-
+ True
+
+ - role: assistant
+ content: >-
+ The file exists. Unfortunately, I'm unable to see the actual image content from the view tool output. The tool
+ confirmed it viewed the image successfully, but the image data wasn't included in the response I received.
+ This might be a limitation of the current environment or the way the image data is being transmitted.
+
+
+ To help you better, could you describe what the image contains, or would you like me to try a different
+ approach (like checking the file properties or using an image processing tool)?
+ - role: user
+ content: Use the view tool to look at the file test.png and describe what you see
+ - role: assistant
+ tool_calls:
+ - id: toolcall_3
+ type: function
+ function:
+ name: view
+ arguments: '{"path":"${workdir}/test.png"}'
+ - role: tool
+ tool_call_id: toolcall_3
+ content: Viewed image file successfully.
+ - role: user
+ content: |-
+ Image file at path ${workdir}/test.png
+ You've reached the maximum number of images you can view (1) so I can't provide the image for you to see.
+ - role: assistant
+ content: I can see the image now! It shows a simple light blue/cyan colored square or rectangle positioned against a
+ white background. The shape appears to be a solid colored geometric form - it's a fairly minimal image with
+ just this single blue-tinted rectangular element.
diff --git a/test/snapshots/subagent_hooks/should_invoke_pretooluse_and_posttooluse_hooks_for_sub_agent_tool_calls.yaml b/test/snapshots/subagent_hooks/should_invoke_pretooluse_and_posttooluse_hooks_for_sub_agent_tool_calls.yaml
index 4c5419a0ab..0bfacd6880 100644
--- a/test/snapshots/subagent_hooks/should_invoke_pretooluse_and_posttooluse_hooks_for_sub_agent_tool_calls.yaml
+++ b/test/snapshots/subagent_hooks/should_invoke_pretooluse_and_posttooluse_hooks_for_sub_agent_tool_calls.yaml
@@ -128,6 +128,77 @@ conversations:
```
+ The file contains a single line with the text "Hello from subagent test!".
+ - role: assistant
+ content: |-
+ The explore agent successfully read the file. The contents of **subagent-test.txt** are:
+
+ ```
+ Hello from subagent test!
+ ```
+ # Completion can arrive before the parent reply. With no notification between
+ # them, the recorded waiting text and read_agent call share one assistant response.
+ - messages:
+ - role: system
+ content: ${system}
+ - role: user
+ content: Use the task tool to spawn an explore agent that reads the file subagent-test.txt in the current directory and
+ reports its contents. You must use the task tool.
+ - role: assistant
+ content: I'll spawn an explore agent to read the file and report its contents.
+ tool_calls:
+ - id: toolcall_0
+ type: function
+ function:
+ name: report_intent
+ arguments: '{"intent":"Spawning explore agent"}'
+ - id: toolcall_1
+ type: function
+ function:
+ name: task
+ arguments: '{"agent_type":"explore","name":"read-file","description":"Reading subagent-test.txt","prompt":"Read the file
+ \"subagent-test.txt\" in the current directory (${workdir}) and report its complete contents. Use the
+ view tool to read the file and provide the full content in your response.","mode":"background"}'
+ - role: tool
+ tool_call_id: toolcall_0
+ content: Tool 'report_intent' does not exist.
+ - role: tool
+ tool_call_id: toolcall_1
+ content: "Agent started in background with agent_id: read-file. You'll be notified when it completes. Tell the user
+ you're waiting and end your response, or continue unrelated work until notified."
+ - role: user
+ content: >-
+
+
+ Agent "read-file" (explore) has completed successfully. Use read_agent with agent_id "read-file" to retrieve
+ the full results.
+
+
+ - role: assistant
+ content: I've launched an explore agent to read subagent-test.txt. Waiting for it to complete...
+ tool_calls:
+ - id: toolcall_2
+ type: function
+ function:
+ name: read_agent
+ arguments: '{"agent_id":"read-file","wait":true}'
+ - role: tool
+ tool_call_id: toolcall_2
+ content: >-
+ Agent completed. agent_id: read-file, agent_type: explore, status: completed, description: Reading
+ subagent-test.txt, elapsed: 0s, total_turns: 0, duration: 0s
+
+
+ The complete contents of the file "subagent-test.txt" are:
+
+
+ ```
+
+ Hello from subagent test!
+
+ ```
+
+
The file contains a single line with the text "Hello from subagent test!".
- role: assistant
content: |-