From 422e2bea3a2fec806a5cf6ad9f7304a04061f7ff Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Wed, 4 Mar 2026 22:52:34 -0500 Subject: [PATCH 1/3] Add session shutdown method across all SDKs Add a new shutdown() method (ShutdownAsync in .NET, Shutdown in Go) that sends the session.destroy RPC to the CLI without clearing event handlers. This allows callers to observe the session.shutdown notification that the CLI sends after responding to the destroy request. The existing destroy() / DisposeAsync() method now calls shutdown() internally before clearing handlers, preserving full backward compatibility. Updated E2E tests in all four SDKs to exercise the new method and assert that the session.shutdown event is received. Updated API reference docs in all language READMEs, docs/compatibility.md, and the docs-maintenance agent. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/agents/docs-maintenance.agent.md | 8 ++--- docs/troubleshooting/compatibility.md | 1 + dotnet/README.md | 6 +++- dotnet/src/Session.cs | 45 ++++++++++++++++++++++-- dotnet/test/SessionTests.cs | 9 +++++ go/README.md | 1 + go/internal/e2e/session_test.go | 28 +++++++++++++++ go/session.go | 45 ++++++++++++++++++++++-- nodejs/README.md | 6 +++- nodejs/src/session.ts | 43 ++++++++++++++++++++-- nodejs/test/e2e/session.test.ts | 18 ++++++++++ python/copilot/session.py | 35 +++++++++++++++++- python/e2e/test_session.py | 13 +++++++ 13 files changed, 243 insertions(+), 15 deletions(-) diff --git a/.github/agents/docs-maintenance.agent.md b/.github/agents/docs-maintenance.agent.md index 9b97fecf..3d0aef94 100644 --- a/.github/agents/docs-maintenance.agent.md +++ b/.github/agents/docs-maintenance.agent.md @@ -344,7 +344,7 @@ cat nodejs/src/types.ts | grep -A 10 "export interface ExportSessionOptions" **Must match:** - `CopilotClient` constructor options: `cliPath`, `cliUrl`, `useStdio`, `port`, `logLevel`, `autoStart`, `autoRestart`, `env`, `githubToken`, `useLoggedInUser` - `createSession()` config: `model`, `tools`, `hooks`, `systemMessage`, `mcpServers`, `availableTools`, `excludedTools`, `streaming`, `reasoningEffort`, `provider`, `infiniteSessions`, `customAgents`, `workingDirectory` -- `CopilotSession` methods: `send()`, `sendAndWait()`, `getMessages()`, `disconnect()`, `abort()`, `on()`, `once()`, `off()` +- `CopilotSession` methods: `send()`, `sendAndWait()`, `getMessages()`, `shutdown()`, `disconnect()`, `abort()`, `on()`, `once()`, `off()` - Hook names: `onPreToolUse`, `onPostToolUse`, `onUserPromptSubmitted`, `onSessionStart`, `onSessionEnd`, `onErrorOccurred` #### Python Validation @@ -362,7 +362,7 @@ cat python/copilot/types.py | grep -A 15 "class SessionHooks" **Must match (snake_case):** - `CopilotClient` options: `cli_path`, `cli_url`, `use_stdio`, `port`, `log_level`, `auto_start`, `auto_restart`, `env`, `github_token`, `use_logged_in_user` - `create_session()` config keys: `model`, `tools`, `hooks`, `system_message`, `mcp_servers`, `available_tools`, `excluded_tools`, `streaming`, `reasoning_effort`, `provider`, `infinite_sessions`, `custom_agents`, `working_directory` -- `CopilotSession` methods: `send()`, `send_and_wait()`, `get_messages()`, `disconnect()`, `abort()`, `export_session()` +- `CopilotSession` methods: `send()`, `send_and_wait()`, `get_messages()`, `shutdown()`, `disconnect()`, `abort()`, `export_session()` - Hook names: `on_pre_tool_use`, `on_post_tool_use`, `on_user_prompt_submitted`, `on_session_start`, `on_session_end`, `on_error_occurred` #### Go Validation @@ -380,7 +380,7 @@ cat go/types.go | grep -A 15 "type SessionHooks struct" **Must match (PascalCase for exported):** - `ClientOptions` fields: `CLIPath`, `CLIUrl`, `UseStdio`, `Port`, `LogLevel`, `AutoStart`, `AutoRestart`, `Env`, `GithubToken`, `UseLoggedInUser` - `SessionConfig` fields: `Model`, `Tools`, `Hooks`, `SystemMessage`, `MCPServers`, `AvailableTools`, `ExcludedTools`, `Streaming`, `ReasoningEffort`, `Provider`, `InfiniteSessions`, `CustomAgents`, `WorkingDirectory` -- `Session` methods: `Send()`, `SendAndWait()`, `GetMessages()`, `Disconnect()`, `Abort()`, `ExportSession()` +- `Session` methods: `Send()`, `SendAndWait()`, `GetMessages()`, `Shutdown()`, `Disconnect()`, `Abort()`, `ExportSession()` - Hook fields: `OnPreToolUse`, `OnPostToolUse`, `OnUserPromptSubmitted`, `OnSessionStart`, `OnSessionEnd`, `OnErrorOccurred` #### .NET Validation @@ -398,7 +398,7 @@ cat dotnet/src/Types.cs | grep -A 15 "public class SessionHooks" **Must match (PascalCase):** - `CopilotClientOptions` properties: `CliPath`, `CliUrl`, `UseStdio`, `Port`, `LogLevel`, `AutoStart`, `AutoRestart`, `Environment`, `GithubToken`, `UseLoggedInUser` - `SessionConfig` properties: `Model`, `Tools`, `Hooks`, `SystemMessage`, `McpServers`, `AvailableTools`, `ExcludedTools`, `Streaming`, `ReasoningEffort`, `Provider`, `InfiniteSessions`, `CustomAgents`, `WorkingDirectory` -- `CopilotSession` methods: `SendAsync()`, `SendAndWaitAsync()`, `GetMessagesAsync()`, `DisposeAsync()`, `AbortAsync()`, `ExportSessionAsync()` +- `CopilotSession` methods: `SendAsync()`, `SendAndWaitAsync()`, `GetMessagesAsync()`, `ShutdownAsync()`, `DisposeAsync()`, `AbortAsync()`, `ExportSessionAsync()` - Hook properties: `OnPreToolUse`, `OnPostToolUse`, `OnUserPromptSubmitted`, `OnSessionStart`, `OnSessionEnd`, `OnErrorOccurred` #### Common Sample Errors to Check diff --git a/docs/troubleshooting/compatibility.md b/docs/troubleshooting/compatibility.md index 1a322b88..4e375461 100644 --- a/docs/troubleshooting/compatibility.md +++ b/docs/troubleshooting/compatibility.md @@ -16,6 +16,7 @@ The Copilot SDK communicates with the CLI via JSON-RPC protocol. Features must b | Create session | `createSession()` | Full config support | | Resume session | `resumeSession()` | With infinite session workspaces | | Disconnect session | `disconnect()` | Release in-memory resources | +| Shutdown session | `shutdown()` | End session server-side, keeping handlers active | | Destroy session *(deprecated)* | `destroy()` | Use `disconnect()` instead | | Delete session | `deleteSession()` | Remove from storage | | List sessions | `listSessions()` | All stored sessions | diff --git a/dotnet/README.md b/dotnet/README.md index bdb3e8da..bece4865 100644 --- a/dotnet/README.md +++ b/dotnet/README.md @@ -219,7 +219,11 @@ Get all events/messages from this session. ##### `DisposeAsync(): ValueTask` -Close the session and release in-memory resources. Session data on disk is preserved — the conversation can be resumed later via `ResumeSessionAsync()`. To permanently delete session data, use `client.DeleteSessionAsync()`. +Close the session and release in-memory resources. Calls `ShutdownAsync()` first if not already called. Session data on disk is preserved — the conversation can be resumed later via `ResumeSessionAsync()`. To permanently delete session data, use `client.DeleteSessionAsync()`. + +##### `ShutdownAsync(CancellationToken): Task` + +Shut down the session on the server without clearing local event handlers. Call this before `DisposeAsync()` when you want to observe the `SessionShutdownEvent`. ```csharp // Preferred: automatic cleanup via await using diff --git a/dotnet/src/Session.cs b/dotnet/src/Session.cs index b9d70a2a..da4f9904 100644 --- a/dotnet/src/Session.cs +++ b/dotnet/src/Session.cs @@ -67,6 +67,7 @@ public sealed partial class CopilotSession : IAsyncDisposable private readonly SemaphoreSlim _hooksLock = new(1, 1); private SessionRpc? _sessionRpc; private int _isDisposed; + private int _isShutdown; /// /// Gets the unique identifier for this session. @@ -696,6 +697,42 @@ public async Task LogAsync(string message, SessionLogRequestLevel? level = null, await Rpc.LogAsync(message, level, ephemeral, cancellationToken); } + /// + /// Shuts down this session on the server without clearing local event handlers. + /// + /// + /// + /// Call this before when you want to observe the + /// . The event is dispatched to registered handlers + /// after this method returns. Once you have processed the event, call + /// to clear handlers and release local resources. + /// + /// + /// If the session has already been shut down, this is a no-op. + /// + /// + /// A cancellation token to cancel the operation. + /// A task representing the asynchronous shutdown operation. + /// + /// + /// var shutdownTcs = new TaskCompletionSource(); + /// session.On(evt => { if (evt is SessionShutdownEvent) shutdownTcs.TrySetResult(); }); + /// await session.ShutdownAsync(); + /// await shutdownTcs.Task; + /// await session.DisposeAsync(); + /// + /// + public async Task ShutdownAsync(CancellationToken cancellationToken = default) + { + if (Interlocked.Exchange(ref _isShutdown, 1) == 1) + { + return; + } + + await InvokeRpcAsync( + "session.destroy", [new SessionDestroyRequest() { SessionId = SessionId }], cancellationToken); + } + /// /// Closes this session and releases all in-memory resources (event handlers, /// tool handlers, permission handlers). @@ -710,6 +747,11 @@ public async Task LogAsync(string message, SessionLogRequestLevel? level = null, /// instead. /// /// + /// If was not called first, this method calls it automatically. + /// In that case the may not be observed because handlers + /// are cleared immediately after the server responds. + /// + /// /// After calling this method, the session object can no longer be used. /// /// @@ -733,8 +775,7 @@ public async ValueTask DisposeAsync() try { - await InvokeRpcAsync( - "session.destroy", [new SessionDestroyRequest() { SessionId = SessionId }], CancellationToken.None); + await ShutdownAsync(); } catch (ObjectDisposedException) { diff --git a/dotnet/test/SessionTests.cs b/dotnet/test/SessionTests.cs index 20d6f3ac..b6b8e4dc 100644 --- a/dotnet/test/SessionTests.cs +++ b/dotnet/test/SessionTests.cs @@ -248,6 +248,7 @@ public async Task Should_Receive_Session_Events() var session = await CreateSessionAsync(); var receivedEvents = new List(); var idleReceived = new TaskCompletionSource(); + var shutdownReceived = new TaskCompletionSource(); session.On(evt => { @@ -256,6 +257,10 @@ public async Task Should_Receive_Session_Events() { idleReceived.TrySetResult(true); } + else if (evt is SessionShutdownEvent) + { + shutdownReceived.TrySetResult(true); + } }); // Send a message to trigger events @@ -276,6 +281,10 @@ public async Task Should_Receive_Session_Events() Assert.NotNull(assistantMessage); Assert.Contains("300", assistantMessage!.Data.Content); + // Shut down session and verify shutdown event is received + await session.ShutdownAsync(); + await shutdownReceived.Task.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.Contains(receivedEvents, evt => evt is SessionShutdownEvent); await session.DisposeAsync(); } diff --git a/go/README.md b/go/README.md index 4cc73398..a676c7a0 100644 --- a/go/README.md +++ b/go/README.md @@ -170,6 +170,7 @@ Event types: `SessionLifecycleCreated`, `SessionLifecycleDeleted`, `SessionLifec - `Abort(ctx context.Context) error` - Abort the currently processing message - `GetMessages(ctx context.Context) ([]SessionEvent, error)` - Get message history - `Disconnect() error` - Disconnect the session (releases in-memory resources, preserves disk state) +- `Shutdown() error` - Shut down the session on the server without clearing local handlers (call before `Disconnect()` to observe the `session.shutdown` event) - `Destroy() error` - *(Deprecated)* Use `Disconnect()` instead ### Helper Functions diff --git a/go/internal/e2e/session_test.go b/go/internal/e2e/session_test.go index 8da66cdd..0521741a 100644 --- a/go/internal/e2e/session_test.go +++ b/go/internal/e2e/session_test.go @@ -595,6 +595,7 @@ func TestSession(t *testing.T) { var receivedEvents []copilot.SessionEvent idle := make(chan bool) + shutdown := make(chan bool) session.On(func(event copilot.SessionEvent) { receivedEvents = append(receivedEvents, event) @@ -603,6 +604,11 @@ func TestSession(t *testing.T) { case idle <- true: default: } + } else if event.Type == "session.shutdown" { + select { + case shutdown <- true: + default: + } } }) @@ -656,6 +662,28 @@ func TestSession(t *testing.T) { if assistantMessage.Data.Content == nil || !strings.Contains(*assistantMessage.Data.Content, "300") { t.Errorf("Expected assistant message to contain '300', got %v", assistantMessage.Data.Content) } + + // Shut down session and verify shutdown event is received + if err := session.Shutdown(); err != nil { + t.Fatalf("Failed to shut down session: %v", err) + } + select { + case <-shutdown: + case <-time.After(5 * time.Second): + t.Fatal("Timed out waiting for session.shutdown") + } + hasShutdown := false + for _, evt := range receivedEvents { + if evt.Type == "session.shutdown" { + hasShutdown = true + } + } + if !hasShutdown { + t.Error("Expected to receive session.shutdown event") + } + if err := session.Destroy(); err != nil { + t.Fatalf("Failed to destroy session: %v", err) + } }) t.Run("should create session with custom config dir", func(t *testing.T) { diff --git a/go/session.go b/go/session.go index 74529c52..136d0bd6 100644 --- a/go/session.go +++ b/go/session.go @@ -6,6 +6,7 @@ import ( "encoding/json" "fmt" "sync" + "sync/atomic" "time" "github.com/github/copilot-sdk/go/internal/jsonrpc2" @@ -64,6 +65,7 @@ type Session struct { userInputMux sync.RWMutex hooks *SessionHooks hooksMux sync.RWMutex + isShutdown atomic.Bool // RPC provides typed session-scoped RPC methods. RPC *rpc.SessionRpc @@ -607,6 +609,40 @@ func (s *Session) GetMessages(ctx context.Context) ([]SessionEvent, error) { return response.Events, nil } +// Shutdown ends this session on the server without clearing local event handlers. +// +// Call this before [Session.Disconnect] when you want to observe the session.shutdown +// event. The event is dispatched to registered handlers after this method returns. +// Once you have processed the event, call [Session.Disconnect] to clear handlers and +// release local resources. +// +// If the session has already been shut down, this is a no-op. +// +// Returns an error if the connection fails. +// +// Example: +// +// session.On(func(event copilot.SessionEvent) { +// if event.Type == copilot.SessionShutdown { +// fmt.Println("Shutdown metrics:", event.Data) +// } +// }) +// if err := session.Shutdown(); err != nil { +// log.Printf("Failed to shut down session: %v", err) +// } +// // ... wait for the shutdown event ... +// session.Disconnect() +func (s *Session) Shutdown() error { + if s.isShutdown.Swap(true) { + return nil + } + _, err := s.client.Request("session.destroy", sessionDestroyRequest{SessionID: s.SessionID}) + if err != nil { + return fmt.Errorf("failed to shut down session: %w", err) + } + return nil +} + // Disconnect closes this session and releases all in-memory resources (event // handlers, tool handlers, permission handlers). // @@ -617,6 +653,10 @@ func (s *Session) GetMessages(ctx context.Context) ([]SessionEvent, error) { // // After calling this method, the session object can no longer be used. // +// If [Session.Shutdown] was not called first, this method calls it automatically. +// In that case the session.shutdown event may not be observed because handlers +// are cleared immediately after the server responds. +// // Returns an error if the connection fails. // // Example: @@ -626,9 +666,8 @@ func (s *Session) GetMessages(ctx context.Context) ([]SessionEvent, error) { // log.Printf("Failed to disconnect session: %v", err) // } func (s *Session) Disconnect() error { - _, err := s.client.Request("session.destroy", sessionDestroyRequest{SessionID: s.SessionID}) - if err != nil { - return fmt.Errorf("failed to disconnect session: %w", err) + if err := s.Shutdown(); err != nil { + return err } // Clear handlers diff --git a/nodejs/README.md b/nodejs/README.md index 78a535b7..65cb1e08 100644 --- a/nodejs/README.md +++ b/nodejs/README.md @@ -274,7 +274,11 @@ Get all events/messages from this session. ##### `disconnect(): Promise` -Disconnect the session and free resources. Session data on disk is preserved for later resumption. +Disconnect the session and free resources. Calls `shutdown()` first if not already called. Session data on disk is preserved for later resumption. + +##### `shutdown(): Promise` + +Shut down the session on the server without clearing local event handlers. Call this before `disconnect()` when you want to observe the `session.shutdown` event. ##### `destroy(): Promise` *(deprecated)* diff --git a/nodejs/src/session.ts b/nodejs/src/session.ts index c8c88d2c..efc7e702 100644 --- a/nodejs/src/session.ts +++ b/nodejs/src/session.ts @@ -80,6 +80,8 @@ export class CopilotSession { private readonly _workspacePath?: string ) {} + private _isShutdown = false; + /** * Typed session-scoped RPC methods. */ @@ -604,6 +606,39 @@ export class CopilotSession { return (response as { events: SessionEvent[] }).events; } + /** + * Shuts down this session on the server without clearing local event handlers. + * + * Call this before {@link disconnect} when you want to observe the `session.shutdown` + * event. The event is dispatched to registered handlers after this method returns. + * Once you have processed the event, call {@link disconnect} to clear handlers and + * release local resources. + * + * If the session has already been shut down, this is a no-op. + * + * @returns A promise that resolves when the server has acknowledged the shutdown + * @throws Error if the connection fails + * + * @example + * ```typescript + * session.on("session.shutdown", (event) => { + * console.log("Shutdown metrics:", event.data.modelMetrics); + * }); + * await session.shutdown(); + * // ... wait for the shutdown event ... + * await session.disconnect(); + * ``` + */ + async shutdown(): Promise { + if (this._isShutdown) { + return; + } + this._isShutdown = true; + await this.connection.sendRequest("session.destroy", { + sessionId: this.sessionId, + }); + } + /** * Disconnects this session and releases all in-memory resources (event handlers, * tool handlers, permission handlers). @@ -614,6 +649,10 @@ export class CopilotSession { * remove all session data including files on disk, use * {@link CopilotClient.deleteSession} instead. * + * If {@link shutdown} was not called first, this method calls it automatically. + * In that case the `session.shutdown` event may not be observed because handlers + * are cleared immediately after the server responds. + * * After calling this method, the session object can no longer be used. * * @returns A promise that resolves when the session is disconnected @@ -626,9 +665,7 @@ export class CopilotSession { * ``` */ async disconnect(): Promise { - await this.connection.sendRequest("session.destroy", { - sessionId: this.sessionId, - }); + await this.shutdown(); this.eventHandlers.clear(); this.typedEventHandlers.clear(); this.toolHandlers.clear(); diff --git a/nodejs/test/e2e/session.test.ts b/nodejs/test/e2e/session.test.ts index 7cd781bc..ae2fba79 100644 --- a/nodejs/test/e2e/session.test.ts +++ b/nodejs/test/e2e/session.test.ts @@ -299,9 +299,16 @@ describe("Sessions", async () => { it("should receive session events", async () => { const session = await client.createSession({ onPermissionRequest: approveAll }); const receivedEvents: Array<{ type: string }> = []; + let resolveShutdown: () => void; + const shutdownReceived = new Promise((resolve) => { + resolveShutdown = resolve; + }); session.on((event) => { receivedEvents.push(event); + if (event.type === "session.shutdown") { + resolveShutdown(); + } }); // Send a message and wait for completion @@ -315,6 +322,17 @@ describe("Sessions", async () => { // Verify the assistant response contains the expected answer expect(assistantMessage?.data.content).toContain("300"); + + // Shut down session and verify shutdown event is received + await session.shutdown(); + await Promise.race([ + shutdownReceived, + new Promise((_, reject) => + setTimeout(() => reject(new Error("Timed out waiting for session.shutdown")), 5000) + ), + ]); + expect(receivedEvents.some((e) => e.type === "session.shutdown")).toBe(true); + await session.destroy(); }); it("should create session with custom config dir", async () => { diff --git a/python/copilot/session.py b/python/copilot/session.py index ee46cbd7..db399ccc 100644 --- a/python/copilot/session.py +++ b/python/copilot/session.py @@ -97,6 +97,7 @@ def __init__(self, session_id: str, client: Any, workspace_path: str | None = No self._hooks: SessionHooks | None = None self._hooks_lock = threading.Lock() self._rpc: SessionRpc | None = None + self._is_shutdown = False @property def rpc(self) -> SessionRpc: @@ -638,6 +639,34 @@ async def get_messages(self) -> list[SessionEvent]: events_dicts = response["events"] return [session_event_from_dict(event_dict) for event_dict in events_dicts] + async def shutdown(self) -> None: + """ + Shut down this session on the server without clearing local event handlers. + + Call this before :meth:`disconnect` when you want to observe the + ``session.shutdown`` event. The event is dispatched to registered handlers + after this method returns. Once you have processed the event, call + :meth:`disconnect` to clear handlers and release local resources. + + If the session has already been shut down, this is a no-op. + + Raises: + Exception: If the connection fails. + + Example: + >>> def on_shutdown(event): + ... if event.type == SessionEventType.SESSION_SHUTDOWN: + ... print("Shutdown:", event.data) + >>> session.on(on_shutdown) + >>> await session.shutdown() + >>> # ... wait for the shutdown event ... + >>> await session.disconnect() + """ + if self._is_shutdown: + return + self._is_shutdown = True + await self._client.request("session.destroy", {"sessionId": self.session_id}) + async def disconnect(self) -> None: """ Disconnect this session and release all in-memory resources (event handlers, @@ -651,6 +680,10 @@ async def disconnect(self) -> None: After calling this method, the session object can no longer be used. + If :meth:`shutdown` was not called first, this method calls it automatically. + In that case the ``session.shutdown`` event may not be observed because + handlers are cleared immediately after the server responds. + Raises: Exception: If the connection fails. @@ -658,7 +691,7 @@ async def disconnect(self) -> None: >>> # Clean up when done — session can still be resumed later >>> await session.disconnect() """ - await self._client.request("session.destroy", {"sessionId": self.session_id}) + await self.shutdown() with self._event_handlers_lock: self._event_handlers.clear() with self._tool_handlers_lock: diff --git a/python/e2e/test_session.py b/python/e2e/test_session.py index aa93ed42..86bf1f33 100644 --- a/python/e2e/test_session.py +++ b/python/e2e/test_session.py @@ -455,11 +455,14 @@ async def test_should_receive_session_events(self, ctx: E2ETestContext): ) received_events = [] idle_event = asyncio.Event() + shutdown_event = asyncio.Event() def on_event(event): received_events.append(event) if event.type.value == "session.idle": idle_event.set() + elif event.type.value == "session.shutdown": + shutdown_event.set() session.on(on_event) @@ -483,6 +486,16 @@ def on_event(event): assistant_message = await get_final_assistant_message(session) assert "300" in assistant_message.data.content + # Shut down session and verify shutdown event is received + await session.shutdown() + try: + await asyncio.wait_for(shutdown_event.wait(), timeout=5) + except TimeoutError: + pytest.fail("Timed out waiting for session.shutdown") + event_types = [e.type.value for e in received_events] + assert "session.shutdown" in event_types + await session.destroy() + async def test_should_create_session_with_custom_config_dir(self, ctx: E2ETestContext): import os From ae18c0f2e35af156f9b6b36a46200b17383e9fa4 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Wed, 4 Mar 2026 23:27:33 -0500 Subject: [PATCH 2/3] Fix E2E tests: remove shutdown event assertions, update cleanup test The CLI runtime does not currently emit session.shutdown notifications during the session.destroy flow, so remove E2E assertions that wait for the event. The shutdown() method and two-phase API remain in place for when the runtime is updated. Update the Node.js failed-cleanup test to reflect that shutdown()'s idempotency guard causes the destroy retry to succeed with local-only cleanup when the server process is dead. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dotnet/test/SessionTests.cs | 9 +------ go/internal/e2e/session_test.go | 22 +--------------- nodejs/test/e2e/client.test.ts | 46 ++++++++++++++++++--------------- nodejs/test/e2e/session.test.ts | 16 +----------- python/e2e/test_session.py | 11 +------- 5 files changed, 29 insertions(+), 75 deletions(-) diff --git a/dotnet/test/SessionTests.cs b/dotnet/test/SessionTests.cs index b6b8e4dc..4437e04a 100644 --- a/dotnet/test/SessionTests.cs +++ b/dotnet/test/SessionTests.cs @@ -248,7 +248,6 @@ public async Task Should_Receive_Session_Events() var session = await CreateSessionAsync(); var receivedEvents = new List(); var idleReceived = new TaskCompletionSource(); - var shutdownReceived = new TaskCompletionSource(); session.On(evt => { @@ -257,10 +256,6 @@ public async Task Should_Receive_Session_Events() { idleReceived.TrySetResult(true); } - else if (evt is SessionShutdownEvent) - { - shutdownReceived.TrySetResult(true); - } }); // Send a message to trigger events @@ -281,10 +276,8 @@ public async Task Should_Receive_Session_Events() Assert.NotNull(assistantMessage); Assert.Contains("300", assistantMessage!.Data.Content); - // Shut down session and verify shutdown event is received + // Shut down session (sends RPC without clearing handlers), then dispose await session.ShutdownAsync(); - await shutdownReceived.Task.WaitAsync(TimeSpan.FromSeconds(5)); - Assert.Contains(receivedEvents, evt => evt is SessionShutdownEvent); await session.DisposeAsync(); } diff --git a/go/internal/e2e/session_test.go b/go/internal/e2e/session_test.go index 0521741a..0caebe6d 100644 --- a/go/internal/e2e/session_test.go +++ b/go/internal/e2e/session_test.go @@ -595,7 +595,6 @@ func TestSession(t *testing.T) { var receivedEvents []copilot.SessionEvent idle := make(chan bool) - shutdown := make(chan bool) session.On(func(event copilot.SessionEvent) { receivedEvents = append(receivedEvents, event) @@ -604,11 +603,6 @@ func TestSession(t *testing.T) { case idle <- true: default: } - } else if event.Type == "session.shutdown" { - select { - case shutdown <- true: - default: - } } }) @@ -663,24 +657,10 @@ func TestSession(t *testing.T) { t.Errorf("Expected assistant message to contain '300', got %v", assistantMessage.Data.Content) } - // Shut down session and verify shutdown event is received + // Shut down session (sends RPC without clearing handlers), then destroy if err := session.Shutdown(); err != nil { t.Fatalf("Failed to shut down session: %v", err) } - select { - case <-shutdown: - case <-time.After(5 * time.Second): - t.Fatal("Timed out waiting for session.shutdown") - } - hasShutdown := false - for _, evt := range receivedEvents { - if evt.Type == "session.shutdown" { - hasShutdown = true - } - } - if !hasShutdown { - t.Error("Expected to receive session.shutdown event") - } if err := session.Destroy(); err != nil { t.Fatalf("Failed to destroy session: %v", err) } diff --git a/nodejs/test/e2e/client.test.ts b/nodejs/test/e2e/client.test.ts index 9d71ee72..8a0bcd86 100644 --- a/nodejs/test/e2e/client.test.ts +++ b/nodejs/test/e2e/client.test.ts @@ -43,27 +43,31 @@ describe("Client", () => { expect(client.getState()).toBe("disconnected"); }); - it.skipIf(process.platform === "darwin")("should return errors on failed cleanup", async () => { - // Use TCP mode to avoid stdin stream destruction issues - // Without this, on macOS there are intermittent test failures - // saying "Cannot call write after a stream was destroyed" - // because the JSON-RPC logic is still trying to write to stdin after - // the process has exited. - const client = new CopilotClient({ useStdio: false }); - - await client.createSession({ onPermissionRequest: approveAll }); - - // Kill the server processto force cleanup to fail - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const cliProcess = (client as any).cliProcess as ChildProcess; - expect(cliProcess).toBeDefined(); - cliProcess.kill("SIGKILL"); - await new Promise((resolve) => setTimeout(resolve, 100)); - - const errors = await client.stop(); - expect(errors.length).toBeGreaterThan(0); - expect(errors[0].message).toContain("Failed to disconnect session"); - }); + it.skipIf(process.platform === "darwin")( + "should handle cleanup when server process is dead", + async () => { + // Use TCP mode to avoid stdin stream destruction issues + // Without this, on macOS there are intermittent test failures + // saying "Cannot call write after a stream was destroyed" + // because the JSON-RPC logic is still trying to write to stdin after + // the process has exited. + const client = new CopilotClient({ useStdio: false }); + + await client.createSession({ onPermissionRequest: approveAll }); + + // Kill the server process to force the first destroy attempt to fail. + // The retry succeeds because shutdown() is idempotent (the guard + // prevents a second RPC) and local handler cleanup always works. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const cliProcess = (client as any).cliProcess as ChildProcess; + expect(cliProcess).toBeDefined(); + cliProcess.kill("SIGKILL"); + await new Promise((resolve) => setTimeout(resolve, 100)); + + const errors = await client.stop(); + expect(errors).toHaveLength(0); + } + ); it("should forceStop without cleanup", async () => { const client = new CopilotClient({}); diff --git a/nodejs/test/e2e/session.test.ts b/nodejs/test/e2e/session.test.ts index ae2fba79..40147059 100644 --- a/nodejs/test/e2e/session.test.ts +++ b/nodejs/test/e2e/session.test.ts @@ -299,16 +299,9 @@ describe("Sessions", async () => { it("should receive session events", async () => { const session = await client.createSession({ onPermissionRequest: approveAll }); const receivedEvents: Array<{ type: string }> = []; - let resolveShutdown: () => void; - const shutdownReceived = new Promise((resolve) => { - resolveShutdown = resolve; - }); session.on((event) => { receivedEvents.push(event); - if (event.type === "session.shutdown") { - resolveShutdown(); - } }); // Send a message and wait for completion @@ -323,15 +316,8 @@ describe("Sessions", async () => { // Verify the assistant response contains the expected answer expect(assistantMessage?.data.content).toContain("300"); - // Shut down session and verify shutdown event is received + // Shut down session (sends RPC without clearing handlers), then destroy await session.shutdown(); - await Promise.race([ - shutdownReceived, - new Promise((_, reject) => - setTimeout(() => reject(new Error("Timed out waiting for session.shutdown")), 5000) - ), - ]); - expect(receivedEvents.some((e) => e.type === "session.shutdown")).toBe(true); await session.destroy(); }); diff --git a/python/e2e/test_session.py b/python/e2e/test_session.py index 86bf1f33..5a218840 100644 --- a/python/e2e/test_session.py +++ b/python/e2e/test_session.py @@ -455,14 +455,11 @@ async def test_should_receive_session_events(self, ctx: E2ETestContext): ) received_events = [] idle_event = asyncio.Event() - shutdown_event = asyncio.Event() def on_event(event): received_events.append(event) if event.type.value == "session.idle": idle_event.set() - elif event.type.value == "session.shutdown": - shutdown_event.set() session.on(on_event) @@ -486,14 +483,8 @@ def on_event(event): assistant_message = await get_final_assistant_message(session) assert "300" in assistant_message.data.content - # Shut down session and verify shutdown event is received + # Shut down session (sends RPC without clearing handlers), then destroy await session.shutdown() - try: - await asyncio.wait_for(shutdown_event.wait(), timeout=5) - except TimeoutError: - pytest.fail("Timed out waiting for session.shutdown") - event_types = [e.type.value for e in received_events] - assert "session.shutdown" in event_types await session.destroy() async def test_should_create_session_with_custom_config_dir(self, ctx: E2ETestContext): From bf126ce7985d03aa6d333c22e9b8d61f92c94d64 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Tue, 10 Mar 2026 00:33:36 -0400 Subject: [PATCH 3/3] Restore shutdown notification coverage Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dotnet/test/SessionTests.cs | 9 ++- go/internal/e2e/session_test.go | 26 ++++++- go/session_test.go | 129 ++++++++++++++++++++++++++++++++ nodejs/package-lock.json | 56 +++++++------- nodejs/package.json | 2 +- nodejs/test/e2e/session.test.ts | 16 +++- python/e2e/test_session.py | 11 ++- 7 files changed, 214 insertions(+), 35 deletions(-) diff --git a/dotnet/test/SessionTests.cs b/dotnet/test/SessionTests.cs index 4437e04a..b6b8e4dc 100644 --- a/dotnet/test/SessionTests.cs +++ b/dotnet/test/SessionTests.cs @@ -248,6 +248,7 @@ public async Task Should_Receive_Session_Events() var session = await CreateSessionAsync(); var receivedEvents = new List(); var idleReceived = new TaskCompletionSource(); + var shutdownReceived = new TaskCompletionSource(); session.On(evt => { @@ -256,6 +257,10 @@ public async Task Should_Receive_Session_Events() { idleReceived.TrySetResult(true); } + else if (evt is SessionShutdownEvent) + { + shutdownReceived.TrySetResult(true); + } }); // Send a message to trigger events @@ -276,8 +281,10 @@ public async Task Should_Receive_Session_Events() Assert.NotNull(assistantMessage); Assert.Contains("300", assistantMessage!.Data.Content); - // Shut down session (sends RPC without clearing handlers), then dispose + // Shut down session and verify shutdown event is received await session.ShutdownAsync(); + await shutdownReceived.Task.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.Contains(receivedEvents, evt => evt is SessionShutdownEvent); await session.DisposeAsync(); } diff --git a/go/internal/e2e/session_test.go b/go/internal/e2e/session_test.go index 0caebe6d..3a2a26ce 100644 --- a/go/internal/e2e/session_test.go +++ b/go/internal/e2e/session_test.go @@ -594,13 +594,19 @@ func TestSession(t *testing.T) { } var receivedEvents []copilot.SessionEvent - idle := make(chan bool) + idle := make(chan struct{}, 1) + shutdown := make(chan struct{}, 1) session.On(func(event copilot.SessionEvent) { receivedEvents = append(receivedEvents, event) if event.Type == "session.idle" { select { - case idle <- true: + case idle <- struct{}{}: + default: + } + } else if event.Type == "session.shutdown" { + select { + case shutdown <- struct{}{}: default: } } @@ -657,10 +663,24 @@ func TestSession(t *testing.T) { t.Errorf("Expected assistant message to contain '300', got %v", assistantMessage.Data.Content) } - // Shut down session (sends RPC without clearing handlers), then destroy + // Shut down session and verify shutdown event is received if err := session.Shutdown(); err != nil { t.Fatalf("Failed to shut down session: %v", err) } + select { + case <-shutdown: + case <-time.After(5 * time.Second): + t.Fatal("Timed out waiting for session.shutdown") + } + hasShutdown := false + for _, evt := range receivedEvents { + if evt.Type == "session.shutdown" { + hasShutdown = true + } + } + if !hasShutdown { + t.Error("Expected to receive session.shutdown event") + } if err := session.Destroy(); err != nil { t.Fatalf("Failed to destroy session: %v", err) } diff --git a/go/session_test.go b/go/session_test.go index 40874a65..1dfec2b2 100644 --- a/go/session_test.go +++ b/go/session_test.go @@ -119,3 +119,132 @@ func TestSession_On(t *testing.T) { } }) } + +func TestSession_Shutdown(t *testing.T) { + t.Run("shutdown event dispatches to handlers", func(t *testing.T) { + session := &Session{ + handlers: make([]sessionHandler, 0), + } + + var received []SessionEvent + session.On(func(event SessionEvent) { + received = append(received, event) + }) + + session.dispatchEvent(SessionEvent{Type: "session.shutdown"}) + + if len(received) != 1 { + t.Fatalf("Expected 1 event, got %d", len(received)) + } + if received[0].Type != "session.shutdown" { + t.Errorf("Expected session.shutdown event, got %s", received[0].Type) + } + }) + + t.Run("handlers still active after shutdown flag set", func(t *testing.T) { + session := &Session{ + handlers: make([]sessionHandler, 0), + } + + var received []SessionEvent + session.On(func(event SessionEvent) { + received = append(received, event) + }) + + // Simulate what Shutdown() does: set the flag + session.isShutdown.Store(true) + + // Handlers should still be active — Shutdown does not clear them + session.dispatchEvent(SessionEvent{Type: "session.shutdown"}) + + if len(received) != 1 { + t.Fatalf("Expected 1 event after shutdown, got %d", len(received)) + } + if received[0].Type != "session.shutdown" { + t.Errorf("Expected session.shutdown, got %s", received[0].Type) + } + }) + + t.Run("shutdown idempotency via atomic flag", func(t *testing.T) { + session := &Session{ + handlers: make([]sessionHandler, 0), + } + + // First swap should return false (was not shut down) + if session.isShutdown.Swap(true) { + t.Error("Expected first Swap to return false") + } + + // Second swap should return true (already shut down) + if !session.isShutdown.Swap(true) { + t.Error("Expected second Swap to return true") + } + }) + + t.Run("disconnect clears handlers", func(t *testing.T) { + session := &Session{ + handlers: make([]sessionHandler, 0), + toolHandlers: make(map[string]ToolHandler), + } + + var count int + session.On(func(event SessionEvent) { count++ }) + + // Dispatch before disconnect — handler should fire + session.dispatchEvent(SessionEvent{Type: "test"}) + if count != 1 { + t.Fatalf("Expected 1 event before disconnect, got %d", count) + } + + // Simulate Disconnect's handler-clearing logic + session.handlerMutex.Lock() + session.handlers = nil + session.handlerMutex.Unlock() + + session.toolHandlersM.Lock() + session.toolHandlers = nil + session.toolHandlersM.Unlock() + + session.permissionMux.Lock() + session.permissionHandler = nil + session.permissionMux.Unlock() + + // Dispatch after disconnect — handler should NOT fire + session.dispatchEvent(SessionEvent{Type: "test"}) + if count != 1 { + t.Errorf("Expected no additional events after disconnect, got %d total", count) + } + }) + + t.Run("two-phase shutdown then disconnect preserves notification", func(t *testing.T) { + session := &Session{ + handlers: make([]sessionHandler, 0), + } + + var events []string + session.On(func(event SessionEvent) { + events = append(events, string(event.Type)) + }) + + // Phase 1: Shutdown sends the RPC (simulated) — handlers still active + session.isShutdown.Store(true) + + // Server sends back shutdown notification — handler receives it + session.dispatchEvent(SessionEvent{Type: "session.shutdown"}) + + // Phase 2: Clear handlers (simulating Disconnect) + session.handlerMutex.Lock() + session.handlers = nil + session.handlerMutex.Unlock() + + // Any further events should not reach handlers + session.dispatchEvent(SessionEvent{Type: "should.not.arrive"}) + + if len(events) != 1 { + t.Fatalf("Expected exactly 1 event, got %d: %v", len(events), events) + } + if events[0] != "session.shutdown" { + t.Errorf("Expected session.shutdown, got %s", events[0]) + } + }) +} diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json index a07746bf..c17f2131 100644 --- a/nodejs/package-lock.json +++ b/nodejs/package-lock.json @@ -9,7 +9,7 @@ "version": "0.1.8", "license": "MIT", "dependencies": { - "@github/copilot": "^1.0.3-0", + "@github/copilot": "^1.0.3", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" }, @@ -662,26 +662,26 @@ } }, "node_modules/@github/copilot": { - "version": "1.0.3-0", - "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.3-0.tgz", - "integrity": "sha512-wvd3FwQUgf4Bm3dwRBNXdjE60eGi+4cK0Shn9Ky8GSuusHtClIanTL65ft5HdOlZ1H+ieyWrrGgu7rO1Sip/yQ==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.3.tgz", + "integrity": "sha512-5J68wbShQq8biIgHD3ixlEg9hdj4kE72L2U7VwNXnhQ6tJNJtnXPHIyNbcc4L5ncu3k7IRmHMquJ76OApwvHxA==", "license": "SEE LICENSE IN LICENSE.md", "bin": { "copilot": "npm-loader.js" }, "optionalDependencies": { - "@github/copilot-darwin-arm64": "1.0.3-0", - "@github/copilot-darwin-x64": "1.0.3-0", - "@github/copilot-linux-arm64": "1.0.3-0", - "@github/copilot-linux-x64": "1.0.3-0", - "@github/copilot-win32-arm64": "1.0.3-0", - "@github/copilot-win32-x64": "1.0.3-0" + "@github/copilot-darwin-arm64": "1.0.3", + "@github/copilot-darwin-x64": "1.0.3", + "@github/copilot-linux-arm64": "1.0.3", + "@github/copilot-linux-x64": "1.0.3", + "@github/copilot-win32-arm64": "1.0.3", + "@github/copilot-win32-x64": "1.0.3" } }, "node_modules/@github/copilot-darwin-arm64": { - "version": "1.0.3-0", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.3-0.tgz", - "integrity": "sha512-9bpouod3i4S5TbO9zMb6e47O2l8tussndaQu8D2nD7dBVUO/p+k7r9N1agAZ9/h3zrIqWo+JpJ57iUYb8tbCSw==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.3.tgz", + "integrity": "sha512-gWeMjR6yP+F1SIY4RNm54C35ryYEyOg8ejOyM3lO3I9Xbq9IzBFCdOxhXSSeNPz6x1VF3vOIh/sxLPIOL1Y/Gg==", "cpu": [ "arm64" ], @@ -695,9 +695,9 @@ } }, "node_modules/@github/copilot-darwin-x64": { - "version": "1.0.3-0", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.3-0.tgz", - "integrity": "sha512-L4/OJLcnSnPIUIPaTZR6K7+mjXDPkHFNixioefJZQvJerOZdo9LTML6zkc2j21dWleSHiOVaLAfUdoLMyWzaVg==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.3.tgz", + "integrity": "sha512-kPvMctqiPW6Jq8yxxgbGzYvgtOj9U7Hk8MJknt+9nhrf/duvUobWuYJ6/FivMowGisYYtDbGjknM351vOUC7qA==", "cpu": [ "x64" ], @@ -711,9 +711,9 @@ } }, "node_modules/@github/copilot-linux-arm64": { - "version": "1.0.3-0", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.3-0.tgz", - "integrity": "sha512-3zGP9UuQAh7goXo7Ae2jm1SPpHWmNJw3iW6oEIhTocYm+xUecYdny7AbDAQs491fZcVGYea22Jqyynlcj1lH/g==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.3.tgz", + "integrity": "sha512-AVveXRt3QKXSCYIbHTQABLRw4MbmJeRxZgHrR2h3qHMmpUkXf5dM+9Ott12LPENILU962w3kB/j1Q+QqJUhAUw==", "cpu": [ "arm64" ], @@ -727,9 +727,9 @@ } }, "node_modules/@github/copilot-linux-x64": { - "version": "1.0.3-0", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.3-0.tgz", - "integrity": "sha512-cdxGofsF7LHjw5mO0uvmsK4wl1QnW3cd2rhwc14XgWMXbenlgyBTmwamGbVdlYtZRIAYgKNQAo3PpZSsyPXw8A==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.3.tgz", + "integrity": "sha512-adCgNMBeeMqs3C0jumjv/ewIvBo37b3QGFSm21pBpvZIA9Td9gZXVF4+1uBMeUrOLy/8okNGuO7ao9r8jhrR5g==", "cpu": [ "x64" ], @@ -743,9 +743,9 @@ } }, "node_modules/@github/copilot-win32-arm64": { - "version": "1.0.3-0", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.3-0.tgz", - "integrity": "sha512-ZjUDdE7IOi6EeUEb8hJvRu5RqPrY5kuPzdqMAiIqwDervBdNJwy9AkCNtg0jJ2fPamoQgKSFcAX7QaUX4kMx3A==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.3.tgz", + "integrity": "sha512-vmHkjwzr4VZFOTE17n5GxL2qP9GPr6Z39xzdtLfGnv1uJOIk1UPKdpzBUoFNVTumtz0I0ZnRPJI1jF+MgKiafQ==", "cpu": [ "arm64" ], @@ -759,9 +759,9 @@ } }, "node_modules/@github/copilot-win32-x64": { - "version": "1.0.3-0", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.3-0.tgz", - "integrity": "sha512-mNoeF4hwbxXxDtGZPWe78jEfAwdQbG1Zeyztme7Z19NjZF4bUI/iDaifKUfn+fMzGHZyykoaPl9mLrTSYr77Cw==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.3.tgz", + "integrity": "sha512-hIbzYdpXuM6PoSTS4NX8UOlbOPwCJ7bSsAe8JvJdo7lRv6Fcj4Xj/ZQmC9gDsiTZxBL2aIxQtn0WVYLFWnvMjQ==", "cpu": [ "x64" ], diff --git a/nodejs/package.json b/nodejs/package.json index 4b407127..61b34c8a 100644 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -44,7 +44,7 @@ "author": "GitHub", "license": "MIT", "dependencies": { - "@github/copilot": "^1.0.3-0", + "@github/copilot": "^1.0.3", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" }, diff --git a/nodejs/test/e2e/session.test.ts b/nodejs/test/e2e/session.test.ts index 40147059..ae2fba79 100644 --- a/nodejs/test/e2e/session.test.ts +++ b/nodejs/test/e2e/session.test.ts @@ -299,9 +299,16 @@ describe("Sessions", async () => { it("should receive session events", async () => { const session = await client.createSession({ onPermissionRequest: approveAll }); const receivedEvents: Array<{ type: string }> = []; + let resolveShutdown: () => void; + const shutdownReceived = new Promise((resolve) => { + resolveShutdown = resolve; + }); session.on((event) => { receivedEvents.push(event); + if (event.type === "session.shutdown") { + resolveShutdown(); + } }); // Send a message and wait for completion @@ -316,8 +323,15 @@ describe("Sessions", async () => { // Verify the assistant response contains the expected answer expect(assistantMessage?.data.content).toContain("300"); - // Shut down session (sends RPC without clearing handlers), then destroy + // Shut down session and verify shutdown event is received await session.shutdown(); + await Promise.race([ + shutdownReceived, + new Promise((_, reject) => + setTimeout(() => reject(new Error("Timed out waiting for session.shutdown")), 5000) + ), + ]); + expect(receivedEvents.some((e) => e.type === "session.shutdown")).toBe(true); await session.destroy(); }); diff --git a/python/e2e/test_session.py b/python/e2e/test_session.py index 5a218840..86bf1f33 100644 --- a/python/e2e/test_session.py +++ b/python/e2e/test_session.py @@ -455,11 +455,14 @@ async def test_should_receive_session_events(self, ctx: E2ETestContext): ) received_events = [] idle_event = asyncio.Event() + shutdown_event = asyncio.Event() def on_event(event): received_events.append(event) if event.type.value == "session.idle": idle_event.set() + elif event.type.value == "session.shutdown": + shutdown_event.set() session.on(on_event) @@ -483,8 +486,14 @@ def on_event(event): assistant_message = await get_final_assistant_message(session) assert "300" in assistant_message.data.content - # Shut down session (sends RPC without clearing handlers), then destroy + # Shut down session and verify shutdown event is received await session.shutdown() + try: + await asyncio.wait_for(shutdown_event.wait(), timeout=5) + except TimeoutError: + pytest.fail("Timed out waiting for session.shutdown") + event_types = [e.type.value for e in received_events] + assert "session.shutdown" in event_types await session.destroy() async def test_should_create_session_with_custom_config_dir(self, ctx: E2ETestContext):