Skip to content

Commit eec7f11

Browse files
Update CLI to 0.0.383
1 parent 1e23513 commit eec7f11

25 files changed

+545
-252
lines changed

dotnet/src/Client.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -766,7 +766,10 @@ public void OnSessionEvent(string sessionId,
766766
if (session != null && @event != null)
767767
{
768768
var evt = SessionEvent.FromJson(@event.Value.GetRawText());
769-
session.DispatchEvent(evt);
769+
if (evt != null)
770+
{
771+
session.DispatchEvent(evt);
772+
}
770773
}
771774
}
772775

dotnet/src/Generated/SessionEvents.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ internal class SessionEventConverter : JsonConverter<SessionEvent>
7878
throw new JsonException("Missing 'type' discriminator property");
7979

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

8383
// Deserialize to the concrete type without using this converter (to avoid recursion)
8484
return (SessionEvent?)obj.Deserialize(targetType, SerializerOptions.WithoutConverter);

dotnet/src/Session.cs

Lines changed: 77 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,8 @@ namespace GitHub.Copilot.SDK;
3636
/// }
3737
/// });
3838
///
39-
/// // Send a message
40-
/// await session.SendAsync(new MessageOptions { Prompt = "Hello, world!" });
39+
/// // Send a message and wait for completion
40+
/// await session.SendAndWaitAsync(new MessageOptions { Prompt = "Hello, world!" });
4141
/// </code>
4242
/// </example>
4343
public class CopilotSession : IAsyncDisposable
@@ -76,8 +76,13 @@ internal CopilotSession(string sessionId, JsonRpc rpc)
7676
/// <returns>A task that resolves with the ID of the response message, which can be used to correlate events.</returns>
7777
/// <exception cref="InvalidOperationException">Thrown if the session has been disposed.</exception>
7878
/// <remarks>
79-
/// The message is processed asynchronously. Subscribe to events via <see cref="On"/> to receive
80-
/// streaming responses and other session events.
79+
/// <para>
80+
/// This method returns immediately after the message is queued. Use <see cref="SendAndWaitAsync"/>
81+
/// if you need to wait for the assistant to finish processing.
82+
/// </para>
83+
/// <para>
84+
/// Subscribe to events via <see cref="On"/> to receive streaming responses and other session events.
85+
/// </para>
8186
/// </remarks>
8287
/// <example>
8388
/// <code>
@@ -107,6 +112,70 @@ public async Task<string> SendAsync(MessageOptions options, CancellationToken ca
107112
return response.MessageId;
108113
}
109114

115+
/// <summary>
116+
/// Sends a message to the Copilot session and waits until the session becomes idle.
117+
/// </summary>
118+
/// <param name="options">Options for the message to be sent, including the prompt and optional attachments.</param>
119+
/// <param name="timeout">Timeout duration (default: 60 seconds). Controls how long to wait; does not abort in-flight agent work.</param>
120+
/// <param name="cancellationToken">A <see cref="CancellationToken"/> that can be used to cancel the operation.</param>
121+
/// <returns>A task that resolves with the final assistant message event, or null if none was received.</returns>
122+
/// <exception cref="TimeoutException">Thrown if the timeout is reached before the session becomes idle.</exception>
123+
/// <exception cref="InvalidOperationException">Thrown if the session has been disposed.</exception>
124+
/// <remarks>
125+
/// <para>
126+
/// This is a convenience method that combines <see cref="SendAsync"/> with waiting for
127+
/// the <c>session.idle</c> event. Use this when you want to block until the assistant
128+
/// has finished processing the message.
129+
/// </para>
130+
/// <para>
131+
/// Events are still delivered to handlers registered via <see cref="On"/> while waiting.
132+
/// </para>
133+
/// </remarks>
134+
/// <example>
135+
/// <code>
136+
/// // Send and wait for completion with default 60s timeout
137+
/// var response = await session.SendAndWaitAsync(new MessageOptions { Prompt = "What is 2+2?" });
138+
/// Console.WriteLine(response?.Data?.Content); // "4"
139+
/// </code>
140+
/// </example>
141+
public async Task<AssistantMessageEvent?> SendAndWaitAsync(
142+
MessageOptions options,
143+
TimeSpan? timeout = null,
144+
CancellationToken cancellationToken = default)
145+
{
146+
var effectiveTimeout = timeout ?? TimeSpan.FromSeconds(60);
147+
var tcs = new TaskCompletionSource<AssistantMessageEvent?>();
148+
AssistantMessageEvent? lastAssistantMessage = null;
149+
150+
void Handler(SessionEvent evt)
151+
{
152+
if (evt is AssistantMessageEvent assistantMessage)
153+
{
154+
lastAssistantMessage = assistantMessage;
155+
}
156+
else if (evt.Type == "session.idle")
157+
{
158+
tcs.TrySetResult(lastAssistantMessage);
159+
}
160+
else if (evt is SessionErrorEvent errorEvent)
161+
{
162+
var message = errorEvent.Data?.Message ?? "session error";
163+
tcs.TrySetException(new InvalidOperationException($"Session error: {message}"));
164+
}
165+
}
166+
167+
using var subscription = On(Handler);
168+
169+
await SendAsync(options, cancellationToken);
170+
171+
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
172+
cts.CancelAfter(effectiveTimeout);
173+
174+
using var registration = cts.Token.Register(() =>
175+
tcs.TrySetException(new TimeoutException($"SendAndWaitAsync timed out after {effectiveTimeout}")));
176+
return await tcs.Task;
177+
}
178+
110179
/// <summary>
111180
/// Registers a callback for session events.
112181
/// </summary>
@@ -271,7 +340,10 @@ public async Task<IReadOnlyList<SessionEvent>> GetMessagesAsync(CancellationToke
271340
var response = await _rpc.InvokeWithCancellationAsync<GetMessagesResponse>(
272341
"session.getMessages", [new { sessionId = SessionId }], cancellationToken);
273342

274-
return response.Events.Select(e => SessionEvent.FromJson(e.ToJsonString())).ToList();
343+
return response.Events
344+
.Select(e => SessionEvent.FromJson(e.ToJsonString()))
345+
.Where(e => e != null)
346+
.ToList()!;
275347
}
276348

