Skip to content
Open
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
20 changes: 15 additions & 5 deletions dotnet/src/Session.cs
Original file line number Diff line number Diff line change
Expand Up @@ -200,20 +200,26 @@ public async Task<string> SendAsync(MessageOptions options, CancellationToken ca
}

/// <summary>
/// Sends a message to the Copilot session and waits until the session becomes idle.
/// Sends a message to the Copilot session and waits until the session is fully 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="TimeoutException">Thrown if the timeout is reached before the session becomes fully idle.</exception>
/// <exception cref="OperationCanceledException">Thrown if the <paramref name="cancellationToken"/> is cancelled.</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.
/// has finished processing the message and all background tasks have completed.
/// </para>
/// <para>
/// <b>Background tasks:</b> When the CLI emits <c>session.idle</c> with a non-empty
/// <c>backgroundTasks</c> field, it signals that background agents or shells are still
/// running. <see cref="SendAndWaitAsync"/> will continue waiting until a <c>session.idle</c>
/// arrives with no active background tasks, or until the timeout fires.
/// </para>
/// <para>
/// Events are still delivered to handlers registered via <see cref="On"/> while waiting.
Expand Down Expand Up @@ -243,8 +249,12 @@ void Handler(SessionEvent evt)
lastAssistantMessage = assistantMessage;
break;

case SessionIdleEvent:
tcs.TrySetResult(lastAssistantMessage);
case SessionIdleEvent idleEvent:
var bgTasks = idleEvent.Data?.BackgroundTasks;
bool hasActiveTasks = bgTasks != null &&
((bgTasks.Agents?.Length ?? 0) > 0 || (bgTasks.Shells?.Length ?? 0) > 0);
if (!hasActiveTasks)
tcs.TrySetResult(lastAssistantMessage);
break;

case SessionErrorEvent errorEvent:
Expand Down
34 changes: 34 additions & 0 deletions dotnet/test/SessionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -427,6 +427,40 @@ public async Task Should_Get_Session_Metadata_By_Id()
Assert.Null(notFound);
}

// ─────────────────────────────────────────────────────────────────────────
// BackgroundTasks regression tests for PolyPilot#299.
//
// The fix ensures SendAndWaitAsync only resolves when session.idle arrives
// with no active backgroundTasks (agents[] and shells[] both empty/absent).
//
// Note: The "does NOT resolve with active backgroundTasks" case cannot be
// tested through public APIs in .NET because DispatchEvent is internal and
// InternalsVisibleTo is not permitted per project conventions. The
// equivalent unit tests (including the failing-before-fix repro) live in
// nodejs/test/client.test.ts under "sendAndWait backgroundTasks", and the
// Go unit tests in go/session_test.go TestSendAndWait_BackgroundTasks both
// cover the same fix logic.
// ─────────────────────────────────────────────────────────────────────────

/// <summary>
/// Proves that SendAndWaitAsync resolves when session.idle has no
/// backgroundTasks (the normal, clean-idle case after our fix).
/// </summary>
[Fact]
public async Task SendAndWait_Resolves_After_Clean_SessionIdle()
{
var session = await CreateSessionAsync();

// A simple prompt that completes quickly — the CLI will emit
// session.idle with no backgroundTasks once the turn ends.
var result = await session.SendAndWaitAsync(
new MessageOptions { Prompt = "What is 1+1?" },
TimeSpan.FromSeconds(30));

Assert.NotNull(result);
Assert.Contains("2", result!.Data.Content);
}

[Fact]
public async Task SendAndWait_Throws_On_Timeout()
{
Expand Down
26 changes: 19 additions & 7 deletions go/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -150,18 +150,26 @@ func (s *Session) Send(ctx context.Context, options MessageOptions) (string, err
return response.MessageID, nil
}

// SendAndWait sends a message to this session and waits until the session becomes idle.
// SendAndWait sends a message to this session and waits until the session is fully idle.
//
// This is a convenience method that combines [Session.Send] with waiting for
// the session.idle event. Use this when you want to block until the assistant
// has finished processing the message.
// has finished processing the message and all background tasks (background
// agents and shell commands) have completed.
//
// Background tasks: when the CLI emits session.idle with a non-empty
// BackgroundTasks field it signals that background agents or shells are still
// running. SendAndWait continues waiting until a session.idle arrives with no
// active background tasks, or until the context deadline fires.
//
// Events are still delivered to handlers registered via [Session.On] while waiting.
//
// Parameters:
// - ctx: Controls how long to wait. Pass a context with a deadline to set a
// custom timeout; if no deadline is set, a default 60-second timeout is
// applied. Cancelling the context or reaching the deadline will return an
// error — it does not abort in-flight agent work on the CLI side.
// - options: The message options including the prompt and optional attachments.
// - timeout: How long to wait for completion. Defaults to 60 seconds if zero.
// Controls how long to wait; does not abort in-flight agent work.
//
// Returns the final assistant message event, or nil if none was received.
// Returns an error if the timeout is reached or the connection fails.
Expand Down Expand Up @@ -197,9 +205,13 @@ func (s *Session) SendAndWait(ctx context.Context, options MessageOptions) (*Ses
lastAssistantMessage = &eventCopy
mu.Unlock()
case SessionEventTypeSessionIdle:
select {
case idleCh <- struct{}{}:
default:
bgTasks := event.Data.BackgroundTasks
hasActive := bgTasks != nil && (len(bgTasks.Agents) > 0 || len(bgTasks.Shells) > 0)
if !hasActive {
select {
case idleCh <- struct{}{}:
default:
}
}
case SessionEventTypeSessionError:
errMsg := "session error"
Expand Down
Loading