Skip to content

Commit d4dfee2

Browse files
stephentoubCopilot
andcommitted
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>
1 parent 87a54de commit d4dfee2

File tree

13 files changed

+243
-15
lines changed

13 files changed

+243
-15
lines changed

.github/agents/docs-maintenance.agent.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -344,7 +344,7 @@ cat nodejs/src/types.ts | grep -A 10 "export interface ExportSessionOptions"
344344
**Must match:**
345345
- `CopilotClient` constructor options: `cliPath`, `cliUrl`, `useStdio`, `port`, `logLevel`, `autoStart`, `autoRestart`, `env`, `githubToken`, `useLoggedInUser`
346346
- `createSession()` config: `model`, `tools`, `hooks`, `systemMessage`, `mcpServers`, `availableTools`, `excludedTools`, `streaming`, `reasoningEffort`, `provider`, `infiniteSessions`, `customAgents`, `workingDirectory`
347-
- `CopilotSession` methods: `send()`, `sendAndWait()`, `getMessages()`, `destroy()`, `abort()`, `on()`, `once()`, `off()`
347+
- `CopilotSession` methods: `send()`, `sendAndWait()`, `getMessages()`, `shutdown()`, `destroy()`, `abort()`, `on()`, `once()`, `off()`
348348
- Hook names: `onPreToolUse`, `onPostToolUse`, `onUserPromptSubmitted`, `onSessionStart`, `onSessionEnd`, `onErrorOccurred`
349349

350350
#### Python Validation
@@ -362,7 +362,7 @@ cat python/copilot/types.py | grep -A 15 "class SessionHooks"
362362
**Must match (snake_case):**
363363
- `CopilotClient` options: `cli_path`, `cli_url`, `use_stdio`, `port`, `log_level`, `auto_start`, `auto_restart`, `env`, `github_token`, `use_logged_in_user`
364364
- `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`
365-
- `CopilotSession` methods: `send()`, `send_and_wait()`, `get_messages()`, `destroy()`, `abort()`, `export_session()`
365+
- `CopilotSession` methods: `send()`, `send_and_wait()`, `get_messages()`, `shutdown()`, `destroy()`, `abort()`, `export_session()`
366366
- Hook names: `on_pre_tool_use`, `on_post_tool_use`, `on_user_prompt_submitted`, `on_session_start`, `on_session_end`, `on_error_occurred`
367367

368368
#### Go Validation
@@ -380,7 +380,7 @@ cat go/types.go | grep -A 15 "type SessionHooks struct"
380380
**Must match (PascalCase for exported):**
381381
- `ClientOptions` fields: `CLIPath`, `CLIUrl`, `UseStdio`, `Port`, `LogLevel`, `AutoStart`, `AutoRestart`, `Env`, `GithubToken`, `UseLoggedInUser`
382382
- `SessionConfig` fields: `Model`, `Tools`, `Hooks`, `SystemMessage`, `MCPServers`, `AvailableTools`, `ExcludedTools`, `Streaming`, `ReasoningEffort`, `Provider`, `InfiniteSessions`, `CustomAgents`, `WorkingDirectory`
383-
- `Session` methods: `Send()`, `SendAndWait()`, `GetMessages()`, `Destroy()`, `Abort()`, `ExportSession()`
383+
- `Session` methods: `Send()`, `SendAndWait()`, `GetMessages()`, `Shutdown()`, `Destroy()`, `Abort()`, `ExportSession()`
384384
- Hook fields: `OnPreToolUse`, `OnPostToolUse`, `OnUserPromptSubmitted`, `OnSessionStart`, `OnSessionEnd`, `OnErrorOccurred`
385385

