Skip to content

Commit 563c216

Browse files
PureWeenCopilot
andcommitted
test: add backgroundTasks regression tests and update sendAndWait docs
- Add timeout test: sendAndWait times out when backgroundTasks never clears (all 3 review models flagged this gap; test uses 300ms timeout for speed) - Add Go backgroundTasks unit tests (TestSendAndWait_BackgroundTasks, 4 subtests) using io.Pipe fake jsonrpc2 client - Add .NET SendAndWait_Resolves_After_Clean_SessionIdle test in SessionTests.cs - Add Python async backgroundTasks tests in test_session_background_tasks.py - Add YAML snapshot for .NET clean-idle test - Update sendAndWait docs in all 4 SDKs: clarify that it waits for ALL background tasks to complete (not just first session.idle), document timeout behavior for stuck background agents Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent de24ea1 commit 563c216

File tree

9 files changed

+563
-16
lines changed

9 files changed

+563
-16
lines changed

dotnet/src/Session.cs

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -173,20 +173,26 @@ public async Task<string> SendAsync(MessageOptions options, CancellationToken ca
173173
}
174174

175175
/// <summary>
176-
/// Sends a message to the Copilot session and waits until the session becomes idle.
176+
/// Sends a message to the Copilot session and waits until the session is fully idle.
177177
/// </summary>
178178
/// <param name="options">Options for the message to be sent, including the prompt and optional attachments.</param>
179179
/// <param name="timeout">Timeout duration (default: 60 seconds). Controls how long to wait; does not abort in-flight agent work.</param>
180180
/// <param name="cancellationToken">A <see cref="CancellationToken"/> that can be used to cancel the operation.</param>
181181
/// <returns>A task that resolves with the final assistant message event, or null if none was received.</returns>
182-
/// <exception cref="TimeoutException">Thrown if the timeout is reached before the session becomes idle.</exception>
182+
/// <exception cref="TimeoutException">Thrown if the timeout is reached before the session becomes fully idle.</exception>
183183
/// <exception cref="OperationCanceledException">Thrown if the <paramref name="cancellationToken"/> is cancelled.</exception>
184184
/// <exception cref="InvalidOperationException">Thrown if the session has been disposed.</exception>
185185
/// <remarks>
186186
/// <para>
187187
/// This is a convenience method that combines <see cref="SendAsync"/> with waiting for
188188
/// the <c>session.idle</c> event. Use this when you want to block until the assistant
189-
/// has finished processing the message.
189+
/// has finished processing the message and all background tasks have completed.
190+
/// </para>
191+
/// <para>
192+
/// <b>Background tasks:</b> When the CLI emits <c>session.idle</c> with a non-empty
193+
/// <c>backgroundTasks</c> field, it signals that background agents or shells are still
194+
/// running. <see cref="SendAndWaitAsync"/> will continue waiting until a <c>session.idle</c>
195+
/// arrives with no active background tasks, or until the timeout fires.
190196
/// </para>
191197
/// <para>
192198
/// Events are still delivered to handlers registered via <see cref="On"/> while waiting.

dotnet/test/SessionTests.cs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -427,6 +427,40 @@ public async Task Should_Get_Session_Metadata_By_Id()
427427
Assert.Null(notFound);
428428
}
429429

