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: |-