277349
/// <summary>

dotnet/test/McpAndAgentsTests.cs

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,7 @@ public async Task Should_Accept_MCP_Server_Configuration_On_Session_Resume()
4747
// Create a session first
4848
var session1 = await Client.CreateSessionAsync();
4949
var sessionId = session1.SessionId;
50-
await session1.SendAsync(new MessageOptions { Prompt = "What is 1+1?" });
51-
await TestHelper.GetFinalAssistantMessageAsync(session1);
50+
await session1.SendAndWaitAsync(new MessageOptions { Prompt = "What is 1+1?" });
5251

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

7069
Assert.Equal(sessionId, session2.SessionId);
7170

72-
await session2.SendAsync(new MessageOptions { Prompt = "What is 3+3?" });
73-
74-
var message = await TestHelper.GetFinalAssistantMessageAsync(session2);
71+
var message = await session2.SendAndWaitAsync(new MessageOptions { Prompt = "What is 3+3?" });
7572
Assert.NotNull(message);
7673
Assert.Contains("6", message!.Data.Content);
7774

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

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

169165
Assert.Equal(sessionId, session2.SessionId);
170166

171-
await session2.SendAsync(new MessageOptions { Prompt = "What is 6+6?" });
172-
173-
var message = await TestHelper.GetFinalAssistantMessageAsync(session2);
167+
var message = await session2.SendAndWaitAsync(new MessageOptions { Prompt = "What is 6+6?" });
174168
Assert.NotNull(message);
175169
Assert.Contains("12", message!.Data.Content);
176170

dotnet/test/PermissionTests.cs

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -118,8 +118,7 @@ public async Task Should_Resume_Session_With_Permission_Handler()
118118
// Create session without permission handler
119119
var session1 = await Client.CreateSessionAsync();
120120
var sessionId = session1.SessionId;
121-
await session1.SendAsync(new MessageOptions { Prompt = "What is 1+1?" });
122-
await TestHelper.GetFinalAssistantMessageAsync(session1);
121+
await session1.SendAndWaitAsync(new MessageOptions { Prompt = "What is 1+1?" });
123122

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

134-
await session2.SendAsync(new MessageOptions
133+
await session2.SendAndWaitAsync(new MessageOptions
135134
{
136135
Prompt = "Run 'echo resumed' for me"
137136
});
138137

139-
await TestHelper.GetFinalAssistantMessageAsync(session2);
140-
141138
Assert.True(permissionRequestReceived, "Permission request should have been received");
142139
}
143140

dotnet/test/SessionTests.cs