430+
// ─────────────────────────────────────────────────────────────────────────
431+
// BackgroundTasks regression tests for PolyPilot#299.
432+
//
433+
// The fix ensures SendAndWaitAsync only resolves when session.idle arrives
434+
// with no active backgroundTasks (agents[] and shells[] both empty/absent).
435+
//
436+
// Note: The "does NOT resolve with active backgroundTasks" case cannot be
437+
// tested through public APIs in .NET because DispatchEvent is internal and
438+
// InternalsVisibleTo is not permitted per project conventions. The
439+
// equivalent unit tests (including the failing-before-fix repro) live in
440+
// nodejs/test/client.test.ts under "sendAndWait backgroundTasks", and the
441+
// Go unit tests in go/session_test.go TestSendAndWait_BackgroundTasks both
442+
// cover the same fix logic.
443+
// ─────────────────────────────────────────────────────────────────────────
444+
445+
/// <summary>
446+
/// Proves that SendAndWaitAsync resolves when session.idle has no
447+
/// backgroundTasks (the normal, clean-idle case after our fix).
448+
/// </summary>
449+
[Fact]
450+
public async Task SendAndWait_Resolves_After_Clean_SessionIdle()
451+
{
452+
var session = await CreateSessionAsync();
453+
454+
// A simple prompt that completes quickly — the CLI will emit
455+
// session.idle with no backgroundTasks once the turn ends.
456+
var result = await session.SendAndWaitAsync(
457+
new MessageOptions { Prompt = "What is 1+1?" },
458+
TimeSpan.FromSeconds(30));
459+
460+
Assert.NotNull(result);
461+
Assert.Contains("2", result!.Data.Content);
462+
}
463+
430464
[Fact]
431465
public async Task SendAndWait_Throws_On_Timeout()
432466
{

go/session.go

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -143,18 +143,25 @@ func (s *Session) Send(ctx context.Context, options MessageOptions) (string, err
143143
return response.MessageID, nil
144144
}
145145

146-
// SendAndWait sends a message to this session and waits until the session becomes idle.
146+
// SendAndWait sends a message to this session and waits until the session is fully idle.
147147
//
148148
// This is a convenience method that combines [Session.Send] with waiting for
149149
// the session.idle event. Use this when you want to block until the assistant
150-
// has finished processing the message.
150+
// has finished processing the message and all background tasks (background
151+
// agents and shell commands) have completed.
152+
//
153+
// Background tasks: when the CLI emits session.idle with a non-empty
154+
// BackgroundTasks field it signals that background agents or shells are still
155+
// running. SendAndWait continues waiting until a session.idle arrives with no
156+
// active background tasks, or until the context deadline fires.
151157
//
152158
// Events are still delivered to handlers registered via [Session.On] while waiting.
153159
//
154160
// Parameters:
155161
// - options: The message options including the prompt and optional attachments.
156162
// - timeout: How long to wait for completion. Defaults to 60 seconds if zero.
157-
// Controls how long to wait; does not abort in-flight agent work.
163+
// Controls how long to wait; does not abort in-flight agent work. If
164+
// background tasks are stuck the context deadline will fire.
158165
//
159166
// Returns the final assistant message event, or nil if none was received.
160167
// Returns an error if the timeout is reached or the connection fails.

go/session_test.go

Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,18 @@
11
package copilot
22

33
import (
4+
"bufio"
5+
"context"
6+
"encoding/json"
7+
"fmt"
8+
"io"
9+
"strings"
410
"sync"
511
"sync/atomic"
612
"testing"
713
"time"
14+
15+
"github.com/github/copilot-sdk/go/internal/jsonrpc2"
816
)
917

1018
// newTestSession creates a session with an event channel and starts the consumer goroutine.
@@ -204,3 +212,252 @@ func TestSession_On(t *testing.T) {
204212
}
205213
})
206214
}
215+
216+
// newTestSessionWithFakeClient creates a Session backed by an in-process fake
217+
// JSON-RPC 2.0 server that immediately acknowledges any "session.send" call.
218+
// The returned requestReceived channel is signalled once per incoming request,
219+
// so tests can synchronize dispatch of follow-up events.
220+
// The cleanup function must be deferred by the caller.
221+
func newTestSessionWithFakeClient(t *testing.T) (sess *Session, requestReceived <-chan struct{}, cleanup func()) {
222+
t.Helper()
223+
224+
stdinR, stdinW := io.Pipe()
225+
stdoutR, stdoutW := io.Pipe()
226+
227+
client := jsonrpc2.NewClient(stdinW, stdoutR)
228+
client.Start()
229+
230+
reqCh := make(chan struct{}, 4)
231+
232+
go func() {
233+
reader := bufio.NewReader(stdinR)
234+
buf := make([]byte, 8192)
235+
for {
236+
var contentLength int
237+
for {
238+
line, err := reader.ReadString('\n')
239+
if err != nil {
240+
return
241+
}
242+
line = strings.TrimSpace(line)
243+
if line == "" {
244+
break
245+
}
246+
fmt.Sscanf(line, "Content-Length: %d", &contentLength)
247+
}
248+
if contentLength == 0 {
249+
continue
250+
}
251+
if contentLength > len(buf) {
252+
buf = make([]byte, contentLength)
253+
}
254+
if _, err := io.ReadFull(reader, buf[:contentLength]); err != nil {
255+
return
256+
}
257+
var req struct {
258+
ID json.RawMessage `json:"id"`
259+
}
260+
json.Unmarshal(buf[:contentLength], &req) //nolint:errcheck
261+
262+
// Signal that a request was received before sending the response,
263+
// so tests can dispatch events once Send() is in-flight.
264+
select {
265+
case reqCh <- struct{}{}:
266+
default:
267+
}
268+
269+
result := `{"messageId":"test-msg"}`
270+
body := fmt.Sprintf(`{"jsonrpc":"2.0","id":%s,"result":%s}`, req.ID, result)
271+
header := fmt.Sprintf("Content-Length: %d\r\n\r\n", len(body))
272+
stdoutW.Write([]byte(header + body)) //nolint:errcheck
273+
}
274+
}()
275+
276+
s := &Session{
277+
SessionID: "test-session",
278+
handlers: make([]sessionHandler, 0),
279+
eventCh: make(chan SessionEvent, 128),
280+
client: client,
281+
}
282+
go s.processEvents()
283+
284+
cleanupFn := func() {
285+
stdoutW.Close()
286+
stdinW.Close()
287+
client.Stop()
288+
close(s.eventCh)
289+
}
290+
291+
return s, reqCh, cleanupFn
292+
}
293+
294+
// TestSendAndWait_BackgroundTasks verifies the fix for PolyPilot#299:
295+
// SendAndWait must NOT resolve when session.idle carries active background tasks.
296+
func TestSendAndWait_BackgroundTasks(t *testing.T) {
297+
t.Run("does not resolve when session.idle has active background agents", func(t *testing.T) {
298+
session, requestReceived, cleanup := newTestSessionWithFakeClient(t)
299+
defer cleanup()
300+
301+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
302+
defer cancel()
303+
304+
var resolved atomic.Bool
305+
done := make(chan struct{})
306+
go func() {
307+
defer close(done)
308+
session.SendAndWait(ctx, MessageOptions{Prompt: "test"}) //nolint:errcheck
309+
resolved.Store(true)
310+
}()
311+
312+
// Wait for the fake server to receive the session.send request.
313+
select {
314+
case <-requestReceived:
315+
case <-time.After(2 * time.Second):
316+
t.Fatal("timeout waiting for session.send request")
317+
}
318+
319+
// Dispatch session.idle WITH active background agents — must NOT resolve.
320+
session.dispatchEvent(SessionEvent{
321+
Type: SessionEventTypeSessionIdle,
322+
Data: Data{
323+
BackgroundTasks: &BackgroundTasks{
324+
Agents: []BackgroundTasksAgent{{AgentID: "bg-1", AgentType: "worker"}},
325+
Shells: []Shell{},
326+
},
327+
},
328+
})
329+
330+
time.Sleep(100 * time.Millisecond)
331+
if resolved.Load() {
332+
t.Error("BUG #299: SendAndWait resolved prematurely while background agents were active")
333+
}
334+
335+
// Dispatch a clean idle (no background tasks) — now it SHOULD resolve.
336+
session.dispatchEvent(SessionEvent{
337+
Type: SessionEventTypeSessionIdle,
338+
Data: Data{},
339+
})
340+
341+
select {
342+
case <-done:
343+
case <-time.After(2 * time.Second):
344+
t.Fatal("SendAndWait did not resolve after clean session.idle")
345+
}
346+
if !resolved.Load() {
347+
t.Error("expected SendAndWait to resolve after clean session.idle")
348+
}
349+
})
350+
351+
t.Run("does not resolve when session.idle has active background shells", func(t *testing.T) {
352+
session, requestReceived, cleanup := newTestSessionWithFakeClient(t)
353+
defer cleanup()
354+
355+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
356+
defer cancel()
357+
358+
var resolved atomic.Bool
359+
done := make(chan struct{})
360+
go func() {
361+
defer close(done)
362+
session.SendAndWait(ctx, MessageOptions{Prompt: "test"}) //nolint:errcheck
363+
resolved.Store(true)
364+
}()
365+
366+
select {
367+
case <-requestReceived:
368+
case <-time.After(2 * time.Second):
369+
t.Fatal("timeout waiting for session.send request")
370+
}
371+
372+
// Dispatch session.idle WITH active background shell — must NOT resolve.
373+
session.dispatchEvent(SessionEvent{
374+
Type: SessionEventTypeSessionIdle,
375+
Data: Data{
376+
BackgroundTasks: &BackgroundTasks{
377+
Agents: []BackgroundTasksAgent{},
378+
Shells: []Shell{{ShellID: "sh-1"}},
379+
},
380+
},
381+
})
382+
383+
time.Sleep(100 * time.Millisecond)
384+
if resolved.Load() {
385+
t.Error("BUG #299: SendAndWait resolved prematurely while background shells were active")
386+
}
387+
388+
// Clean idle — should now resolve.
389+
session.dispatchEvent(SessionEvent{
390+
Type: SessionEventTypeSessionIdle,
391+
Data: Data{BackgroundTasks: &BackgroundTasks{Agents: []BackgroundTasksAgent{}, Shells: []Shell{}}},
392+
})
393+
394+
select {
395+
case <-done:
396+
case <-time.After(2 * time.Second):
397+
t.Fatal("SendAndWait did not resolve after clean session.idle")
398+
}
399+
})
400+
401+
t.Run("resolves when session.idle has no backgroundTasks", func(t *testing.T) {
402+
session, requestReceived, cleanup := newTestSessionWithFakeClient(t)
403+
defer cleanup()
404+
405+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
406+
defer cancel()
407+
408+
done := make(chan struct{})
409+
go func() {
410+
defer close(done)
411+
session.SendAndWait(ctx, MessageOptions{Prompt: "test"}) //nolint:errcheck
412+
}()
413+
414+
select {
415+
case <-requestReceived:
416+
case <-time.After(2 * time.Second):
417+
t.Fatal("timeout waiting for session.send request")
418+
}
419+
420+
session.dispatchEvent(SessionEvent{
421+
Type: SessionEventTypeSessionIdle,
422+
Data: Data{},
423+
})
424+
425+
select {
426+
case <-done:
427+
case <-time.After(2 * time.Second):
428+
t.Fatal("SendAndWait did not resolve when session.idle had no backgroundTasks")
429+
}
430+
})
431+
432+
t.Run("resolves when session.idle has empty backgroundTasks arrays", func(t *testing.T) {
433+
session, requestReceived, cleanup := newTestSessionWithFakeClient(t)
434+
defer cleanup()
435+
436+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
437+
defer cancel()
438+
439+
done := make(chan struct{})
440+
go func() {
441+
defer close(done)
442+
session.SendAndWait(ctx, MessageOptions{Prompt: "test"}) //nolint:errcheck
443+
}()
444+
445+
select {
446+
case <-requestReceived:
447+
case <-time.After(2 * time.Second):
448+
t.Fatal("timeout waiting for session.send request")
449+
}
450+
451+
session.dispatchEvent(SessionEvent{
452+
Type: SessionEventTypeSessionIdle,
453+
Data: Data{BackgroundTasks: &BackgroundTasks{Agents: []BackgroundTasksAgent{}, Shells: []Shell{}}},
454+
})
455+
456+
select {
457+
case <-done:
458+
case <-time.After(2 * time.Second):
459+
t.Fatal("SendAndWait did not resolve when backgroundTasks arrays were empty")
460+
}
461+
})
462+
}
463+

0 commit comments

Comments
 (0)