Skip to content
Merged
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
16 changes: 16 additions & 0 deletions .github/workflows/sdk-e2e-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@ jobs:
working-directory: ./test/harness
run: npm ci --ignore-scripts

- name: Warm up PowerShell
if: runner.os == 'Windows'
run: pwsh.exe -Command "Write-Host 'PowerShell ready'"

- name: Run Node.js SDK tests
env:
COPILOT_HMAC_KEY: ${{ secrets.COPILOT_DEVELOPER_CLI_INTEGRATION_HMAC_KEY }}
Expand Down Expand Up @@ -99,6 +103,10 @@ jobs:
working-directory: ./test/harness
run: npm ci --ignore-scripts

- name: Warm up PowerShell
if: runner.os == 'Windows'
run: pwsh.exe -Command "Write-Host 'PowerShell ready'"

- name: Run Go SDK tests
env:
COPILOT_HMAC_KEY: ${{ secrets.COPILOT_DEVELOPER_CLI_INTEGRATION_HMAC_KEY }}
Expand Down Expand Up @@ -144,6 +152,10 @@ jobs:
working-directory: ./test/harness
run: npm ci --ignore-scripts

- name: Warm up PowerShell
if: runner.os == 'Windows'
run: pwsh.exe -Command "Write-Host 'PowerShell ready'"

- name: Run Python SDK tests
env:
COPILOT_HMAC_KEY: ${{ secrets.COPILOT_DEVELOPER_CLI_INTEGRATION_HMAC_KEY }}
Expand Down Expand Up @@ -196,6 +208,10 @@ jobs:
working-directory: ./test/harness
run: npm ci --ignore-scripts

- name: Warm up PowerShell
if: runner.os == 'Windows'
run: pwsh.exe -Command "Write-Host 'PowerShell ready'"

- name: Run .NET SDK tests
env:
COPILOT_HMAC_KEY: ${{ secrets.COPILOT_DEVELOPER_CLI_INTEGRATION_HMAC_KEY }}
Expand Down
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 @@ -78,7 +78,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()))
.OfType<SessionEvent>()
.ToList();
}

/// <summary>
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
57 changes: 53 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,55 @@ 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));

// Use a slow command so we can verify SendAsync() returns before completion
await session.SendAsync(new MessageOptions { Prompt = "Run 'sleep 2 && echo done'" });

// SendAsync() 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("done", 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();

// Use a slow command to ensure timeout triggers before completion
var ex = await Assert.ThrowsAsync<TimeoutException>(() =>
session.SendAndWaitAsync(new MessageOptions { Prompt = "Run 'sleep 2 && echo done'" }, TimeSpan.FromMilliseconds(100)));

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