Lines changed: 51 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,13 +35,11 @@ public async Task Should_Have_Stateful_Conversation()
3535
{
3636
var session = await Client.CreateSessionAsync();
3737

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

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

323321
await session.DisposeAsync();
324322
}
323+
324+
[Fact]
325+
public async Task Send_Returns_Immediately_While_Events_Stream_In_Background()
326+
{
327+
var session = await Client.CreateSessionAsync();
328+
var events = new List<string>();
329+
330+
session.On(evt => events.Add(evt.Type));
331+
332+
await session.SendAsync(new MessageOptions { Prompt = "What is 1+1?" });
333+
334+
// send() should return before turn completes (no session.idle yet)
335+
Assert.DoesNotContain("session.idle", events);
336+
337+
// Wait for turn to complete
338+
var message = await TestHelper.GetFinalAssistantMessageAsync(session);
339+
340+
Assert.Contains("2", message?.Data.Content ?? string.Empty);
341+
Assert.Contains("session.idle", events);
342+
Assert.Contains("assistant.message", events);
343+
}
344+
345+
[Fact]
346+
public async Task SendAndWait_Blocks_Until_Session_Idle_And_Returns_Final_Assistant_Message()
347+
{
348+
var session = await Client.CreateSessionAsync();
349+
var events = new List<string>();
350+
351+
session.On(evt => events.Add(evt.Type));
352+
353+
var response = await session.SendAndWaitAsync(new MessageOptions { Prompt = "What is 2+2?" });
354+
355+
Assert.NotNull(response);
356+
Assert.Equal("assistant.message", response!.Type);
357+
Assert.Contains("4", response.Data.Content ?? string.Empty);
358+
Assert.Contains("session.idle", events);
359+
Assert.Contains("assistant.message", events);
360+
}
361+
362+
[Fact]
363+
public async Task SendAndWait_Throws_On_Timeout()
364+
{
365+
var session = await Client.CreateSessionAsync();
366+
367+
var ex = await Assert.ThrowsAsync<TimeoutException>(() =>
368+
session.SendAndWaitAsync(new MessageOptions { Prompt = "What is 3+3?" }, TimeSpan.FromMilliseconds(1)));
369+
370+
Assert.Contains("timed out", ex.Message);
371+
}
325372
}

go/e2e/mcp_and_agents_test.go

Lines changed: 4 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -67,14 +67,10 @@ func TestMCPServers(t *testing.T) {
6767
}
6868
sessionID := session1.SessionID
6969

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

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

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

105-
message, err := testharness.GetFinalAssistantMessage(session2, 60*time.Second)
106-
if err != nil {
107-
t.Fatalf("Failed to get final message: %v", err)
108-
}
109-
110101
if message.Data.Content == nil || !strings.Contains(*message.Data.Content, "6") {
111102
t.Errorf("Expected message to contain '6', got: %v", message.Data.Content)
112103
}
@@ -207,14 +198,10 @@ func TestCustomAgents(t *testing.T) {
207198
}
208199
sessionID := session1.SessionID
209200

210-
_, err = session1.Send(copilot.MessageOptions{Prompt: "What is 1+1?"})
201+
_, err = session1.SendAndWait(copilot.MessageOptions{Prompt: "What is 1+1?"}, 60*time.Second)
211202
if err != nil {
212203
t.Fatalf("Failed to send message: %v", err)
213204
}
214-
_, err = testharness.GetFinalAssistantMessage(session1, 60*time.Second)
215-
if err != nil {
216-
t.Fatalf("Failed to get final message: %v", err)
217-
}
218205

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

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

245-
message, err := testharness.GetFinalAssistantMessage(session2, 60*time.Second)
246-
if err != nil {
247-
t.Fatalf("Failed to get final message: %v", err)
248-
}
249-
250232
if message.Data.Content == nil || !strings.Contains(*message.Data.Content, "12") {
251233
t.Errorf("Expected message to contain '12', got: %v", message.Data.Content)
252234
}

go/e2e/permissions_test.go

Lines changed: 5 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -48,18 +48,13 @@ func TestPermissions(t *testing.T) {
4848
t.Fatalf("Failed to write test file: %v", err)
4949
}
5050

51-
_, err = session.Send(copilot.MessageOptions{
51+
_, err = session.SendAndWait(copilot.MessageOptions{
5252
Prompt: "Edit test.txt and replace 'original' with 'modified'",
53-
})
53+
}, 60*time.Second)
5454
if err != nil {
5555
t.Fatalf("Failed to send message: %v", err)
5656
}
5757

58-
_, err = testharness.GetFinalAssistantMessage(session, 60*time.Second)
59-
if err != nil {
60-
t.Fatalf("Failed to get final message: %v", err)
61-
}
62-
6358
mu.Lock()
6459
if len(permissionRequests) == 0 {
6560
t.Error("Expected at least one permission request")
@@ -98,18 +93,13 @@ func TestPermissions(t *testing.T) {
9893
t.Fatalf("Failed to create session: %v", err)
9994
}
10095

101-
_, err = session.Send(copilot.MessageOptions{
102-
Prompt: "Run 'echo hello' and tell me the output",
103-
})
96+
_, err = session.SendAndWait(copilot.MessageOptions{
97+
Prompt: "Run 'echo hello world' and tell me the output",
98+
}, 60*time.Second)
10499
if err != nil {
105100
t.Fatalf("Failed to send message: %v", err)
106101
}
107102

108-
_, err = testharness.GetFinalAssistantMessage(session, 60*time.Second)
109-
if err != nil {
110-
t.Fatalf("Failed to get final message: %v", err)
111-
}
112-
113103
mu.Lock()
114104
shellCount := 0
115105
for _, req := range permissionRequests {

0 commit comments

Comments
 (0)