Skip to content

Commit bcae272

Browse files
Add .net support. Clean up TypeScript implementation
1 parent c761260 commit bcae272

File tree

4 files changed

+126
-5
lines changed

4 files changed

+126
-5
lines changed

dotnet/src/Session.cs

Lines changed: 68 additions & 4 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,65 @@ 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+
}
161+
162+
using var subscription = On(Handler);
163+
164+
await SendAsync(options, cancellationToken);
165+
166+
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
167+
cts.CancelAfter(effectiveTimeout);
168+
169+
using var registration = cts.Token.Register(() =>
170+
tcs.TrySetException(new TimeoutException($"SendAndWaitAsync timed out after {effectiveTimeout}")));
171+
return await tcs.Task;
172+
}
173+
110174
/// <summary>
111175
/// Registers a callback for session events.
112176
/// </summary>

dotnet/test/SessionTests.cs

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,4 +322,53 @@ public async Task Should_Receive_Session_Events()
322322

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

nodejs/src/session.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ export class CopilotSession {
100100
* Events are still delivered to handlers registered via {@link on} while waiting.
101101
*
102102
* @param options - The message options including the prompt and optional attachments
103-
* @param timeout - Timeout in milliseconds (default: 60000)
103+
* @param timeout - Timeout in milliseconds (default: 60000). Controls how long to wait; does not abort in-flight agent work.
104104
* @returns A promise that resolves with the final assistant message when the session becomes idle,
105105
* or undefined if no assistant message was received
106106
* @throws Error if the timeout is reached before the session becomes idle

nodejs/test/e2e/session.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -384,4 +384,12 @@ describe("Send Blocking Behavior", async () => {
384384
expect(events).toContain("session.idle");
385385
expect(events).toContain("assistant.message");
386386
});
387+
388+
it("sendAndWait throws on timeout", async () => {
389+
const session = await client.createSession();
390+
391+
await expect(session.sendAndWait({ prompt: "What is 3+3?" }, 1)).rejects.toThrow(
392+
/Timeout after 1ms/
393+
);
394+
});
387395
});

0 commit comments

Comments
 (0)