Skip to content
Closed
5 changes: 4 additions & 1 deletion dotnet/src/Client.cs
Original file line number Diff line number Diff line change
Expand Up @@ -766,7 +766,10 @@ public void OnSessionEvent(string sessionId,
if (session != null && @event != null)
{
var evt = SessionEvent.FromJson(@event.Value.GetRawText());
session.DispatchEvent(evt);
if (evt != null)
{
session.DispatchEvent(evt);
}
}
}

Expand Down
2 changes: 1 addition & 1 deletion dotnet/src/Generated/SessionEvents.cs
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ internal class SessionEventConverter : JsonConverter<SessionEvent>
throw new JsonException("Missing 'type' discriminator property");

if (!TypeMap.TryGetValue(typeProp, out var targetType))
throw new JsonException($"Unknown event type: {typeProp}");
return null; // Ignore unknown event types for forward compatibility

// Deserialize to the concrete type without using this converter (to avoid recursion)
return (SessionEvent?)obj.Deserialize(targetType, SerializerOptions.WithoutConverter);
Expand Down
82 changes: 77 additions & 5 deletions dotnet/src/Session.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@ namespace GitHub.Copilot.SDK;
/// }
/// });
///
/// // Send a message
/// await session.SendAsync(new MessageOptions { Prompt = "Hello, world!" });
/// // Send a message and wait for completion
/// await session.SendAndWaitAsync(new MessageOptions { Prompt = "Hello, world!" });
/// </code>
/// </example>
public class CopilotSession : IAsyncDisposable
Expand Down Expand Up @@ -76,8 +76,13 @@ internal CopilotSession(string sessionId, JsonRpc rpc)
/// <returns>A task that resolves with the ID of the response message, which can be used to correlate events.</returns>
/// <exception cref="InvalidOperationException">Thrown if the session has been disposed.</exception>
/// <remarks>
/// The message is processed asynchronously. Subscribe to events via <see cref="On"/> to receive
/// streaming responses and other session events.
/// <para>
/// This method returns immediately after the message is queued. Use <see cref="SendAndWaitAsync"/>
/// if you need to wait for the assistant to finish processing.
/// </para>
/// <para>
/// Subscribe to events via <see cref="On"/> to receive streaming responses and other session events.
/// </para>
/// </remarks>
/// <example>
/// <code>
Expand Down Expand Up @@ -107,6 +112,70 @@ public async Task<string> SendAsync(MessageOptions options, CancellationToken ca
return response.MessageId;
}

/// <summary>
/// Sends a message to the Copilot session and waits until the session becomes idle.
/// </summary>
/// <param name="options">Options for the message to be sent, including the prompt and optional attachments.</param>
/// <param name="timeout">Timeout duration (default: 60 seconds). Controls how long to wait; does not abort in-flight agent work.</param>
/// <param name="cancellationToken">A <see cref="CancellationToken"/> that can be used to cancel the operation.</param>
/// <returns>A task that resolves with the final assistant message event, or null if none was received.</returns>
/// <exception cref="TimeoutException">Thrown if the timeout is reached before the session becomes idle.</exception>
/// <exception cref="InvalidOperationException">Thrown if the session has been disposed.</exception>
/// <remarks>
/// <para>
/// This is a convenience method that combines <see cref="SendAsync"/> with waiting for
/// the <c>session.idle</c> event. Use this when you want to block until the assistant
/// has finished processing the message.
/// </para>
/// <para>
/// Events are still delivered to handlers registered via <see cref="On"/> while waiting.
/// </para>
/// </remarks>
/// <example>
/// <code>
/// // Send and wait for completion with default 60s timeout
/// var response = await session.SendAndWaitAsync(new MessageOptions { Prompt = "What is 2+2?" });
/// Console.WriteLine(response?.Data?.Content); // "4"
/// </code>
/// </example>
public async Task<AssistantMessageEvent?> SendAndWaitAsync(
MessageOptions options,
TimeSpan? timeout = null,
CancellationToken cancellationToken = default)
{
var effectiveTimeout = timeout ?? TimeSpan.FromSeconds(60);
var tcs = new TaskCompletionSource<AssistantMessageEvent?>();
AssistantMessageEvent? lastAssistantMessage = null;

void Handler(SessionEvent evt)
{
if (evt is AssistantMessageEvent assistantMessage)
{
lastAssistantMessage = assistantMessage;
}
else if (evt.Type == "session.idle")
{
tcs.TrySetResult(lastAssistantMessage);
}
else if (evt is SessionErrorEvent errorEvent)
{
var message = errorEvent.Data?.Message ?? "session error";
tcs.TrySetException(new InvalidOperationException($"Session error: {message}"));
}
}

using var subscription = On(Handler);

await SendAsync(options, cancellationToken);

using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(effectiveTimeout);

using var registration = cts.Token.Register(() =>
tcs.TrySetException(new TimeoutException($"SendAndWaitAsync timed out after {effectiveTimeout}")));
return await tcs.Task;
}