386386
#### .NET Validation
@@ -398,7 +398,7 @@ cat dotnet/src/Types.cs | grep -A 15 "public class SessionHooks"
398398
**Must match (PascalCase):**
399399
- `CopilotClientOptions` properties: `CliPath`, `CliUrl`, `UseStdio`, `Port`, `LogLevel`, `AutoStart`, `AutoRestart`, `Environment`, `GithubToken`, `UseLoggedInUser`
400400
- `SessionConfig` properties: `Model`, `Tools`, `Hooks`, `SystemMessage`, `McpServers`, `AvailableTools`, `ExcludedTools`, `Streaming`, `ReasoningEffort`, `Provider`, `InfiniteSessions`, `CustomAgents`, `WorkingDirectory`
401-
- `CopilotSession` methods: `SendAsync()`, `SendAndWaitAsync()`, `GetMessagesAsync()`, `DisposeAsync()`, `AbortAsync()`, `ExportSessionAsync()`
401+
- `CopilotSession` methods: `SendAsync()`, `SendAndWaitAsync()`, `GetMessagesAsync()`, `ShutdownAsync()`, `DisposeAsync()`, `AbortAsync()`, `ExportSessionAsync()`
402402
- Hook properties: `OnPreToolUse`, `OnPostToolUse`, `OnUserPromptSubmitted`, `OnSessionStart`, `OnSessionEnd`, `OnErrorOccurred`
403403

404404
#### Common Sample Errors to Check

docs/compatibility.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ The Copilot SDK communicates with the CLI via JSON-RPC protocol. Features must b
1616
| Create session | `createSession()` | Full config support |
1717
| Resume session | `resumeSession()` | With infinite session workspaces |
1818
| Destroy session | `destroy()` | Clean up resources |
19+
| Shutdown session | `shutdown()` | End session server-side, keeping handlers active |
1920
| Delete session | `deleteSession()` | Remove from storage |
2021
| List sessions | `listSessions()` | All stored sessions |
2122
| Get last session | `getLastSessionId()` | For quick resume |

dotnet/README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -219,7 +219,11 @@ Get all events/messages from this session.
219219

220220
##### `DisposeAsync(): ValueTask`
221221

222-
Dispose the session and free resources.
222+
Dispose the session and free resources. Calls `ShutdownAsync()` first if not already called.
223+
224+
##### `ShutdownAsync(CancellationToken): Task`
225+
226+
Shut down the session on the server without clearing local event handlers. Call this before `DisposeAsync()` when you want to observe the `SessionShutdownEvent`.
223227

224228
---
225229

dotnet/src/Session.cs

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ public partial class CopilotSession : IAsyncDisposable
5959
private readonly SemaphoreSlim _hooksLock = new(1, 1);
6060
private SessionRpc? _sessionRpc;
6161
private int _isDisposed;
62+
private int _isShutdown;
6263

6364
/// <summary>
6465
/// Gets the unique identifier for this session.
@@ -523,6 +524,42 @@ public async Task SetModelAsync(string model, CancellationToken cancellationToke
523524
await Rpc.Model.SwitchToAsync(model, cancellationToken);
524525
}
525526

