Skip to content

Commit 4125fe7

Browse files
stephentoubCopilot
andauthored
Register sessions before RPC and add SessionConfig.OnEvent (#664)
Register sessions in the client's sessions map before issuing the session.create and session.resume RPC calls, so that events emitted by the CLI during the RPC (e.g. session.start, permission requests, tool calls) are not dropped. Previously, sessions were registered only after the RPC completed, creating a window where notifications for the session had no target. The session ID is now generated client-side (via UUID) rather than extracted from the server response. On RPC failure, the session is cleaned up from the map. Add a new OnEvent property to each SDK's session configuration (SessionConfig / ResumeSessionConfig) that registers an event handler on the session before the create/resume RPC is issued. This guarantees that early events emitted by the CLI during session creation (e.g. session.start) are delivered to the handler, unlike calling On() after createSession() returns. Changes across all four SDKs (Node.js, Python, Go, .NET): - Generate sessionId client-side before the RPC - Create and register the session in the sessions map before the RPC - Set workspacePath from the RPC response after it completes - Remove the session from the map if the RPC fails - Add OnEvent/on_event config property wired up before the RPC Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 7235609 commit 4125fe7

File tree

78 files changed

+580
-209
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

78 files changed

+580
-209
lines changed

dotnet/src/Client.cs

Lines changed: 90 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -403,34 +403,11 @@ public async Task<CopilotSession> CreateSessionAsync(SessionConfig config, Cance
403403
config.Hooks.OnSessionEnd != null ||
404404
config.Hooks.OnErrorOccurred != null);
405405

406-
var request = new CreateSessionRequest(
407-
config.Model,
408-
config.SessionId,
409-
config.ClientName,
410-
config.ReasoningEffort,
411-
config.Tools?.Select(ToolDefinition.FromAIFunction).ToList(),
412-
config.SystemMessage,
413-
config.AvailableTools,
414-
config.ExcludedTools,
415-
config.Provider,
416-
(bool?)true,
417-
config.OnUserInputRequest != null ? true : null,
418-
hasHooks ? true : null,
419-
config.WorkingDirectory,
420-
config.Streaming is true ? true : null,
421-
config.McpServers,
422-
"direct",
423-
config.CustomAgents,
424-
config.Agent,
425-
config.ConfigDir,
426-
config.SkillDirectories,
427-
config.DisabledSkills,
428-
config.InfiniteSessions);
429-
430-
var response = await InvokeRpcAsync<CreateSessionResponse>(
431-
connection.Rpc, "session.create", [request], cancellationToken);
432-
433-
var session = new CopilotSession(response.SessionId, connection.Rpc, response.WorkspacePath);
406+
var sessionId = config.SessionId ?? Guid.NewGuid().ToString();
407+
408+
// Create and register the session before issuing the RPC so that
409+
// events emitted by the CLI (e.g. session.start) are not dropped.
410+
var session = new CopilotSession(sessionId, connection.Rpc);
434411
session.RegisterTools(config.Tools ?? []);
435412
session.RegisterPermissionHandler(config.OnPermissionRequest);
436413
if (config.OnUserInputRequest != null)
@@ -441,10 +418,47 @@ public async Task<CopilotSession> CreateSessionAsync(SessionConfig config, Cance
441418
{
442419
session.RegisterHooks(config.Hooks);
443420
}
421+
if (config.OnEvent != null)
422+
{
423+
session.On(config.OnEvent);
424+
}
425+
_sessions[sessionId] = session;
444426

445-
if (!_sessions.TryAdd(response.SessionId, session))
427+
try
446428
{
447-
throw new InvalidOperationException($"Session {response.SessionId} already exists");
429+
var request = new CreateSessionRequest(
430+
config.Model,
431+
sessionId,
432+
config.ClientName,
433+
config.ReasoningEffort,
434+
config.Tools?.Select(ToolDefinition.FromAIFunction).ToList(),
435+
config.SystemMessage,
436+
config.AvailableTools,
437+
config.ExcludedTools,
438+
config.Provider,
439+
(bool?)true,
440+
config.OnUserInputRequest != null ? true : null,
441+
hasHooks ? true : null,
442+
config.WorkingDirectory,
443+
config.Streaming is true ? true : null,
444+
config.McpServers,
445+
"direct",
446+
config.CustomAgents,
447+
config.Agent,
448+
config.ConfigDir,
449+
config.SkillDirectories,
450+
config.DisabledSkills,
451+
config.InfiniteSessions);
452+
453+
var response = await InvokeRpcAsync<CreateSessionResponse>(
454+
connection.Rpc, "session.create", [request], cancellationToken);
455+
456+
session.WorkspacePath = response.WorkspacePath;
457+
}
458+
catch
459+
{
460+
_sessions.TryRemove(sessionId, out _);
461+
throw;
448462
}
449463

450464
return session;
@@ -495,35 +509,9 @@ public async Task<CopilotSession> ResumeSessionAsync(string sessionId, ResumeSes
495509
config.Hooks.OnSessionEnd != null ||
496510
config.Hooks.OnErrorOccurred != null);
497511

498-
var request = new ResumeSessionRequest(
499-
sessionId,
500-
config.ClientName,
501-
config.Model,
502-
config.ReasoningEffort,
503-
config.Tools?.Select(ToolDefinition.FromAIFunction).ToList(),
504-
config.SystemMessage,
505-
config.AvailableTools,
506-
config.ExcludedTools,
507-
config.Provider,
508-
(bool?)true,
509-
config.OnUserInputRequest != null ? true : null,
510-
hasHooks ? true : null,
511-
config.WorkingDirectory,
512-
config.ConfigDir,
513-
config.DisableResume is true ? true : null,
514-
config.Streaming is true ? true : null,
515-
config.McpServers,
516-
"direct",
517-
config.CustomAgents,
518-
config.Agent,
519-
config.SkillDirectories,
520-
config.DisabledSkills,
521-
config.InfiniteSessions);
522-
523-
var response = await InvokeRpcAsync<ResumeSessionResponse>(
524-
connection.Rpc, "session.resume", [request], cancellationToken);
525-
526-
var session = new CopilotSession(response.SessionId, connection.Rpc, response.WorkspacePath);
512+
// Create and register the session before issuing the RPC so that
513+
// events emitted by the CLI (e.g. session.start) are not dropped.
514+
var session = new CopilotSession(sessionId, connection.Rpc);
527515
session.RegisterTools(config.Tools ?? []);
528516
session.RegisterPermissionHandler(config.OnPermissionRequest);
529517
if (config.OnUserInputRequest != null)
@@ -534,9 +522,50 @@ public async Task<CopilotSession> ResumeSessionAsync(string sessionId, ResumeSes
534522
{
535523
session.RegisterHooks(config.Hooks);
536524
}
525+
if (config.OnEvent != null)
526+
{
527+
session.On(config.OnEvent);
528+
}
529+
_sessions[sessionId] = session;
530+
531+
try
532+
{
533+
var request = new ResumeSessionRequest(
534+
sessionId,
535+
config.ClientName,
536+
config.Model,
537+
config.ReasoningEffort,
538+
config.Tools?.Select(ToolDefinition.FromAIFunction).ToList(),
539+
config.SystemMessage,
540+
config.AvailableTools,
541+
config.ExcludedTools,
542+
config.Provider,
543+
(bool?)true,
544+
config.OnUserInputRequest != null ? true : null,
545+
hasHooks ? true : null,
546+
config.WorkingDirectory,
547+
config.ConfigDir,
548+
config.DisableResume is true ? true : null,
549+
config.Streaming is true ? true : null,
550+
config.McpServers,
551+
"direct",
552+
config.CustomAgents,
553+
config.Agent,
554+
config.SkillDirectories,
555+
config.DisabledSkills,
556+
config.InfiniteSessions);
557+
558+
var response = await InvokeRpcAsync<ResumeSessionResponse>(
559+
connection.Rpc, "session.resume", [request], cancellationToken);
560+
561+
session.WorkspacePath = response.WorkspacePath;
562+
}
563+
catch
564+
{
565+
_sessions.TryRemove(sessionId, out _);
566+
throw;
567+
}
537568

538-
// Replace any existing session entry to ensure new config (like permission handler) is used
539-
_sessions[response.SessionId] = session;
540569
return session;
541570
}
542571

dotnet/src/Session.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ public sealed partial class CopilotSession : IAsyncDisposable
8686
/// The path to the workspace containing checkpoints/, plan.md, and files/ subdirectories,
8787
/// or null if infinite sessions are disabled.
8888
/// </value>
89-
public string? WorkspacePath { get; }
89+
public string? WorkspacePath { get; internal set; }
9090

9191
/// <summary>
9292
/// Initializes a new instance of the <see cref="CopilotSession"/> class.

dotnet/src/Types.cs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1183,6 +1183,7 @@ protected SessionConfig(SessionConfig? other)
11831183
? new Dictionary<string, object>(other.McpServers, other.McpServers.Comparer)
11841184
: null;
11851185
Model = other.Model;
1186+
OnEvent = other.OnEvent;
11861187
OnPermissionRequest = other.OnPermissionRequest;
11871188
OnUserInputRequest = other.OnUserInputRequest;
11881189
Provider = other.Provider;
@@ -1307,6 +1308,18 @@ protected SessionConfig(SessionConfig? other)
13071308
/// </summary>
13081309
public InfiniteSessionConfig? InfiniteSessions { get; set; }
13091310

1311+
/// <summary>
1312+
/// Optional event handler that is registered on the session before the
1313+
/// session.create RPC is issued.
1314+
/// </summary>
1315+
/// <remarks>
1316+
/// Equivalent to calling <see cref="CopilotSession.On"/> immediately
1317+
/// after creation, but executes earlier in the lifecycle so no events are missed.
1318+
/// Using this property rather than <see cref="CopilotSession.On"/> guarantees that early events emitted
1319+
/// by the CLI during session creation (e.g. session.start) are delivered to the handler.
1320+
/// </remarks>
1321+
public SessionEventHandler? OnEvent { get; set; }
1322+
13101323
/// <summary>
13111324
/// Creates a shallow clone of this <see cref="SessionConfig"/> instance.
13121325
/// </summary>
@@ -1355,6 +1368,7 @@ protected ResumeSessionConfig(ResumeSessionConfig? other)
13551368
? new Dictionary<string, object>(other.McpServers, other.McpServers.Comparer)
13561369
: null;
13571370
Model = other.Model;
1371+
OnEvent = other.OnEvent;
13581372
OnPermissionRequest = other.OnPermissionRequest;
13591373
OnUserInputRequest = other.OnUserInputRequest;
13601374
Provider = other.Provider;
@@ -1482,6 +1496,12 @@ protected ResumeSessionConfig(ResumeSessionConfig? other)
14821496
/// </summary>
14831497
public InfiniteSessionConfig? InfiniteSessions { get; set; }
14841498

1499+
/// <summary>
1500+
/// Optional event handler registered before the session.resume RPC is issued,
1501+
/// ensuring early events are delivered. See <see cref="SessionConfig.OnEvent"/>.
1502+
/// </summary>
1503+
public SessionEventHandler? OnEvent { get; set; }
1504+
14851505
/// <summary>
14861506
/// Creates a shallow clone of this <see cref="ResumeSessionConfig"/> instance.
14871507
/// </summary>

dotnet/test/SessionTests.cs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -245,7 +245,17 @@ await session.SendAsync(new MessageOptions
245245
[Fact]
246246
public async Task Should_Receive_Session_Events()
247247
{
248-
var session = await CreateSessionAsync();
248+
// Use OnEvent to capture events dispatched during session creation.
249+
// session.start is emitted during the session.create RPC; if the session
250+
// weren't registered in the sessions map before the RPC, it would be dropped.
251+
var earlyEvents = new List<SessionEvent>();
252+
var session = await CreateSessionAsync(new SessionConfig
253+
{
254+
OnEvent = evt => earlyEvents.Add(evt),
255+
});
256+
257+
Assert.Contains(earlyEvents, evt => evt is SessionStartEvent);
258+
249259
var receivedEvents = new List<SessionEvent>();
250260
var idleReceived = new TaskCompletionSource<bool>();
251261

go/client.go

Lines changed: 56 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ import (
4444
"sync/atomic"
4545
"time"
4646

47+
"github.com/google/uuid"
48+
4749
"github.com/github/copilot-sdk/go/internal/embeddedcli"
4850
"github.com/github/copilot-sdk/go/internal/jsonrpc2"
4951
"github.com/github/copilot-sdk/go/rpc"
@@ -493,7 +495,6 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses
493495

494496
req := createSessionRequest{}
495497
req.Model = config.Model
496-
req.SessionID = config.SessionID
497498
req.ClientName = config.ClientName
498499
req.ReasoningEffort = config.ReasoningEffort
499500
req.ConfigDir = config.ConfigDir
@@ -527,17 +528,15 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses
527528
}
528529
req.RequestPermission = Bool(true)
529530

530-
result, err := c.client.Request("session.create", req)
531-
if err != nil {
532-
return nil, fmt.Errorf("failed to create session: %w", err)
533-
}
534-
535-
var response createSessionResponse
536-
if err := json.Unmarshal(result, &response); err != nil {
537-
return nil, fmt.Errorf("failed to unmarshal response: %w", err)
531+
sessionID := config.SessionID
532+
if sessionID == "" {
533+
sessionID = uuid.New().String()
538534
}
535+
req.SessionID = sessionID
539536

540-
session := newSession(response.SessionID, c.client, response.WorkspacePath)
537+
// Create and register the session before issuing the RPC so that
538+
// events emitted by the CLI (e.g. session.start) are not dropped.
539+
session := newSession(sessionID, c.client, "")
541540

542541
session.registerTools(config.Tools)
543542
session.registerPermissionHandler(config.OnPermissionRequest)
@@ -547,11 +546,32 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses
547546
if config.Hooks != nil {
548547
session.registerHooks(config.Hooks)
549548
}
549+
if config.OnEvent != nil {
550+
session.On(config.OnEvent)
551+
}
550552

551553
c.sessionsMux.Lock()
552-
c.sessions[response.SessionID] = session
554+
c.sessions[sessionID] = session
553555
c.sessionsMux.Unlock()
554556

557+
result, err := c.client.Request("session.create", req)
558+
if err != nil {
559+
c.sessionsMux.Lock()
560+
delete(c.sessions, sessionID)
561+
c.sessionsMux.Unlock()
562+
return nil, fmt.Errorf("failed to create session: %w", err)
563+
}
564+
565+
var response createSessionResponse
566+
if err := json.Unmarshal(result, &response); err != nil {
567+
c.sessionsMux.Lock()
568+
delete(c.sessions, sessionID)
569+
c.sessionsMux.Unlock()
570+
return nil, fmt.Errorf("failed to unmarshal response: %w", err)
571+
}
572+
573+
session.workspacePath = response.WorkspacePath
574+
555575
return session, nil
556576
}
557577

@@ -627,17 +647,10 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string,
627647
req.InfiniteSessions = config.InfiniteSessions
628648
req.RequestPermission = Bool(true)
629649

630-
result, err := c.client.Request("session.resume", req)
631-
if err != nil {
632-
return nil, fmt.Errorf("failed to resume session: %w", err)
633-
}
650+
// Create and register the session before issuing the RPC so that
651+
// events emitted by the CLI (e.g. session.start) are not dropped.
652+
session := newSession(sessionID, c.client, "")
634653

635-
var response resumeSessionResponse
636-
if err := json.Unmarshal(result, &response); err != nil {
637-
return nil, fmt.Errorf("failed to unmarshal response: %w", err)
638-
}
639-
640-
session := newSession(response.SessionID, c.client, response.WorkspacePath)
641654
session.registerTools(config.Tools)
642655
session.registerPermissionHandler(config.OnPermissionRequest)
643656
if config.OnUserInputRequest != nil {
@@ -646,11 +659,32 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string,
646659
if config.Hooks != nil {
647660
session.registerHooks(config.Hooks)
648661
}
662+
if config.OnEvent != nil {
663+
session.On(config.OnEvent)
664+
}
649665

650666
c.sessionsMux.Lock()
651-
c.sessions[response.SessionID] = session
667+
c.sessions[sessionID] = session
652668
c.sessionsMux.Unlock()
653669

670+
result, err := c.client.Request("session.resume", req)
671+
if err != nil {
672+
c.sessionsMux.Lock()
673+
delete(c.sessions, sessionID)
674+
c.sessionsMux.Unlock()
675+
return nil, fmt.Errorf("failed to resume session: %w", err)
676+
}
677+
678+
var response resumeSessionResponse
679+
if err := json.Unmarshal(result, &response); err != nil {
680+
c.sessionsMux.Lock()
681+
delete(c.sessions, sessionID)
682+
c.sessionsMux.Unlock()
683+
return nil, fmt.Errorf("failed to unmarshal response: %w", err)
684+
}
685+
686+
session.workspacePath = response.WorkspacePath
687+
654688
return session, nil
655689
}
656690

go/go.mod

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,5 @@ require (
66
github.com/google/jsonschema-go v0.4.2
77
github.com/klauspost/compress v1.18.3
88
)
9+
10+
require github.com/google/uuid v1.6.0

0 commit comments

Comments
 (0)