/// <summary>
/// Registers a callback for session events.
/// </summary>
Expand Down Expand Up @@ -271,7 +340,10 @@ public async Task<IReadOnlyList<SessionEvent>> GetMessagesAsync(CancellationToke
var response = await _rpc.InvokeWithCancellationAsync<GetMessagesResponse>(
"session.getMessages", [new { sessionId = SessionId }], cancellationToken);

return response.Events.Select(e => SessionEvent.FromJson(e.ToJsonString())).ToList();
return response.Events
.Select(e => SessionEvent.FromJson(e.ToJsonString()))
.Where(e => e != null)
.ToList()!;
}

/// <summary>
Expand Down
5 changes: 3 additions & 2 deletions dotnet/test/Harness/CapiProxy.cs
Original file line number Diff line number Diff line change
Expand Up @@ -84,15 +84,16 @@ async Task<string> StartCoreAsync()
}
}

public async Task StopAsync()
public async Task StopAsync(bool skipWritingCache = false)
{
if (_startupTask != null)
{
try
{
var url = await _startupTask;
var stopUrl = skipWritingCache ? $"{url}/stop?skipWritingCache=true" : $"{url}/stop";
using var client = new HttpClient();
await client.PostAsync($"{url}/stop", null);
await client.PostAsync(stopUrl, null);
}
catch { /* Best effort */ }
}
Expand Down
4 changes: 3 additions & 1 deletion dotnet/test/Harness/E2ETestContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,9 @@ public IReadOnlyDictionary<string, string> GetEnvironment()

public async ValueTask DisposeAsync()
{
await _proxy.DisposeAsync();
// Skip writing snapshots in CI to avoid corrupting them on test failures
var isCI = !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CI"));
await _proxy.StopAsync(skipWritingCache: isCI);

try { if (Directory.Exists(HomeDir)) Directory.Delete(HomeDir, true); } catch { }
try { if (Directory.Exists(WorkDir)) Directory.Delete(WorkDir, true); } catch { }
Expand Down
14 changes: 4 additions & 10 deletions dotnet/test/McpAndAgentsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,7 @@ public async Task Should_Accept_MCP_Server_Configuration_On_Session_Resume()
// Create a session first
var session1 = await Client.CreateSessionAsync();
var sessionId = session1.SessionId;
await session1.SendAsync(new MessageOptions { Prompt = "What is 1+1?" });
await TestHelper.GetFinalAssistantMessageAsync(session1);
await session1.SendAndWaitAsync(new MessageOptions { Prompt = "What is 1+1?" });

// Resume with MCP servers
var mcpServers = new Dictionary<string, object>
Expand All @@ -69,9 +68,7 @@ public async Task Should_Accept_MCP_Server_Configuration_On_Session_Resume()

Assert.Equal(sessionId, session2.SessionId);

await session2.SendAsync(new MessageOptions { Prompt = "What is 3+3?" });

var message = await TestHelper.GetFinalAssistantMessageAsync(session2);
var message = await session2.SendAndWaitAsync(new MessageOptions { Prompt = "What is 3+3?" });
Assert.NotNull(message);
Assert.Contains("6", message!.Data.Content);

Expand Down Expand Up @@ -146,8 +143,7 @@ public async Task Should_Accept_Custom_Agent_Configuration_On_Session_Resume()
// Create a session first
var session1 = await Client.CreateSessionAsync();
var sessionId = session1.SessionId;
await session1.SendAsync(new MessageOptions { Prompt = "What is 1+1?" });
await TestHelper.GetFinalAssistantMessageAsync(session1);
await session1.SendAndWaitAsync(new MessageOptions { Prompt = "What is 1+1?" });

// Resume with custom agents
var customAgents = new List<CustomAgentConfig>
Expand All @@ -168,9 +164,7 @@ public async Task Should_Accept_Custom_Agent_Configuration_On_Session_Resume()

Assert.Equal(sessionId, session2.SessionId);

await session2.SendAsync(new MessageOptions { Prompt = "What is 6+6?" });

var message = await TestHelper.GetFinalAssistantMessageAsync(session2);
var message = await session2.SendAndWaitAsync(new MessageOptions { Prompt = "What is 6+6?" });
Assert.NotNull(message);
Assert.Contains("12", message!.Data.Content);

Expand Down
7 changes: 2 additions & 5 deletions dotnet/test/PermissionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -118,8 +118,7 @@ public async Task Should_Resume_Session_With_Permission_Handler()
// Create session without permission handler
var session1 = await Client.CreateSessionAsync();
var sessionId = session1.SessionId;
await session1.SendAsync(new MessageOptions { Prompt = "What is 1+1?" });
await TestHelper.GetFinalAssistantMessageAsync(session1);
await session1.SendAndWaitAsync(new MessageOptions { Prompt = "What is 1+1?" });

// Resume with permission handler
var session2 = await Client.ResumeSessionAsync(sessionId, new ResumeSessionConfig
Expand All @@ -131,13 +130,11 @@ public async Task Should_Resume_Session_With_Permission_Handler()
}
});

