Skip to content

Commit cd198b5

Browse files
Add sendAndWait API for all SDKs (#28)
* Add sendAndWait API for non-blocking send with completion waiting * Update CLI to 0.0.384 and regenerate snapshot * Add sendAndWait for all languages * Regenerate async permission handler snapshot * Add PowerShell warm-up step for Windows CI runs
1 parent 1e23513 commit cd198b5

29 files changed

+665
-283
lines changed

.github/workflows/sdk-e2e-tests.yml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,10 @@ jobs:
5151
working-directory: ./test/harness
5252
run: npm ci --ignore-scripts
5353

54+
- name: Warm up PowerShell
55+
if: runner.os == 'Windows'
56+
run: pwsh.exe -Command "Write-Host 'PowerShell ready'"
57+
5458
- name: Run Node.js SDK tests
5559
env:
5660
COPILOT_HMAC_KEY: ${{ secrets.COPILOT_DEVELOPER_CLI_INTEGRATION_HMAC_KEY }}
@@ -99,6 +103,10 @@ jobs:
99103
working-directory: ./test/harness
100104
run: npm ci --ignore-scripts
101105

106+
- name: Warm up PowerShell
107+
if: runner.os == 'Windows'
108+
run: pwsh.exe -Command "Write-Host 'PowerShell ready'"
109+
102110
- name: Run Go SDK tests
103111
env:
104112
COPILOT_HMAC_KEY: ${{ secrets.COPILOT_DEVELOPER_CLI_INTEGRATION_HMAC_KEY }}
@@ -144,6 +152,10 @@ jobs:
144152
working-directory: ./test/harness
145153
run: npm ci --ignore-scripts
146154

155+
- name: Warm up PowerShell
156+
if: runner.os == 'Windows'
157+
run: pwsh.exe -Command "Write-Host 'PowerShell ready'"
158+
147159
- name: Run Python SDK tests
148160
env:
149161
COPILOT_HMAC_KEY: ${{ secrets.COPILOT_DEVELOPER_CLI_INTEGRATION_HMAC_KEY }}
@@ -196,6 +208,10 @@ jobs:
196208
working-directory: ./test/harness
197209
run: npm ci --ignore-scripts
198210

211+
- name: Warm up PowerShell
212+
if: runner.os == 'Windows'
213+
run: pwsh.exe -Command "Write-Host 'PowerShell ready'"
214+
199215
- name: Run .NET SDK tests
200216
env:
201217
COPILOT_HMAC_KEY: ${{ secrets.COPILOT_DEVELOPER_CLI_INTEGRATION_HMAC_KEY }}

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+
.OfType<SessionEvent>()
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: 53 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,55 @@ 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+
// Use a slow command so we can verify SendAsync() returns before completion
333+
await session.SendAsync(new MessageOptions { Prompt = "Run 'sleep 2 && echo done'" });
334+
335+
// SendAsync() should return before turn completes (no session.idle yet)
336+
Assert.DoesNotContain("session.idle", events);
337+
338+
// Wait for turn to complete
339+
var message = await TestHelper.GetFinalAssistantMessageAsync(session);
340+
341+
Assert.Contains("done", message?.Data.Content ?? string.Empty);
342+
Assert.Contains("session.idle", events);
343+
Assert.Contains("assistant.message", events);
344+
}
345+
346+
[Fact]
347+
public async Task SendAndWait_Blocks_Until_Session_Idle_And_Returns_Final_Assistant_Message()
348+
{
349+
var session = await Client.CreateSessionAsync();
350+
var events = new List<string>();
351+
352+
session.On(evt => events.Add(evt.Type));
353+
354+
var response = await session.SendAndWaitAsync(new MessageOptions { Prompt = "What is 2+2?" });
355+
356+
Assert.NotNull(response);
357+
Assert.Equal("assistant.message", response!.Type);
358+
Assert.Contains("4", response.Data.Content ?? string.Empty);
359+
Assert.Contains("session.idle", events);
360+
Assert.Contains("assistant.message", events);
361+
}
362+
363+
[Fact]
364+
public async Task SendAndWait_Throws_On_Timeout()
365+
{
366+
var session = await Client.CreateSessionAsync();
367+
368+
// Use a slow command to ensure timeout triggers before completion
369+
var ex = await Assert.ThrowsAsync<TimeoutException>(() =>
370+
session.SendAndWaitAsync(new MessageOptions { Prompt = "Run 'sleep 2 && echo done'" }, TimeSpan.FromMilliseconds(100)));
371+
372+
Assert.Contains("timed out", ex.Message);
373+
}
325374
}

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
}

0 commit comments

Comments
 (0)