Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 88 additions & 59 deletions dotnet/src/Client.cs
Original file line number Diff line number Diff line change
Expand Up @@ -381,33 +381,11 @@ public async Task<CopilotSession> CreateSessionAsync(SessionConfig config, Cance
config.Hooks.OnSessionEnd != null ||
config.Hooks.OnErrorOccurred != null);

var request = new CreateSessionRequest(
config.Model,
config.SessionId,
config.ClientName,
config.ReasoningEffort,
config.Tools?.Select(ToolDefinition.FromAIFunction).ToList(),
config.SystemMessage,
config.AvailableTools,
config.ExcludedTools,
config.Provider,
(bool?)true,
config.OnUserInputRequest != null ? true : null,
hasHooks ? true : null,
config.WorkingDirectory,
config.Streaming is true ? true : null,
config.McpServers,
"direct",
config.CustomAgents,
config.ConfigDir,
config.SkillDirectories,
config.DisabledSkills,
config.InfiniteSessions);

var response = await InvokeRpcAsync<CreateSessionResponse>(
connection.Rpc, "session.create", [request], cancellationToken);

var session = new CopilotSession(response.SessionId, connection.Rpc, response.WorkspacePath);
var sessionId = config.SessionId ?? Guid.NewGuid().ToString();

// Create and register the session before issuing the RPC so that
// events emitted by the CLI (e.g. session.start) are not dropped.
var session = new CopilotSession(sessionId, connection.Rpc);
session.RegisterTools(config.Tools ?? []);
session.RegisterPermissionHandler(config.OnPermissionRequest);
if (config.OnUserInputRequest != null)
Expand All @@ -418,10 +396,46 @@ public async Task<CopilotSession> CreateSessionAsync(SessionConfig config, Cance
{
session.RegisterHooks(config.Hooks);
}
if (config.OnEvent != null)
{
session.On(config.OnEvent);
}
_sessions[sessionId] = session;

if (!_sessions.TryAdd(response.SessionId, session))
try
{
throw new InvalidOperationException($"Session {response.SessionId} already exists");
var request = new CreateSessionRequest(
config.Model,
sessionId,
config.ClientName,
config.ReasoningEffort,
config.Tools?.Select(ToolDefinition.FromAIFunction).ToList(),
config.SystemMessage,
config.AvailableTools,
config.ExcludedTools,
config.Provider,
(bool?)true,
config.OnUserInputRequest != null ? true : null,
hasHooks ? true : null,
config.WorkingDirectory,
config.Streaming is true ? true : null,
config.McpServers,
"direct",
config.CustomAgents,
config.ConfigDir,
config.SkillDirectories,
config.DisabledSkills,
config.InfiniteSessions);

var response = await InvokeRpcAsync<CreateSessionResponse>(
connection.Rpc, "session.create", [request], cancellationToken);

session.WorkspacePath = response.WorkspacePath;
}
catch
{
_sessions.TryRemove(sessionId, out _);
throw;
}

return session;
Expand Down Expand Up @@ -472,34 +486,9 @@ public async Task<CopilotSession> ResumeSessionAsync(string sessionId, ResumeSes
config.Hooks.OnSessionEnd != null ||
config.Hooks.OnErrorOccurred != null);

var request = new ResumeSessionRequest(
sessionId,
config.ClientName,
config.Model,
config.ReasoningEffort,
config.Tools?.Select(ToolDefinition.FromAIFunction).ToList(),
config.SystemMessage,
config.AvailableTools,
config.ExcludedTools,
config.Provider,
(bool?)true,
config.OnUserInputRequest != null ? true : null,
hasHooks ? true : null,
config.WorkingDirectory,
config.ConfigDir,
config.DisableResume is true ? true : null,
config.Streaming is true ? true : null,
config.McpServers,
"direct",
config.CustomAgents,
config.SkillDirectories,
config.DisabledSkills,
config.InfiniteSessions);

var response = await InvokeRpcAsync<ResumeSessionResponse>(
connection.Rpc, "session.resume", [request], cancellationToken);

var session = new CopilotSession(response.SessionId, connection.Rpc, response.WorkspacePath);
// Create and register the session before issuing the RPC so that
// events emitted by the CLI (e.g. session.start) are not dropped.
var session = new CopilotSession(sessionId, connection.Rpc);
session.RegisterTools(config.Tools ?? []);
session.RegisterPermissionHandler(config.OnPermissionRequest);
if (config.OnUserInputRequest != null)
Expand All @@ -510,9 +499,49 @@ public async Task<CopilotSession> ResumeSessionAsync(string sessionId, ResumeSes
{
session.RegisterHooks(config.Hooks);
}
if (config.OnEvent != null)
{
session.On(config.OnEvent);
}
_sessions[sessionId] = session;

try
{
var request = new ResumeSessionRequest(
sessionId,
config.ClientName,
config.Model,
config.ReasoningEffort,
config.Tools?.Select(ToolDefinition.FromAIFunction).ToList(),
config.SystemMessage,
config.AvailableTools,
config.ExcludedTools,
config.Provider,
(bool?)true,
config.OnUserInputRequest != null ? true : null,
hasHooks ? true : null,
config.WorkingDirectory,
config.ConfigDir,
config.DisableResume is true ? true : null,
config.Streaming is true ? true : null,
config.McpServers,
"direct",
config.CustomAgents,
config.SkillDirectories,
config.DisabledSkills,
config.InfiniteSessions);

var response = await InvokeRpcAsync<ResumeSessionResponse>(
connection.Rpc, "session.resume", [request], cancellationToken);

session.WorkspacePath = response.WorkspacePath;
}
catch
{
_sessions.TryRemove(sessionId, out _);
throw;
}

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

Expand Down
2 changes: 1 addition & 1 deletion dotnet/src/Session.cs
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ public partial class CopilotSession : IAsyncDisposable
/// The path to the workspace containing checkpoints/, plan.md, and files/ subdirectories,
/// or null if infinite sessions are disabled.
/// </value>
public string? WorkspacePath { get; }
public string? WorkspacePath { get; internal set; }

/// <summary>
/// Initializes a new instance of the <see cref="CopilotSession"/> class.
Expand Down
20 changes: 20 additions & 0 deletions dotnet/src/Types.cs
Original file line number Diff line number Diff line change
Expand Up @@ -766,6 +766,7 @@ protected SessionConfig(SessionConfig? other)
? new Dictionary<string, object>(other.McpServers, other.McpServers.Comparer)
: null;
Model = other.Model;
OnEvent = other.OnEvent;
OnPermissionRequest = other.OnPermissionRequest;
OnUserInputRequest = other.OnUserInputRequest;
Provider = other.Provider;
Expand Down Expand Up @@ -864,6 +865,18 @@ protected SessionConfig(SessionConfig? other)
/// </summary>
public InfiniteSessionConfig? InfiniteSessions { get; set; }

/// <summary>
/// Optional event handler that is registered on the session before the
/// session.create RPC is issued.
/// </summary>
/// </remarks>
/// Equivalent to calling <see cref="CopilotSession.On"/> immediately
/// after creation, but executes earlier in the lifecycle so no events are missed.
/// Using this property rather than <see cref="CopilotSession.On"/> guarantees that early events emitted
/// by the CLI during session creation (e.g. session.start) are delivered to the handler.
/// <remarks>
public SessionEventHandler? OnEvent { get; set; }

/// <summary>
/// Creates a shallow clone of this <see cref="SessionConfig"/> instance.
/// </summary>
Expand Down Expand Up @@ -905,6 +918,7 @@ protected ResumeSessionConfig(ResumeSessionConfig? other)
? new Dictionary<string, object>(other.McpServers, other.McpServers.Comparer)
: null;
Model = other.Model;
OnEvent = other.OnEvent;
OnPermissionRequest = other.OnPermissionRequest;
OnUserInputRequest = other.OnUserInputRequest;
Provider = other.Provider;
Expand Down Expand Up @@ -1020,6 +1034,12 @@ protected ResumeSessionConfig(ResumeSessionConfig? other)
/// </summary>
public InfiniteSessionConfig? InfiniteSessions { get; set; }

/// <summary>
/// Optional event handler registered before the session.resume RPC is issued,
/// ensuring early events are delivered. See <see cref="SessionConfig.OnEvent"/>.
/// </summary>
public SessionEventHandler? OnEvent { get; set; }

/// <summary>
/// Creates a shallow clone of this <see cref="ResumeSessionConfig"/> instance.
/// </summary>
Expand Down
12 changes: 11 additions & 1 deletion dotnet/test/SessionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,17 @@ await session.SendAsync(new MessageOptions
[Fact]
public async Task Should_Receive_Session_Events()
{
var session = await CreateSessionAsync();
// Use OnEvent to capture events dispatched during session creation.
// session.start is emitted during the session.create RPC; if the session
// weren't registered in the sessions map before the RPC, it would be dropped.
var earlyEvents = new List<SessionEvent>();
var session = await CreateSessionAsync(new SessionConfig
{
OnEvent = evt => earlyEvents.Add(evt),
});

Assert.Contains(earlyEvents, evt => evt is SessionStartEvent);

var receivedEvents = new List<SessionEvent>();
var idleReceived = new TaskCompletionSource<bool>();

Expand Down
78 changes: 56 additions & 22 deletions go/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ import (
"sync/atomic"
"time"

"github.com/google/uuid"

"github.com/github/copilot-sdk/go/internal/embeddedcli"
"github.com/github/copilot-sdk/go/internal/jsonrpc2"
"github.com/github/copilot-sdk/go/rpc"
Expand Down Expand Up @@ -484,7 +486,6 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses

req := createSessionRequest{}
req.Model = config.Model
req.SessionID = config.SessionID
req.ClientName = config.ClientName
req.ReasoningEffort = config.ReasoningEffort
req.ConfigDir = config.ConfigDir
Expand Down Expand Up @@ -517,17 +518,15 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses
}
req.RequestPermission = Bool(true)

result, err := c.client.Request("session.create", req)
if err != nil {
return nil, fmt.Errorf("failed to create session: %w", err)
}

var response createSessionResponse
if err := json.Unmarshal(result, &response); err != nil {
return nil, fmt.Errorf("failed to unmarshal response: %w", err)
sessionID := config.SessionID
if sessionID == "" {
sessionID = uuid.New().String()
}
req.SessionID = sessionID

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

session.registerTools(config.Tools)
session.registerPermissionHandler(config.OnPermissionRequest)
Expand All @@ -537,11 +536,32 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses
if config.Hooks != nil {
session.registerHooks(config.Hooks)
}
if config.OnEvent != nil {
session.On(config.OnEvent)
}

c.sessionsMux.Lock()
c.sessions[response.SessionID] = session
c.sessions[sessionID] = session
c.sessionsMux.Unlock()

result, err := c.client.Request("session.create", req)
if err != nil {
c.sessionsMux.Lock()
delete(c.sessions, sessionID)
c.sessionsMux.Unlock()
return nil, fmt.Errorf("failed to create session: %w", err)
}

var response createSessionResponse
if err := json.Unmarshal(result, &response); err != nil {
c.sessionsMux.Lock()
delete(c.sessions, sessionID)
c.sessionsMux.Unlock()
return nil, fmt.Errorf("failed to unmarshal response: %w", err)
}

session.workspacePath = response.WorkspacePath

return session, nil
}

Expand Down Expand Up @@ -616,17 +636,10 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string,
req.InfiniteSessions = config.InfiniteSessions
req.RequestPermission = Bool(true)

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

var response resumeSessionResponse
if err := json.Unmarshal(result, &response); err != nil {
return nil, fmt.Errorf("failed to unmarshal response: %w", err)
}

session := newSession(response.SessionID, c.client, response.WorkspacePath)
session.registerTools(config.Tools)
session.registerPermissionHandler(config.OnPermissionRequest)
if config.OnUserInputRequest != nil {
Expand All @@ -635,11 +648,32 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string,
if config.Hooks != nil {
session.registerHooks(config.Hooks)
}
if config.OnEvent != nil {
session.On(config.OnEvent)
}

c.sessionsMux.Lock()
c.sessions[response.SessionID] = session
c.sessions[sessionID] = session
c.sessionsMux.Unlock()

result, err := c.client.Request("session.resume", req)
if err != nil {
c.sessionsMux.Lock()
delete(c.sessions, sessionID)
c.sessionsMux.Unlock()
return nil, fmt.Errorf("failed to resume session: %w", err)
}

var response resumeSessionResponse
if err := json.Unmarshal(result, &response); err != nil {
c.sessionsMux.Lock()
delete(c.sessions, sessionID)
c.sessionsMux.Unlock()
return nil, fmt.Errorf("failed to unmarshal response: %w", err)
}

session.workspacePath = response.WorkspacePath

return session, nil
}

Expand Down
2 changes: 2 additions & 0 deletions go/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,5 @@ require (
github.com/google/jsonschema-go v0.4.2
github.com/klauspost/compress v1.18.3
)

require github.com/google/uuid v1.6.0
2 changes: 2 additions & 0 deletions go/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,7 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw=
github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
Loading
Loading