await session2.SendAsync(new MessageOptions
await session2.SendAndWaitAsync(new MessageOptions
{
Prompt = "Run 'echo resumed' for me"
});

await TestHelper.GetFinalAssistantMessageAsync(session2);

Assert.True(permissionRequestReceived, "Permission request should have been received");
}

Expand Down
55 changes: 51 additions & 4 deletions dotnet/test/SessionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,11 @@ public async Task Should_Have_Stateful_Conversation()
{
var session = await Client.CreateSessionAsync();

await session.SendAsync(new MessageOptions { Prompt = "What is 1+1?" });
var assistantMessage = await TestHelper.GetFinalAssistantMessageAsync(session);
var assistantMessage = await session.SendAndWaitAsync(new MessageOptions { Prompt = "What is 1+1?" });
Assert.NotNull(assistantMessage);
Assert.Contains("2", assistantMessage!.Data.Content);

await session.SendAsync(new MessageOptions { Prompt = "Now if you double that, what do you get?" });
var secondMessage = await TestHelper.GetFinalAssistantMessageAsync(session);
var secondMessage = await session.SendAndWaitAsync(new MessageOptions { Prompt = "Now if you double that, what do you get?" });
Assert.NotNull(secondMessage);
Assert.Contains("4", secondMessage!.Data.Content);
}
Expand Down Expand Up @@ -322,4 +320,53 @@ public async Task Should_Receive_Session_Events()

await session.DisposeAsync();
}

[Fact]
public async Task Send_Returns_Immediately_While_Events_Stream_In_Background()
{
var session = await Client.CreateSessionAsync();
var events = new List<string>();

session.On(evt => events.Add(evt.Type));

await session.SendAsync(new MessageOptions { Prompt = "What is 1+1?" });

// send() should return before turn completes (no session.idle yet)
Assert.DoesNotContain("session.idle", events);

// Wait for turn to complete
var message = await TestHelper.GetFinalAssistantMessageAsync(session);

Assert.Contains("2", message?.Data.Content ?? string.Empty);
Assert.Contains("session.idle", events);
Assert.Contains("assistant.message", events);
}