527+
/// <summary>
528+
/// Shuts down this session on the server without clearing local event handlers.
529+
/// </summary>
530+
/// <remarks>
531+
/// <para>
532+
/// Call this before <see cref="DisposeAsync"/> when you want to observe the
533+
/// <see cref="SessionShutdownEvent"/>. The event is dispatched to registered handlers
534+
/// after this method returns. Once you have processed the event, call
535+
/// <see cref="DisposeAsync"/> to clear handlers and release local resources.
536+
/// </para>
537+
/// <para>
538+
/// If the session has already been shut down, this is a no-op.
539+
/// </para>
540+
/// </remarks>
541+
/// <param name="cancellationToken">A cancellation token to cancel the operation.</param>
542+
/// <returns>A task representing the asynchronous shutdown operation.</returns>
543+
/// <example>
544+
/// <code>
545+
/// var shutdownTcs = new TaskCompletionSource();
546+
/// session.On(evt => { if (evt is SessionShutdownEvent) shutdownTcs.TrySetResult(); });
547+
/// await session.ShutdownAsync();
548+
/// await shutdownTcs.Task;
549+
/// await session.DisposeAsync();
550+
/// </code>
551+
/// </example>
552+
public async Task ShutdownAsync(CancellationToken cancellationToken = default)
553+
{
554+
if (Interlocked.Exchange(ref _isShutdown, 1) == 1)
555+
{
556+
return;
557+
}
558+
559+
await InvokeRpcAsync<object>(
560+
"session.destroy", [new SessionDestroyRequest() { SessionId = SessionId }], cancellationToken);
561+
}
562+
526563
/// <summary>
527564
/// Disposes the <see cref="CopilotSession"/> and releases all associated resources.
528565
/// </summary>
@@ -533,6 +570,11 @@ public async Task SetModelAsync(string model, CancellationToken cancellationToke
533570
/// and tool handlers are cleared.
534571
/// </para>
535572
/// <para>
573+
/// If <see cref="ShutdownAsync"/> was not called first, this method calls it automatically.
574+
/// In that case the <see cref="SessionShutdownEvent"/> may not be observed because handlers
575+
/// are cleared immediately after the server responds.
576+
/// </para>
577+
/// <para>
536578
/// To continue the conversation, use <see cref="CopilotClient.ResumeSessionAsync"/>
537579
/// with the session ID.
538580
/// </para>
@@ -557,8 +599,7 @@ public async ValueTask DisposeAsync()
557599

558600
try
559601
{
560-
await InvokeRpcAsync<object>(
561-
"session.destroy", [new SessionDestroyRequest() { SessionId = SessionId }], CancellationToken.None);
602+
await ShutdownAsync();
562603
}
563604
catch (ObjectDisposedException)
564605
{

dotnet/test/SessionTests.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,7 @@ public async Task Should_Receive_Session_Events()
247247
var session = await CreateSessionAsync();
248248
var receivedEvents = new List<SessionEvent>();
249249
var idleReceived = new TaskCompletionSource<bool>();
250+
var shutdownReceived = new TaskCompletionSource<bool>();
250251

251252
session.On(evt =>
252253
{
@@ -255,6 +256,10 @@ public async Task Should_Receive_Session_Events()
255256
{
256257
idleReceived.TrySetResult(true);
257258
}
259+
else if (evt is SessionShutdownEvent)
260+
{
261+
shutdownReceived.TrySetResult(true);
262+
}
258263
});
259264

260265
// Send a message to trigger events
@@ -275,6 +280,10 @@ public async Task Should_Receive_Session_Events()
275280
Assert.NotNull(assistantMessage);
276281
Assert.Contains("300", assistantMessage!.Data.Content);
277282

283+
// Shut down session and verify shutdown event is received
284+
await session.ShutdownAsync();
285+
await shutdownReceived.Task.WaitAsync(TimeSpan.FromSeconds(5));
286+
Assert.Contains(receivedEvents, evt => evt is SessionShutdownEvent);
278287
await session.DisposeAsync();
279288
}
280289

go/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,7 @@ Event types: `SessionLifecycleCreated`, `SessionLifecycleDeleted`, `SessionLifec
170170
- `Abort(ctx context.Context) error` - Abort the currently processing message
171171
- `GetMessages(ctx context.Context) ([]SessionEvent, error)` - Get message history
172172
- `Destroy() error` - Destroy the session
173+
- `Shutdown() error` - Shut down the session on the server without clearing local handlers (call before `Destroy()` to observe the `session.shutdown` event)
173174

174175
### Helper Functions
175176

go/internal/e2e/session_test.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -593,6 +593,7 @@ func TestSession(t *testing.T) {
593593

594594
var receivedEvents []copilot.SessionEvent
595595
idle := make(chan bool)
596+
shutdown := make(chan bool)
596597

597598
session.On(func(event copilot.SessionEvent) {
598599
receivedEvents = append(receivedEvents, event)
@@ -601,6 +602,11 @@ func TestSession(t *testing.T) {
601602
case idle <- true:
602603
default:
603604
}
605+
} else if event.Type == "session.shutdown" {
606+
select {
607+
case shutdown <- true:
608+
default:
609+
}
604610
}
605611
})
606612

@@ -654,6 +660,28 @@ func TestSession(t *testing.T) {
654660
if assistantMessage.Data.Content == nil || !strings.Contains(*assistantMessage.Data.Content, "300") {
655661
t.Errorf("Expected assistant message to contain '300', got %v", assistantMessage.Data.Content)
656662
}
663+
664+
// Shut down session and verify shutdown event is received
665+
if err := session.Shutdown(); err != nil {
666+
t.Fatalf("Failed to shut down session: %v", err)
667+
}
668+
select {
669+
case <-shutdown:
670+
case <-time.After(5 * time.Second):
671+
t.Fatal("Timed out waiting for session.shutdown")
672+
}
673+
hasShutdown := false
674+
for _, evt := range receivedEvents {
675+
if evt.Type == "session.shutdown" {
676+
hasShutdown = true
677+
}
678+
}
679+
if !hasShutdown {
680+
t.Error("Expected to receive session.shutdown event")
681+
}
682+
if err := session.Destroy(); err != nil {
683+
t.Fatalf("Failed to destroy session: %v", err)
684+
}
657685
})
658686

659687
t.Run("should create session with custom config dir", func(t *testing.T) {

go/session.go

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"encoding/json"
77
"fmt"
88
"sync"
9+
"sync/atomic"
910
"time"
1011

1112
"github.com/github/copilot-sdk/go/internal/jsonrpc2"
@@ -64,6 +65,7 @@ type Session struct {
6465
userInputMux sync.RWMutex
6566
hooks *SessionHooks
6667
hooksMux sync.RWMutex
68+
isShutdown atomic.Bool
6769

6870
// RPC provides typed session-scoped RPC methods.
6971
RPC *rpc.SessionRpc
@@ -511,12 +513,50 @@ func (s *Session) GetMessages(ctx context.Context) ([]SessionEvent, error) {
511513
return response.Events, nil
512514
}
513515

516+
// Shutdown ends this session on the server without clearing local event handlers.
517+
//
518+
// Call this before [Session.Destroy] when you want to observe the session.shutdown
519+
// event. The event is dispatched to registered handlers after this method returns.
520+
// Once you have processed the event, call [Session.Destroy] to clear handlers and
521+
// release local resources.
522+
//
523+
// If the session has already been shut down, this is a no-op.
524+
//
525+
// Returns an error if the connection fails.
526+
//
527+
// Example:
528+
//
529+
// session.On(func(event copilot.SessionEvent) {
530+
// if event.Type == copilot.SessionShutdown {
531+
// fmt.Println("Shutdown metrics:", event.Data)
532+
// }
533+
// })
534+
// if err := session.Shutdown(); err != nil {
535+
// log.Printf("Failed to shut down session: %v", err)
536+
// }
537+
// // ... wait for the shutdown event ...
538+
// session.Destroy()
539+
func (s *Session) Shutdown() error {
540+
if s.isShutdown.Swap(true) {
541+
return nil
542+
}
543+
_, err := s.client.Request("session.destroy", sessionDestroyRequest{SessionID: s.SessionID})
544+
if err != nil {
545+
return fmt.Errorf("failed to shut down session: %w", err)
546+
}
547+
return nil
548+
}
549+
514550
// Destroy destroys this session and releases all associated resources.
515551
//
516552
// After calling this method, the session can no longer be used. All event
517553
// handlers and tool handlers are cleared. To continue the conversation,
518554
// use [Client.ResumeSession] with the session ID.
519555
//
556+
// If [Session.Shutdown] was not called first, this method calls it automatically.
557+
// In that case the session.shutdown event may not be observed because handlers
558+
// are cleared immediately after the server responds.
559+
//
520560
// Returns an error if the connection fails.
521561
//
522562
// Example:
@@ -526,9 +566,8 @@ func (s *Session) GetMessages(ctx context.Context) ([]SessionEvent, error) {
526566
// log.Printf("Failed to destroy session: %v", err)
527567
// }
528568
func (s *Session) Destroy() error {
529-
_, err := s.client.Request("session.destroy", sessionDestroyRequest{SessionID: s.SessionID})
530-
if err != nil {
531-
return fmt.Errorf("failed to destroy session: %w", err)
569+
if err := s.Shutdown(); err != nil {
570+
return err
532571
}
533572

534573
// Clear handlers

nodejs/README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -267,7 +267,11 @@ Get all events/messages from this session.
267267

268268
##### `destroy(): Promise<void>`
269269

270-
Destroy the session and free resources.
270+
Destroy the session and free resources. Calls `shutdown()` first if not already called.
271+
272+
##### `shutdown(): Promise<void>`
273+
274+
Shut down the session on the server without clearing local event handlers. Call this before `destroy()` when you want to observe the `session.shutdown` event.
271275

272276
---
273277

nodejs/src/session.ts

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,8 @@ export class CopilotSession {
7979
private readonly _workspacePath?: string
8080
) {}
8181

82+
private _isShutdown = false;
83+
8284
/**
8385
* Typed session-scoped RPC methods.
8486
*/
@@ -498,13 +500,50 @@ export class CopilotSession {
498500
return (response as { events: SessionEvent[] }).events;
499501
}
500502

503+
/**
504+
* Shuts down this session on the server without clearing local event handlers.
505+
*
506+
* Call this before {@link destroy} when you want to observe the `session.shutdown`
507+
* event. The event is dispatched to registered handlers after this method returns.
508+
* Once you have processed the event, call {@link destroy} to clear handlers and
509+
* release local resources.
510+
*
511+
* If the session has already been shut down, this is a no-op.
512+
*
513+
* @returns A promise that resolves when the server has acknowledged the shutdown
514+
* @throws Error if the connection fails
515+
*
516+
* @example
517+
* ```typescript
518+
* session.on("session.shutdown", (event) => {
519+
* console.log("Shutdown metrics:", event.data.modelMetrics);
520+
* });
521+
* await session.shutdown();
522+
* // ... wait for the shutdown event ...
523+
* await session.destroy();
524+
* ```
525+
*/
526+
async shutdown(): Promise<void> {
527+
if (this._isShutdown) {
528+
return;
529+
}
530+
this._isShutdown = true;
531+
await this.connection.sendRequest("session.destroy", {
532+
sessionId: this.sessionId,
533+
});
534+
}
535+
501536
/**
502537
* Destroys this session and releases all associated resources.
503538
*
504539
* After calling this method, the session can no longer be used. All event
505540
* handlers and tool handlers are cleared. To continue the conversation,
506541
* use {@link CopilotClient.resumeSession} with the session ID.
507542
*
543+
* If {@link shutdown} was not called first, this method calls it automatically.
544+
* In that case the `session.shutdown` event may not be observed because handlers
545+
* are cleared immediately after the server responds.
546+
*
508547
* @returns A promise that resolves when the session is destroyed
509548
* @throws Error if the connection fails
510549
*
@@ -515,9 +554,7 @@ export class CopilotSession {
515554
* ```
516555
*/
517556
async destroy(): Promise<void> {
518-
await this.connection.sendRequest("session.destroy", {
519-
sessionId: this.sessionId,
520-
});
557+
await this.shutdown();
521558
this.eventHandlers.clear();
522559
this.typedEventHandlers.clear();
523560
this.toolHandlers.clear();

0 commit comments

Comments
 (0)