[Fact]
public async Task SendAndWait_Blocks_Until_Session_Idle_And_Returns_Final_Assistant_Message()
{
var session = await Client.CreateSessionAsync();
var events = new List<string>();

session.On(evt => events.Add(evt.Type));

var response = await session.SendAndWaitAsync(new MessageOptions { Prompt = "What is 2+2?" });

Assert.NotNull(response);
Assert.Equal("assistant.message", response!.Type);
Assert.Contains("4", response.Data.Content ?? string.Empty);
Assert.Contains("session.idle", events);
Assert.Contains("assistant.message", events);
}

[Fact]
public async Task SendAndWait_Throws_On_Timeout()
{
var session = await Client.CreateSessionAsync();

var ex = await Assert.ThrowsAsync<TimeoutException>(() =>
session.SendAndWaitAsync(new MessageOptions { Prompt = "What is 3+3?" }, TimeSpan.FromMilliseconds(1)));

Assert.Contains("timed out", ex.Message);
}
}
26 changes: 4 additions & 22 deletions go/e2e/mcp_and_agents_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,14 +67,10 @@ func TestMCPServers(t *testing.T) {
}
sessionID := session1.SessionID

_, err = session1.Send(copilot.MessageOptions{Prompt: "What is 1+1?"})
_, err = session1.SendAndWait(copilot.MessageOptions{Prompt: "What is 1+1?"}, 60*time.Second)
if err != nil {
t.Fatalf("Failed to send message: %v", err)
}
_, err = testharness.GetFinalAssistantMessage(session1, 60*time.Second)
if err != nil {
t.Fatalf("Failed to get final message: %v", err)
}

// Resume with MCP servers
mcpServers := map[string]copilot.MCPServerConfig{
Expand All @@ -97,16 +93,11 @@ func TestMCPServers(t *testing.T) {
t.Errorf("Expected session ID %s, got %s", sessionID, session2.SessionID)
}

_, err = session2.Send(copilot.MessageOptions{Prompt: "What is 3+3?"})
message, err := session2.SendAndWait(copilot.MessageOptions{Prompt: "What is 3+3?"}, 60*time.Second)
if err != nil {
t.Fatalf("Failed to send message: %v", err)
}

message, err := testharness.GetFinalAssistantMessage(session2, 60*time.Second)
if err != nil {
t.Fatalf("Failed to get final message: %v", err)
}

if message.Data.Content == nil || !strings.Contains(*message.Data.Content, "6") {
t.Errorf("Expected message to contain '6', got: %v", message.Data.Content)
}
Expand Down Expand Up @@ -207,14 +198,10 @@ func TestCustomAgents(t *testing.T) {
}
sessionID := session1.SessionID

_, err = session1.Send(copilot.MessageOptions{Prompt: "What is 1+1?"})
_, err = session1.SendAndWait(copilot.MessageOptions{Prompt: "What is 1+1?"}, 60*time.Second)
if err != nil {
t.Fatalf("Failed to send message: %v", err)
}
_, err = testharness.GetFinalAssistantMessage(session1, 60*time.Second)
if err != nil {
t.Fatalf("Failed to get final message: %v", err)
}

// Resume with custom agents
customAgents := []copilot.CustomAgentConfig{
Expand All @@ -237,16 +224,11 @@ func TestCustomAgents(t *testing.T) {
t.Errorf("Expected session ID %s, got %s", sessionID, session2.SessionID)
}

_, err = session2.Send(copilot.MessageOptions{Prompt: "What is 6+6?"})
message, err := session2.SendAndWait(copilot.MessageOptions{Prompt: "What is 6+6?"}, 60*time.Second)
if err != nil {
t.Fatalf("Failed to send message: %v", err)
}

message, err := testharness.GetFinalAssistantMessage(session2, 60*time.Second)
if err != nil {
t.Fatalf("Failed to get final message: %v", err)
}

if message.Data.Content == nil || !strings.Contains(*message.Data.Content, "12") {
t.Errorf("Expected message to contain '12', got: %v", message.Data.Content)
}
Expand Down
Loading
Loading