diff --git a/.github/workflows/sdk-e2e-tests.yml b/.github/workflows/sdk-e2e-tests.yml index 0b060a4..3665f05 100644 --- a/.github/workflows/sdk-e2e-tests.yml +++ b/.github/workflows/sdk-e2e-tests.yml @@ -51,6 +51,10 @@ jobs: working-directory: ./test/harness run: npm ci --ignore-scripts + - name: Warm up PowerShell + if: runner.os == 'Windows' + run: pwsh.exe -Command "Write-Host 'PowerShell ready'" + - name: Run Node.js SDK tests env: COPILOT_HMAC_KEY: ${{ secrets.COPILOT_DEVELOPER_CLI_INTEGRATION_HMAC_KEY }} @@ -99,6 +103,10 @@ jobs: working-directory: ./test/harness run: npm ci --ignore-scripts + - name: Warm up PowerShell + if: runner.os == 'Windows' + run: pwsh.exe -Command "Write-Host 'PowerShell ready'" + - name: Run Go SDK tests env: COPILOT_HMAC_KEY: ${{ secrets.COPILOT_DEVELOPER_CLI_INTEGRATION_HMAC_KEY }} @@ -144,6 +152,10 @@ jobs: working-directory: ./test/harness run: npm ci --ignore-scripts + - name: Warm up PowerShell + if: runner.os == 'Windows' + run: pwsh.exe -Command "Write-Host 'PowerShell ready'" + - name: Run Python SDK tests env: COPILOT_HMAC_KEY: ${{ secrets.COPILOT_DEVELOPER_CLI_INTEGRATION_HMAC_KEY }} @@ -196,6 +208,10 @@ jobs: working-directory: ./test/harness run: npm ci --ignore-scripts + - name: Warm up PowerShell + if: runner.os == 'Windows' + run: pwsh.exe -Command "Write-Host 'PowerShell ready'" + - name: Run .NET SDK tests env: COPILOT_HMAC_KEY: ${{ secrets.COPILOT_DEVELOPER_CLI_INTEGRATION_HMAC_KEY }} diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index 8c61c73..87cab01 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -766,7 +766,10 @@ public void OnSessionEvent(string sessionId, if (session != null && @event != null) { var evt = SessionEvent.FromJson(@event.Value.GetRawText()); - session.DispatchEvent(evt); + if (evt != null) + { + session.DispatchEvent(evt); + } } } diff --git a/dotnet/src/Generated/SessionEvents.cs b/dotnet/src/Generated/SessionEvents.cs index acd03c6..716f2ba 100644 --- a/dotnet/src/Generated/SessionEvents.cs +++ b/dotnet/src/Generated/SessionEvents.cs @@ -78,7 +78,7 @@ internal class SessionEventConverter : JsonConverter throw new JsonException("Missing 'type' discriminator property"); if (!TypeMap.TryGetValue(typeProp, out var targetType)) - throw new JsonException($"Unknown event type: {typeProp}"); + return null; // Ignore unknown event types for forward compatibility // Deserialize to the concrete type without using this converter (to avoid recursion) return (SessionEvent?)obj.Deserialize(targetType, SerializerOptions.WithoutConverter); diff --git a/dotnet/src/Session.cs b/dotnet/src/Session.cs index e86e007..210409f 100644 --- a/dotnet/src/Session.cs +++ b/dotnet/src/Session.cs @@ -36,8 +36,8 @@ namespace GitHub.Copilot.SDK; /// } /// }); /// -/// // Send a message -/// await session.SendAsync(new MessageOptions { Prompt = "Hello, world!" }); +/// // Send a message and wait for completion +/// await session.SendAndWaitAsync(new MessageOptions { Prompt = "Hello, world!" }); /// /// public class CopilotSession : IAsyncDisposable @@ -76,8 +76,13 @@ internal CopilotSession(string sessionId, JsonRpc rpc) /// A task that resolves with the ID of the response message, which can be used to correlate events. /// Thrown if the session has been disposed. /// - /// The message is processed asynchronously. Subscribe to events via to receive - /// streaming responses and other session events. + /// + /// This method returns immediately after the message is queued. Use + /// if you need to wait for the assistant to finish processing. + /// + /// + /// Subscribe to events via to receive streaming responses and other session events. + /// /// /// /// @@ -107,6 +112,70 @@ public async Task SendAsync(MessageOptions options, CancellationToken ca return response.MessageId; } + /// + /// Sends a message to the Copilot session and waits until the session becomes idle. + /// + /// Options for the message to be sent, including the prompt and optional attachments. + /// Timeout duration (default: 60 seconds). Controls how long to wait; does not abort in-flight agent work. + /// A that can be used to cancel the operation. + /// A task that resolves with the final assistant message event, or null if none was received. + /// Thrown if the timeout is reached before the session becomes idle. + /// Thrown if the session has been disposed. + /// + /// + /// This is a convenience method that combines with waiting for + /// the session.idle event. Use this when you want to block until the assistant + /// has finished processing the message. + /// + /// + /// Events are still delivered to handlers registered via while waiting. + /// + /// + /// + /// + /// // Send and wait for completion with default 60s timeout + /// var response = await session.SendAndWaitAsync(new MessageOptions { Prompt = "What is 2+2?" }); + /// Console.WriteLine(response?.Data?.Content); // "4" + /// + /// + public async Task SendAndWaitAsync( + MessageOptions options, + TimeSpan? timeout = null, + CancellationToken cancellationToken = default) + { + var effectiveTimeout = timeout ?? TimeSpan.FromSeconds(60); + var tcs = new TaskCompletionSource(); + AssistantMessageEvent? lastAssistantMessage = null; + + void Handler(SessionEvent evt) + { + if (evt is AssistantMessageEvent assistantMessage) + { + lastAssistantMessage = assistantMessage; + } + else if (evt.Type == "session.idle") + { + tcs.TrySetResult(lastAssistantMessage); + } + else if (evt is SessionErrorEvent errorEvent) + { + var message = errorEvent.Data?.Message ?? "session error"; + tcs.TrySetException(new InvalidOperationException($"Session error: {message}")); + } + } + + using var subscription = On(Handler); + + await SendAsync(options, cancellationToken); + + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + cts.CancelAfter(effectiveTimeout); + + using var registration = cts.Token.Register(() => + tcs.TrySetException(new TimeoutException($"SendAndWaitAsync timed out after {effectiveTimeout}"))); + return await tcs.Task; + } + /// /// Registers a callback for session events. /// @@ -271,7 +340,10 @@ public async Task> GetMessagesAsync(CancellationToke var response = await _rpc.InvokeWithCancellationAsync( "session.getMessages", [new { sessionId = SessionId }], cancellationToken); - return response.Events.Select(e => SessionEvent.FromJson(e.ToJsonString())).ToList(); + return response.Events + .Select(e => SessionEvent.FromJson(e.ToJsonString())) + .OfType() + .ToList(); } /// diff --git a/dotnet/test/McpAndAgentsTests.cs b/dotnet/test/McpAndAgentsTests.cs index 5b65cf7..d216032 100644 --- a/dotnet/test/McpAndAgentsTests.cs +++ b/dotnet/test/McpAndAgentsTests.cs @@ -47,8 +47,7 @@ public async Task Should_Accept_MCP_Server_Configuration_On_Session_Resume() // Create a session first var session1 = await Client.CreateSessionAsync(); var sessionId = session1.SessionId; - await session1.SendAsync(new MessageOptions { Prompt = "What is 1+1?" }); - await TestHelper.GetFinalAssistantMessageAsync(session1); + await session1.SendAndWaitAsync(new MessageOptions { Prompt = "What is 1+1?" }); // Resume with MCP servers var mcpServers = new Dictionary @@ -69,9 +68,7 @@ public async Task Should_Accept_MCP_Server_Configuration_On_Session_Resume() Assert.Equal(sessionId, session2.SessionId); - await session2.SendAsync(new MessageOptions { Prompt = "What is 3+3?" }); - - var message = await TestHelper.GetFinalAssistantMessageAsync(session2); + var message = await session2.SendAndWaitAsync(new MessageOptions { Prompt = "What is 3+3?" }); Assert.NotNull(message); Assert.Contains("6", message!.Data.Content); @@ -146,8 +143,7 @@ public async Task Should_Accept_Custom_Agent_Configuration_On_Session_Resume() // Create a session first var session1 = await Client.CreateSessionAsync(); var sessionId = session1.SessionId; - await session1.SendAsync(new MessageOptions { Prompt = "What is 1+1?" }); - await TestHelper.GetFinalAssistantMessageAsync(session1); + await session1.SendAndWaitAsync(new MessageOptions { Prompt = "What is 1+1?" }); // Resume with custom agents var customAgents = new List @@ -168,9 +164,7 @@ public async Task Should_Accept_Custom_Agent_Configuration_On_Session_Resume() Assert.Equal(sessionId, session2.SessionId); - await session2.SendAsync(new MessageOptions { Prompt = "What is 6+6?" }); - - var message = await TestHelper.GetFinalAssistantMessageAsync(session2); + var message = await session2.SendAndWaitAsync(new MessageOptions { Prompt = "What is 6+6?" }); Assert.NotNull(message); Assert.Contains("12", message!.Data.Content); diff --git a/dotnet/test/PermissionTests.cs b/dotnet/test/PermissionTests.cs index 9202ec1..237eb1f 100644 --- a/dotnet/test/PermissionTests.cs +++ b/dotnet/test/PermissionTests.cs @@ -118,8 +118,7 @@ public async Task Should_Resume_Session_With_Permission_Handler() // Create session without permission handler var session1 = await Client.CreateSessionAsync(); var sessionId = session1.SessionId; - await session1.SendAsync(new MessageOptions { Prompt = "What is 1+1?" }); - await TestHelper.GetFinalAssistantMessageAsync(session1); + await session1.SendAndWaitAsync(new MessageOptions { Prompt = "What is 1+1?" }); // Resume with permission handler var session2 = await Client.ResumeSessionAsync(sessionId, new ResumeSessionConfig @@ -131,13 +130,11 @@ public async Task Should_Resume_Session_With_Permission_Handler() } }); - await session2.SendAsync(new MessageOptions + await session2.SendAndWaitAsync(new MessageOptions { Prompt = "Run 'echo resumed' for me" }); - await TestHelper.GetFinalAssistantMessageAsync(session2); - Assert.True(permissionRequestReceived, "Permission request should have been received"); } diff --git a/dotnet/test/SessionTests.cs b/dotnet/test/SessionTests.cs index e72fe27..2e1119f 100644 --- a/dotnet/test/SessionTests.cs +++ b/dotnet/test/SessionTests.cs @@ -35,13 +35,11 @@ public async Task Should_Have_Stateful_Conversation() { var session = await Client.CreateSessionAsync(); - await session.SendAsync(new MessageOptions { Prompt = "What is 1+1?" }); - var assistantMessage = await TestHelper.GetFinalAssistantMessageAsync(session); + var assistantMessage = await session.SendAndWaitAsync(new MessageOptions { Prompt = "What is 1+1?" }); Assert.NotNull(assistantMessage); Assert.Contains("2", assistantMessage!.Data.Content); - await session.SendAsync(new MessageOptions { Prompt = "Now if you double that, what do you get?" }); - var secondMessage = await TestHelper.GetFinalAssistantMessageAsync(session); + var secondMessage = await session.SendAndWaitAsync(new MessageOptions { Prompt = "Now if you double that, what do you get?" }); Assert.NotNull(secondMessage); Assert.Contains("4", secondMessage!.Data.Content); } @@ -322,4 +320,55 @@ public async Task Should_Receive_Session_Events() await session.DisposeAsync(); } + + [Fact] + public async Task Send_Returns_Immediately_While_Events_Stream_In_Background() + { + var session = await Client.CreateSessionAsync(); + var events = new List(); + + session.On(evt => events.Add(evt.Type)); + + // Use a slow command so we can verify SendAsync() returns before completion + await session.SendAsync(new MessageOptions { Prompt = "Run 'sleep 2 && echo done'" }); + + // SendAsync() should return before turn completes (no session.idle yet) + Assert.DoesNotContain("session.idle", events); + + // Wait for turn to complete + var message = await TestHelper.GetFinalAssistantMessageAsync(session); + + Assert.Contains("done", message?.Data.Content ?? string.Empty); + Assert.Contains("session.idle", events); + Assert.Contains("assistant.message", events); + } + + [Fact] + public async Task SendAndWait_Blocks_Until_Session_Idle_And_Returns_Final_Assistant_Message() + { + var session = await Client.CreateSessionAsync(); + var events = new List(); + + session.On(evt => events.Add(evt.Type)); + + var response = await session.SendAndWaitAsync(new MessageOptions { Prompt = "What is 2+2?" }); + + Assert.NotNull(response); + Assert.Equal("assistant.message", response!.Type); + Assert.Contains("4", response.Data.Content ?? string.Empty); + Assert.Contains("session.idle", events); + Assert.Contains("assistant.message", events); + } + + [Fact] + public async Task SendAndWait_Throws_On_Timeout() + { + var session = await Client.CreateSessionAsync(); + + // Use a slow command to ensure timeout triggers before completion + var ex = await Assert.ThrowsAsync(() => + session.SendAndWaitAsync(new MessageOptions { Prompt = "Run 'sleep 2 && echo done'" }, TimeSpan.FromMilliseconds(100))); + + Assert.Contains("timed out", ex.Message); + } } diff --git a/go/e2e/mcp_and_agents_test.go b/go/e2e/mcp_and_agents_test.go index cc264c5..3b565ce 100644 --- a/go/e2e/mcp_and_agents_test.go +++ b/go/e2e/mcp_and_agents_test.go @@ -67,14 +67,10 @@ func TestMCPServers(t *testing.T) { } sessionID := session1.SessionID - _, err = session1.Send(copilot.MessageOptions{Prompt: "What is 1+1?"}) + _, err = session1.SendAndWait(copilot.MessageOptions{Prompt: "What is 1+1?"}, 60*time.Second) if err != nil { t.Fatalf("Failed to send message: %v", err) } - _, err = testharness.GetFinalAssistantMessage(session1, 60*time.Second) - if err != nil { - t.Fatalf("Failed to get final message: %v", err) - } // Resume with MCP servers mcpServers := map[string]copilot.MCPServerConfig{ @@ -97,16 +93,11 @@ func TestMCPServers(t *testing.T) { t.Errorf("Expected session ID %s, got %s", sessionID, session2.SessionID) } - _, err = session2.Send(copilot.MessageOptions{Prompt: "What is 3+3?"}) + message, err := session2.SendAndWait(copilot.MessageOptions{Prompt: "What is 3+3?"}, 60*time.Second) if err != nil { t.Fatalf("Failed to send message: %v", err) } - message, err := testharness.GetFinalAssistantMessage(session2, 60*time.Second) - if err != nil { - t.Fatalf("Failed to get final message: %v", err) - } - if message.Data.Content == nil || !strings.Contains(*message.Data.Content, "6") { t.Errorf("Expected message to contain '6', got: %v", message.Data.Content) } @@ -207,14 +198,10 @@ func TestCustomAgents(t *testing.T) { } sessionID := session1.SessionID - _, err = session1.Send(copilot.MessageOptions{Prompt: "What is 1+1?"}) + _, err = session1.SendAndWait(copilot.MessageOptions{Prompt: "What is 1+1?"}, 60*time.Second) if err != nil { t.Fatalf("Failed to send message: %v", err) } - _, err = testharness.GetFinalAssistantMessage(session1, 60*time.Second) - if err != nil { - t.Fatalf("Failed to get final message: %v", err) - } // Resume with custom agents customAgents := []copilot.CustomAgentConfig{ @@ -237,16 +224,11 @@ func TestCustomAgents(t *testing.T) { t.Errorf("Expected session ID %s, got %s", sessionID, session2.SessionID) } - _, err = session2.Send(copilot.MessageOptions{Prompt: "What is 6+6?"}) + message, err := session2.SendAndWait(copilot.MessageOptions{Prompt: "What is 6+6?"}, 60*time.Second) if err != nil { t.Fatalf("Failed to send message: %v", err) } - message, err := testharness.GetFinalAssistantMessage(session2, 60*time.Second) - if err != nil { - t.Fatalf("Failed to get final message: %v", err) - } - if message.Data.Content == nil || !strings.Contains(*message.Data.Content, "12") { t.Errorf("Expected message to contain '12', got: %v", message.Data.Content) } diff --git a/go/e2e/permissions_test.go b/go/e2e/permissions_test.go index f377f01..f1bb53c 100644 --- a/go/e2e/permissions_test.go +++ b/go/e2e/permissions_test.go @@ -48,18 +48,13 @@ func TestPermissions(t *testing.T) { t.Fatalf("Failed to write test file: %v", err) } - _, err = session.Send(copilot.MessageOptions{ + _, err = session.SendAndWait(copilot.MessageOptions{ Prompt: "Edit test.txt and replace 'original' with 'modified'", - }) + }, 60*time.Second) if err != nil { t.Fatalf("Failed to send message: %v", err) } - _, err = testharness.GetFinalAssistantMessage(session, 60*time.Second) - if err != nil { - t.Fatalf("Failed to get final message: %v", err) - } - mu.Lock() if len(permissionRequests) == 0 { t.Error("Expected at least one permission request") @@ -98,18 +93,13 @@ func TestPermissions(t *testing.T) { t.Fatalf("Failed to create session: %v", err) } - _, err = session.Send(copilot.MessageOptions{ - Prompt: "Run 'echo hello' and tell me the output", - }) + _, err = session.SendAndWait(copilot.MessageOptions{ + Prompt: "Run 'echo hello world' and tell me the output", + }, 60*time.Second) if err != nil { t.Fatalf("Failed to send message: %v", err) } - _, err = testharness.GetFinalAssistantMessage(session, 60*time.Second) - if err != nil { - t.Fatalf("Failed to get final message: %v", err) - } - mu.Lock() shellCount := 0 for _, req := range permissionRequests { diff --git a/go/e2e/session_test.go b/go/e2e/session_test.go index 02cea5b..3de45eb 100644 --- a/go/e2e/session_test.go +++ b/go/e2e/session_test.go @@ -63,30 +63,20 @@ func TestSession(t *testing.T) { t.Fatalf("Failed to create session: %v", err) } - _, err = session.Send(copilot.MessageOptions{Prompt: "What is 1+1?"}) + assistantMessage, err := session.SendAndWait(copilot.MessageOptions{Prompt: "What is 1+1?"}, 60*time.Second) if err != nil { t.Fatalf("Failed to send message: %v", err) } - assistantMessage, err := testharness.GetFinalAssistantMessage(session, 60*time.Second) - if err != nil { - t.Fatalf("Failed to get assistant message: %v", err) - } - if assistantMessage.Data.Content == nil || !strings.Contains(*assistantMessage.Data.Content, "2") { t.Errorf("Expected assistant message to contain '2', got %v", assistantMessage.Data.Content) } - _, err = session.Send(copilot.MessageOptions{Prompt: "Now if you double that, what do you get?"}) + secondMessage, err := session.SendAndWait(copilot.MessageOptions{Prompt: "Now if you double that, what do you get?"}, 60*time.Second) if err != nil { t.Fatalf("Failed to send second message: %v", err) } - secondMessage, err := testharness.GetFinalAssistantMessage(session, 60*time.Second) - if err != nil { - t.Fatalf("Failed to get second assistant message: %v", err) - } - if secondMessage.Data.Content == nil || !strings.Contains(*secondMessage.Data.Content, "4") { t.Errorf("Expected second message to contain '4', got %v", secondMessage.Data.Content) } @@ -106,18 +96,13 @@ func TestSession(t *testing.T) { t.Fatalf("Failed to create session: %v", err) } - _, err = session.Send(copilot.MessageOptions{Prompt: "What is your full name?"}) + assistantMessage, err := session.SendAndWait(copilot.MessageOptions{Prompt: "What is your full name?"}, 60*time.Second) if err != nil { t.Fatalf("Failed to send message: %v", err) } - assistantMessage, err := testharness.GetFinalAssistantMessage(session, 60*time.Second) - if err != nil { - t.Fatalf("Failed to get assistant message: %v", err) - } - content := "" - if assistantMessage.Data.Content != nil { + if assistantMessage != nil && assistantMessage.Data.Content != nil { content = *assistantMessage.Data.Content } diff --git a/go/session.go b/go/session.go index b34fe6e..769d9da 100644 --- a/go/session.go +++ b/go/session.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "sync" + "time" "github.com/github/copilot-sdk/go/generated" ) @@ -113,6 +114,86 @@ func (s *Session) Send(options MessageOptions) (string, error) { return messageID, nil } +// SendAndWait sends a message to this session and waits until the session becomes 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. +// +// Events are still delivered to handlers registered via [Session.On] while waiting. +// +// Parameters: +// - 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. +// +// Example: +// +// response, err := session.SendAndWait(copilot.MessageOptions{ +// Prompt: "What is 2+2?", +// }, 0) // Use default 60s timeout +// if err != nil { +// log.Printf("Failed: %v", err) +// } +// if response != nil { +// fmt.Println(*response.Data.Content) +// } +func (s *Session) SendAndWait(options MessageOptions, timeout time.Duration) (*SessionEvent, error) { + if timeout == 0 { + timeout = 60 * time.Second + } + + idleCh := make(chan struct{}, 1) + errCh := make(chan error, 1) + var lastAssistantMessage *SessionEvent + var mu sync.Mutex + + unsubscribe := s.On(func(event SessionEvent) { + switch event.Type { + case generated.AssistantMessage: + mu.Lock() + eventCopy := event + lastAssistantMessage = &eventCopy + mu.Unlock() + case generated.SessionIdle: + select { + case idleCh <- struct{}{}: + default: + } + case generated.SessionError: + errMsg := "session error" + if event.Data.Message != nil { + errMsg = *event.Data.Message + } + select { + case errCh <- fmt.Errorf("session error: %s", errMsg): + default: + } + } + }) + defer unsubscribe() + + _, err := s.Send(options) + if err != nil { + return nil, err + } + + select { + case <-idleCh: + mu.Lock() + result := lastAssistantMessage + mu.Unlock() + return result, nil + case err := <-errCh: + return nil, err + case <-time.After(timeout): + return nil, fmt.Errorf("timeout after %v waiting for session.idle", timeout) + } +} + // On subscribes to events from this session. // // Events include assistant messages, tool executions, errors, and session state diff --git a/nodejs/README.md b/nodejs/README.md index 73e3648..dea3b3e 100644 --- a/nodejs/README.md +++ b/nodejs/README.md @@ -120,7 +120,7 @@ Represents a single conversation session. ##### `send(options: MessageOptions): Promise` -Send a message to the session. +Send a message to the session. Returns immediately after the message is queued; use event handlers or `sendAndWait()` to wait for completion. **Options:** @@ -130,6 +130,19 @@ Send a message to the session. Returns the message ID. +##### `sendAndWait(options: MessageOptions, timeout?: number): Promise` + +Send a message and wait until the session becomes idle. + +**Options:** + +- `prompt: string` - The message/prompt to send +- `attachments?: Array<{type, path, displayName}>` - File attachments +- `mode?: "enqueue" | "immediate"` - Delivery mode +- `timeout?: number` - Optional timeout in milliseconds + +Returns the final assistant message event, or undefined if none was received. + ##### `on(handler: SessionEventHandler): () => void` Subscribe to session events. Returns an unsubscribe function. @@ -299,8 +312,8 @@ const session1 = await client.createSession({ model: "gpt-5" }); const session2 = await client.createSession({ model: "claude-sonnet-4.5" }); // Both sessions are independent -await session1.send({ prompt: "Hello from session 1" }); -await session2.send({ prompt: "Hello from session 2" }); +await session1.sendAndWait({ prompt: "Hello from session 1" }); +await session2.sendAndWait({ prompt: "Hello from session 2" }); ``` ### Custom Session IDs diff --git a/nodejs/examples/basic-example.ts b/nodejs/examples/basic-example.ts index 2de680b..2dc47d6 100644 --- a/nodejs/examples/basic-example.ts +++ b/nodejs/examples/basic-example.ts @@ -96,22 +96,17 @@ async function main() { // Send a simple message console.log("๐Ÿ’ฌ Sending message..."); - const messageId = await session.send({ + await session.sendAndWait({ prompt: "You can call the lookup_fact tool. First, please tell me 2+2.", }); - console.log(`โœ… Message sent: ${messageId}\n`); - - // Wait a bit for events to arrive - await new Promise((resolve) => setTimeout(resolve, 5000)); + console.log("โœ… Message completed\n"); // Send another message console.log("\n๐Ÿ’ฌ Sending follow-up message..."); - await session.send({ + await session.sendAndWait({ prompt: "Great. Now use lookup_fact to tell me something about Node.js.", }); - - // Wait for response - await new Promise((resolve) => setTimeout(resolve, 5000)); + console.log("โœ… Follow-up completed\n"); // Clean up console.log("\n๐Ÿงน Cleaning up..."); diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json index 1bb7b99..15ea438 100644 --- a/nodejs/package-lock.json +++ b/nodejs/package-lock.json @@ -9,7 +9,7 @@ "version": "0.1.8", "license": "MIT", "dependencies": { - "@github/copilot": "^0.0.383", + "@github/copilot": "^0.0.384", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.5" }, @@ -662,9 +662,9 @@ } }, "node_modules/@github/copilot": { - "version": "0.0.383", - "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-0.0.383.tgz", - "integrity": "sha512-bE81nL/1YTppMS6gB/Nq7S+5EcD45awvrYgSkhLZKBuWhwOQ42jDp0g2lID1nR4GrwatV+FoDckQw2NpDPY93A==", + "version": "0.0.384", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-0.0.384.tgz", + "integrity": "sha512-kcM+H33oPgYAsnu5ESd5IS3zw2HnV26+D/ZRB42EUF0f4FfITZchRBYguZLgk2g+7NCDHHM9vZ/Kg7699byEaA==", "license": "SEE LICENSE IN LICENSE.md", "bin": { "copilot": "npm-loader.js" @@ -673,18 +673,18 @@ "node": ">=22" }, "optionalDependencies": { - "@github/copilot-darwin-arm64": "0.0.383", - "@github/copilot-darwin-x64": "0.0.383", - "@github/copilot-linux-arm64": "0.0.383", - "@github/copilot-linux-x64": "0.0.383", - "@github/copilot-win32-arm64": "0.0.383", - "@github/copilot-win32-x64": "0.0.383" + "@github/copilot-darwin-arm64": "0.0.384", + "@github/copilot-darwin-x64": "0.0.384", + "@github/copilot-linux-arm64": "0.0.384", + "@github/copilot-linux-x64": "0.0.384", + "@github/copilot-win32-arm64": "0.0.384", + "@github/copilot-win32-x64": "0.0.384" } }, "node_modules/@github/copilot-darwin-arm64": { - "version": "0.0.383", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-0.0.383.tgz", - "integrity": "sha512-GfwHGgVmlYS3ksQhyBRQRUQtGtumRDoszByBfkyoJrDH9bLjAMM3EyS6r5nhmH7PMadjU4ZCkj8FGek7imDGtw==", + "version": "0.0.384", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-0.0.384.tgz", + "integrity": "sha512-lSJxgCgFaIz+6RkT/SpXBuysKQru6xCF0prnCZp2tvywOmulsujtbCndBPFCpaUlm7XQh87TZ3RSrkzdisLtjQ==", "cpu": [ "arm64" ], @@ -698,9 +698,9 @@ } }, "node_modules/@github/copilot-darwin-x64": { - "version": "0.0.383", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-0.0.383.tgz", - "integrity": "sha512-4gTjY9St/MyFadPpdvVYiGjvHPPYmFns6ic3AX3q+HTpj1zqGpnjLbwfZeM/Lfb84oMIhM2sR1G/Bv8B+T3l/g==", + "version": "0.0.384", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-0.0.384.tgz", + "integrity": "sha512-mkkgGQn/YMcrBPkaOsmi4JrM/ItSzC1eIBFitiqCw/+LbWEQTVqAwdQLrjo2QtFoGgs+IMJNAZbnnAe6DQ20Eg==", "cpu": [ "x64" ], @@ -714,9 +714,9 @@ } }, "node_modules/@github/copilot-linux-arm64": { - "version": "0.0.383", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-0.0.383.tgz", - "integrity": "sha512-QoqK76G7sAh7DVpg2GlnIDa5lYe9FK9U1oFwOVjwXwwKJe8PpIWwNVeO4nERGrkc4CQy7u4U59GSmfXQzoFXvw==", + "version": "0.0.384", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-0.0.384.tgz", + "integrity": "sha512-TdWNkmEKHXrxLgcnmxtUaem+0eQbggGqxcUiD0jYMnJQ5HWgap1ARHF4To5CnjR7EVBRlxo3ikDlWnHi9dwLqA==", "cpu": [ "arm64" ], @@ -730,9 +730,9 @@ } }, "node_modules/@github/copilot-linux-x64": { - "version": "0.0.383", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-0.0.383.tgz", - "integrity": "sha512-EJHnq575pv7N586WjQkZZdDLqfd2GemGxk3aIhWrHtXMmLY4qRAJJBUnF1MtNqccTKuPmLuD8nAUTrxQp7sWPA==", + "version": "0.0.384", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-0.0.384.tgz", + "integrity": "sha512-rl1IEtd+xGPRDLqJJ4NGd3JHvbR48zf6qUFgVb8Si6LGXNuRl9Wiol97JxOVpAKCKOjYdHMSWxPu5TmQfJXDuA==", "cpu": [ "x64" ], @@ -746,9 +746,9 @@ } }, "node_modules/@github/copilot-win32-arm64": { - "version": "0.0.383", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-0.0.383.tgz", - "integrity": "sha512-76NT8ULHpbmM/YOz71FPAUUfAhfEVqhEew+Wkqtgn+eG48gCnDYu3ZQIRbnWIh/oj6nYVTyi0wg9LUt7M8sFRQ==", + "version": "0.0.384", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-0.0.384.tgz", + "integrity": "sha512-mQXEMqZtNznhkxD0vVIvvvqduJWSpsyHxgL/5R598vCcCM5DLV8khNqW6DRHep5gi39tp96Wo9HYl4wytdMHpA==", "cpu": [ "arm64" ], @@ -762,9 +762,9 @@ } }, "node_modules/@github/copilot-win32-x64": { - "version": "0.0.383", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-0.0.383.tgz", - "integrity": "sha512-/5r5uK8pUoefS8H9cax96GqBzm62uBeXEphct7SxPU/gnf2udDvb+0iBOlvKskAwdWNXLp3Khxgm4nfFgxrr9A==", + "version": "0.0.384", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-0.0.384.tgz", + "integrity": "sha512-emFbcsqGuut1aU3HfeMB1HIW3e2nVz21SepQREGBdnDwJSZFPGguPmHHvkc/TsdAef8cId8QBQj/FtSfaxlZWA==", "cpu": [ "x64" ], diff --git a/nodejs/package.json b/nodejs/package.json index 1042a65..f3c87e4 100644 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -40,7 +40,7 @@ "author": "GitHub", "license": "MIT", "dependencies": { - "@github/copilot": "^0.0.383", + "@github/copilot": "^0.0.384", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.5" }, diff --git a/nodejs/src/index.ts b/nodejs/src/index.ts index 1a1d64f..e5943be 100644 --- a/nodejs/src/index.ts +++ b/nodejs/src/index.ts @@ -9,7 +9,7 @@ */ export { CopilotClient } from "./client.js"; -export { CopilotSession } from "./session.js"; +export { CopilotSession, type AssistantMessageEvent } from "./session.js"; export { defineTool } from "./types.js"; export type { ConnectionState, diff --git a/nodejs/src/session.ts b/nodejs/src/session.ts index 571e24e..ca9789c 100644 --- a/nodejs/src/session.ts +++ b/nodejs/src/session.ts @@ -19,6 +19,9 @@ import type { ToolHandler, } from "./types.js"; +/** Assistant message event - the final response from the assistant. */ +export type AssistantMessageEvent = Extract; + /** * Represents a single conversation session with the Copilot CLI. * @@ -31,17 +34,16 @@ import type { * const session = await client.createSession({ model: "gpt-4" }); * * // Subscribe to events - * const unsubscribe = session.on((event) => { + * session.on((event) => { * if (event.type === "assistant.message") { * console.log(event.data.content); * } * }); * - * // Send a message - * await session.send({ prompt: "Hello, world!" }); + * // Send a message and wait for completion + * await session.sendAndWait({ prompt: "Hello, world!" }); * * // Clean up - * unsubscribe(); * await session.destroy(); * ``` */ @@ -91,6 +93,80 @@ export class CopilotSession { return (response as { messageId: string }).messageId; } + /** + * Sends a message to this session and waits until the session becomes idle. + * + * This is a convenience method that combines {@link send} with waiting for + * the `session.idle` event. Use this when you want to block until the + * assistant has finished processing the message. + * + * Events are still delivered to handlers registered via {@link on} while waiting. + * + * @param options - The message options including the prompt and optional attachments + * @param timeout - Timeout in milliseconds (default: 60000). Controls how long to wait; does not abort in-flight agent work. + * @returns A promise that resolves with the final assistant message when the session becomes idle, + * or undefined if no assistant message was received + * @throws Error if the timeout is reached before the session becomes idle + * @throws Error if the session has been destroyed or the connection fails + * + * @example + * ```typescript + * // Send and wait for completion with default 60s timeout + * const response = await session.sendAndWait({ prompt: "What is 2+2?" }); + * console.log(response?.data.content); // "4" + * ``` + */ + async sendAndWait( + options: MessageOptions, + timeout?: number + ): Promise { + const effectiveTimeout = timeout ?? 60_000; + + let resolveIdle: () => void; + let rejectWithError: (error: Error) => void; + const idlePromise = new Promise((resolve, reject) => { + resolveIdle = resolve; + rejectWithError = reject; + }); + + let lastAssistantMessage: AssistantMessageEvent | undefined; + + // Register event handler BEFORE calling send to avoid race condition + // where session.idle fires before we start listening + const unsubscribe = this.on((event) => { + if (event.type === "assistant.message") { + lastAssistantMessage = event; + } else if (event.type === "session.idle") { + resolveIdle(); + } else if (event.type === "session.error") { + const error = new Error(event.data.message); + error.stack = event.data.stack; + rejectWithError(error); + } + }); + + try { + await this.send(options); + + const timeoutPromise = new Promise((_, reject) => { + setTimeout( + () => + reject( + new Error( + `Timeout after ${effectiveTimeout}ms waiting for session.idle` + ) + ), + effectiveTimeout + ); + }); + await Promise.race([idlePromise, timeoutPromise]); + + return lastAssistantMessage; + } finally { + unsubscribe(); + } + } + /** * Subscribes to events from this session. * diff --git a/nodejs/test/e2e/mcp-and-agents.test.ts b/nodejs/test/e2e/mcp-and-agents.test.ts index 0249b28..49047a0 100644 --- a/nodejs/test/e2e/mcp-and-agents.test.ts +++ b/nodejs/test/e2e/mcp-and-agents.test.ts @@ -5,7 +5,6 @@ import { describe, expect, it } from "vitest"; import type { CustomAgentConfig, MCPLocalServerConfig, MCPServerConfig } from "../../src/index.js"; import { createSdkTestContext } from "./harness/sdkTestContext.js"; -import { getFinalAssistantMessage } from "./harness/sdkTestHelper.js"; describe("MCP Servers and Custom Agents", async () => { const { copilotClient: client } = await createSdkTestContext(); @@ -28,11 +27,9 @@ describe("MCP Servers and Custom Agents", async () => { expect(session.sessionId).toBeDefined(); // Simple interaction to verify session works - await session.send({ + const message = await session.sendAndWait({ prompt: "What is 2+2?", }); - - const message = await getFinalAssistantMessage(session); expect(message?.data.content).toContain("4"); await session.destroy(); @@ -42,8 +39,7 @@ describe("MCP Servers and Custom Agents", async () => { // Create a session first const session1 = await client.createSession(); const sessionId = session1.sessionId; - await session1.send({ prompt: "What is 1+1?" }); - await getFinalAssistantMessage(session1); + await session1.sendAndWait({ prompt: "What is 1+1?" }); // Resume with MCP servers const mcpServers: Record = { @@ -61,11 +57,9 @@ describe("MCP Servers and Custom Agents", async () => { expect(session2.sessionId).toBe(sessionId); - await session2.send({ + const message = await session2.sendAndWait({ prompt: "What is 3+3?", }); - - const message = await getFinalAssistantMessage(session2); expect(message?.data.content).toContain("6"); await session2.destroy(); @@ -115,11 +109,9 @@ describe("MCP Servers and Custom Agents", async () => { expect(session.sessionId).toBeDefined(); // Simple interaction to verify session works - await session.send({ + const message = await session.sendAndWait({ prompt: "What is 5+5?", }); - - const message = await getFinalAssistantMessage(session); expect(message?.data.content).toContain("10"); await session.destroy(); @@ -129,8 +121,7 @@ describe("MCP Servers and Custom Agents", async () => { // Create a session first const session1 = await client.createSession(); const sessionId = session1.sessionId; - await session1.send({ prompt: "What is 1+1?" }); - await getFinalAssistantMessage(session1); + await session1.sendAndWait({ prompt: "What is 1+1?" }); // Resume with custom agents const customAgents: CustomAgentConfig[] = [ @@ -148,11 +139,9 @@ describe("MCP Servers and Custom Agents", async () => { expect(session2.sessionId).toBe(sessionId); - await session2.send({ + const message = await session2.sendAndWait({ prompt: "What is 6+6?", }); - - const message = await getFinalAssistantMessage(session2); expect(message?.data.content).toContain("12"); await session2.destroy(); @@ -257,11 +246,9 @@ describe("MCP Servers and Custom Agents", async () => { expect(session.sessionId).toBeDefined(); - await session.send({ + const message = await session.sendAndWait({ prompt: "What is 7+7?", }); - - const message = await getFinalAssistantMessage(session); expect(message?.data.content).toContain("14"); await session.destroy(); diff --git a/nodejs/test/e2e/permissions.test.ts b/nodejs/test/e2e/permissions.test.ts index 8299f30..91bad2b 100644 --- a/nodejs/test/e2e/permissions.test.ts +++ b/nodejs/test/e2e/permissions.test.ts @@ -7,7 +7,6 @@ import { join } from "path"; import { describe, expect, it } from "vitest"; import type { PermissionRequest, PermissionRequestResult } from "../../src/index.js"; import { createSdkTestContext } from "./harness/sdkTestContext.js"; -import { getFinalAssistantMessage } from "./harness/sdkTestHelper.js"; describe("Permission callbacks", async () => { const { copilotClient: client, workDir } = await createSdkTestContext(); @@ -28,12 +27,10 @@ describe("Permission callbacks", async () => { await writeFile(join(workDir, "test.txt"), "original content"); - await session.send({ + await session.sendAndWait({ prompt: "Edit test.txt and replace 'original' with 'modified'", }); - await getFinalAssistantMessage(session); - // Should have received at least one permission request expect(permissionRequests.length).toBeGreaterThan(0); @@ -55,12 +52,10 @@ describe("Permission callbacks", async () => { const testFile = join(workDir, "protected.txt"); await writeFile(testFile, originalContent); - await session.send({ + await session.sendAndWait({ prompt: "Edit protected.txt and replace 'protected' with 'hacked'.", }); - await getFinalAssistantMessage(session); - // Verify the file was NOT modified const content = await readFile(testFile, "utf-8"); expect(content).toBe(originalContent); @@ -72,11 +67,9 @@ describe("Permission callbacks", async () => { // Create session without onPermissionRequest handler const session = await client.createSession(); - await session.send({ + const message = await session.sendAndWait({ prompt: "What is 2+2?", }); - - const message = await getFinalAssistantMessage(session); expect(message?.data.content).toContain("4"); await session.destroy(); @@ -96,12 +89,10 @@ describe("Permission callbacks", async () => { }, }); - await session.send({ + await session.sendAndWait({ prompt: "Run 'echo test' and tell me what happens", }); - await getFinalAssistantMessage(session); - expect(permissionRequests.length).toBeGreaterThan(0); await session.destroy(); @@ -113,8 +104,7 @@ describe("Permission callbacks", async () => { // Create session without permission handler const session1 = await client.createSession(); const sessionId = session1.sessionId; - await session1.send({ prompt: "What is 1+1?" }); - await getFinalAssistantMessage(session1); + await session1.sendAndWait({ prompt: "What is 1+1?" }); // Resume with permission handler const session2 = await client.resumeSession(sessionId, { @@ -124,12 +114,10 @@ describe("Permission callbacks", async () => { }, }); - await session2.send({ + await session2.sendAndWait({ prompt: "Run 'echo resumed' for me", }); - await getFinalAssistantMessage(session2); - // Should have permission requests from resumed session expect(permissionRequests.length).toBeGreaterThan(0); @@ -143,12 +131,10 @@ describe("Permission callbacks", async () => { }, }); - await session.send({ + const message = await session.sendAndWait({ prompt: "Run 'echo test'. If you can't, say 'failed'.", }); - const message = await getFinalAssistantMessage(session); - // Should handle the error and deny permission expect(message?.data.content?.toLowerCase()).toMatch(/fail|cannot|unable|permission/); @@ -169,12 +155,10 @@ describe("Permission callbacks", async () => { }, }); - await session.send({ + await session.sendAndWait({ prompt: "Run 'echo test'", }); - await getFinalAssistantMessage(session); - expect(receivedToolCallId).toBe(true); await session.destroy(); diff --git a/nodejs/test/e2e/session.test.ts b/nodejs/test/e2e/session.test.ts index a25f00c..6779b00 100644 --- a/nodejs/test/e2e/session.test.ts +++ b/nodejs/test/e2e/session.test.ts @@ -24,13 +24,13 @@ describe("Sessions", async () => { it("should have stateful conversation", async () => { const session = await client.createSession(); - await session.send({ prompt: "What is 1+1?" }); - const assistantMessage = await getFinalAssistantMessage(session); - expect(assistantMessage.data.content).toContain("2"); + const assistantMessage = await session.sendAndWait({ prompt: "What is 1+1?" }); + expect(assistantMessage?.data.content).toContain("2"); - await session.send({ prompt: "Now if you double that, what do you get?" }); - const secondAssistantMessage = await getFinalAssistantMessage(session); - expect(secondAssistantMessage.data.content).toContain("4"); + const secondAssistantMessage = await session.sendAndWait({ + prompt: "Now if you double that, what do you get?", + }); + expect(secondAssistantMessage?.data.content).toContain("4"); }); it("should create a session with appended systemMessage config", async () => { @@ -42,10 +42,9 @@ describe("Sessions", async () => { }, }); - await session.send({ prompt: "What is your full name?" }); - const assistantMessage = await getFinalAssistantMessage(session); - expect(assistantMessage.data.content).toContain("GitHub"); - expect(assistantMessage.data.content).toContain("Have a nice day!"); + const assistantMessage = await session.sendAndWait({ prompt: "What is your full name?" }); + expect(assistantMessage?.data.content).toContain("GitHub"); + expect(assistantMessage?.data.content).toContain("Have a nice day!"); // Also validate the underlying traffic const traffic = await openAiEndpoint.getExchanges(); @@ -60,10 +59,9 @@ describe("Sessions", async () => { systemMessage: { mode: "replace", content: testSystemMessage }, }); - await session.send({ prompt: "What is your full name?" }); - const assistantMessage = await getFinalAssistantMessage(session); - expect(assistantMessage.data.content).not.toContain("GitHub"); - expect(assistantMessage.data.content).toContain("Testy"); + const assistantMessage = await session.sendAndWait({ prompt: "What is your full name?" }); + expect(assistantMessage?.data.content).not.toContain("GitHub"); + expect(assistantMessage?.data.content).toContain("Testy"); // Also validate the underlying traffic const traffic = await openAiEndpoint.getExchanges(); @@ -76,8 +74,7 @@ describe("Sessions", async () => { availableTools: ["view", "edit"], }); - await session.send({ prompt: "What is 1+1?" }); - await getFinalAssistantMessage(session); + await session.sendAndWait({ prompt: "What is 1+1?" }); // It only tells the model about the specified tools and no others const traffic = await openAiEndpoint.getExchanges(); @@ -92,8 +89,7 @@ describe("Sessions", async () => { excludedTools: ["view"], }); - await session.send({ prompt: "What is 1+1?" }); - await getFinalAssistantMessage(session); + await session.sendAndWait({ prompt: "What is 1+1?" }); // It has other tools, but not the one we excluded const traffic = await openAiEndpoint.getExchanges(); @@ -141,24 +137,23 @@ describe("Sessions", async () => { // Create initial session const session1 = await client.createSession(); const sessionId = session1.sessionId; - await session1.send({ prompt: "What is 1+1?" }); - const answer = await getFinalAssistantMessage(session1); - expect(answer.data.content).toContain("2"); + const answer = await session1.sendAndWait({ prompt: "What is 1+1?" }); + expect(answer?.data.content).toContain("2"); // Resume using the same client const session2 = await client.resumeSession(sessionId); expect(session2.sessionId).toBe(sessionId); - const answer2 = await getFinalAssistantMessage(session2); - expect(answer2.data.content).toContain("2"); + const messages = await session2.getMessages(); + const assistantMessages = messages.filter((m) => m.type === "assistant.message"); + expect(assistantMessages[assistantMessages.length - 1].data.content).toContain("2"); }); it("should resume a session using a new client", async () => { // Create initial session const session1 = await client.createSession(); const sessionId = session1.sessionId; - await session1.send({ prompt: "What is 1+1?" }); - const answer = await getFinalAssistantMessage(session1); - expect(answer.data.content).toContain("2"); + const answer = await session1.sendAndWait({ prompt: "What is 1+1?" }); + expect(answer?.data.content).toContain("2"); // Resume using a new client const newClient = new CopilotClient({ @@ -210,9 +205,10 @@ describe("Sessions", async () => { ], }); - await session.send({ prompt: "What is the secret number for key ALPHA?" }); - const session1Answer = await getFinalAssistantMessage(session); - expect(session1Answer.data.content).toContain("54321"); + const answer = await session.sendAndWait({ + prompt: "What is the secret number for key ALPHA?", + }); + expect(answer?.data.content).toContain("54321"); }); it("should resume session with a custom provider", async () => { @@ -235,7 +231,7 @@ describe("Sessions", async () => { const session = await client.createSession(); // Send a message that will take some time to process - await session.send({ prompt: "What is 1+1?" }); + await session.sendAndWait({ prompt: "What is 1+1?" }); // Abort the session immediately await session.abort(); @@ -245,9 +241,8 @@ describe("Sessions", async () => { expect(messages.length).toBeGreaterThan(0); // We should be able to send another message - await session.send({ prompt: "What is 2+2?" }); - const answer = await getFinalAssistantMessage(session); - expect(answer.data.content).toContain("4"); + const answer = await session.sendAndWait({ prompt: "What is 2+2?" }); + expect(answer?.data.content).toContain("4"); }); it("should receive streaming delta events when streaming is enabled", async () => { @@ -270,8 +265,7 @@ describe("Sessions", async () => { } }); - await session.send({ prompt: "What is 2+2?" }); - const assistantMessage = await getFinalAssistantMessage(session); + const assistantMessage = await session.sendAndWait({ prompt: "What is 2+2?" }); unsubscribe(); @@ -280,10 +274,10 @@ describe("Sessions", async () => { // Accumulated deltas should equal the final message const accumulated = deltaContents.join(""); - expect(accumulated).toBe(assistantMessage.data.content); + expect(accumulated).toBe(assistantMessage?.data.content); // Final message should contain the answer - expect(assistantMessage.data.content).toContain("4"); + expect(assistantMessage?.data.content).toContain("4"); }); it("should pass streaming option to session creation", async () => { @@ -295,34 +289,20 @@ describe("Sessions", async () => { expect(session.sessionId).toMatch(/^[a-f0-9-]+$/); // Session should still work normally - await session.send({ prompt: "What is 1+1?" }); - const assistantMessage = await getFinalAssistantMessage(session); - expect(assistantMessage.data.content).toContain("2"); + const assistantMessage = await session.sendAndWait({ prompt: "What is 1+1?" }); + expect(assistantMessage?.data.content).toContain("2"); }); it("should receive session events", async () => { const session = await client.createSession(); const receivedEvents: Array<{ type: string }> = []; - let idleResolve: () => void; - const idlePromise = new Promise((resolve) => { - idleResolve = resolve; - }); session.on((event) => { receivedEvents.push(event); - if (event.type === "session.idle") { - idleResolve(); - } }); - // Send a message to trigger events - await session.send({ prompt: "What is 100+200?" }); - - // Wait for session to become idle - await Promise.race([ - idlePromise, - new Promise((_, reject) => setTimeout(() => reject(new Error("Timeout")), 60000)), - ]); + // Send a message and wait for completion + const assistantMessage = await session.sendAndWait({ prompt: "What is 100+200?" }); // Should have received multiple events expect(receivedEvents.length).toBeGreaterThan(0); @@ -331,8 +311,7 @@ describe("Sessions", async () => { expect(receivedEvents.some((e) => e.type === "session.idle")).toBe(true); // Verify the assistant response contains the expected answer - const assistantMessage = await getFinalAssistantMessage(session); - expect(assistantMessage.data.content).toContain("300"); + expect(assistantMessage?.data.content).toContain("300"); }); }); @@ -342,3 +321,56 @@ function getSystemMessage(exchange: ParsedHttpExchange): string | undefined { | undefined; return systemMessage?.content; } + +describe("Send Blocking Behavior", async () => { + // Tests for Issue #17: send() should return immediately, not block until turn completes + const { copilotClient: client } = await createSdkTestContext(); + + it("send returns immediately while events stream in background", async () => { + const session = await client.createSession(); + + const events: string[] = []; + session.on((event) => { + events.push(event.type); + }); + + // Use a slow command so we can verify send() returns before completion + await session.send({ prompt: "Run 'sleep 2 && echo done'" }); + + // send() should return before turn completes (no session.idle yet) + expect(events).not.toContain("session.idle"); + + // Wait for turn to complete + const message = await getFinalAssistantMessage(session); + + expect(message.data.content).toContain("done"); + expect(events).toContain("session.idle"); + expect(events).toContain("assistant.message"); + }); + + it("sendAndWait blocks until session.idle and returns final assistant message", async () => { + const session = await client.createSession(); + + const events: string[] = []; + session.on((event) => { + events.push(event.type); + }); + + const response = await session.sendAndWait({ prompt: "What is 2+2?" }); + + expect(response).toBeDefined(); + expect(response?.type).toBe("assistant.message"); + expect(response?.data.content).toContain("4"); + expect(events).toContain("session.idle"); + expect(events).toContain("assistant.message"); + }); + + it("sendAndWait throws on timeout", async () => { + const session = await client.createSession(); + + // Use a slow command to ensure timeout triggers before completion + await expect( + session.sendAndWait({ prompt: "Run 'sleep 2 && echo done'" }, 100) + ).rejects.toThrow(/Timeout after 100ms/); + }); +}); diff --git a/nodejs/test/e2e/tools.test.ts b/nodejs/test/e2e/tools.test.ts index ede9d02..85960b8 100644 --- a/nodejs/test/e2e/tools.test.ts +++ b/nodejs/test/e2e/tools.test.ts @@ -8,7 +8,6 @@ import { assert, describe, expect, it } from "vitest"; import { z } from "zod"; import { defineTool } from "../../src/index.js"; import { createSdkTestContext } from "./harness/sdkTestContext"; -import { getFinalAssistantMessage } from "./harness/sdkTestHelper"; describe("Custom tools", async () => { const { copilotClient: client, openAiEndpoint, workDir } = await createSdkTestContext(); @@ -17,8 +16,9 @@ describe("Custom tools", async () => { await writeFile(join(workDir, "README.md"), "# ELIZA, the only chatbot you'll ever need"); const session = await client.createSession(); - await session.send({ prompt: "What's the first line of README.md in this directory?" }); - const assistantMessage = await getFinalAssistantMessage(session); + const assistantMessage = await session.sendAndWait({ + prompt: "What's the first line of README.md in this directory?", + }); expect(assistantMessage?.data.content).toContain("ELIZA"); }); @@ -35,8 +35,9 @@ describe("Custom tools", async () => { ], }); - await session.send({ prompt: "Use encrypt_string to encrypt this string: Hello" }); - const assistantMessage = await getFinalAssistantMessage(session); + const assistantMessage = await session.sendAndWait({ + prompt: "Use encrypt_string to encrypt this string: Hello", + }); expect(assistantMessage?.data.content).toContain("HELLO"); }); @@ -52,10 +53,9 @@ describe("Custom tools", async () => { ], }); - await session.send({ + const answer = await session.sendAndWait({ prompt: "What is my location? If you can't find out, just say 'unknown'.", }); - const answer = await getFinalAssistantMessage(session); // Check the underlying traffic const traffic = await openAiEndpoint.getExchanges(); @@ -108,13 +108,12 @@ describe("Custom tools", async () => { ], }); - await session.send({ + const assistantMessage = await session.sendAndWait({ prompt: "Perform a DB query for the 'cities' table using IDs 12 and 19, sorting ascending. " + "Reply only with lines of the form: [cityname] [population]", }); - const assistantMessage = await getFinalAssistantMessage(session); const responseContent = assistantMessage?.data.content!; expect(assistantMessage).not.toBeNull(); expect(responseContent).not.toBe(""); diff --git a/python/copilot/generated/session_events.py b/python/copilot/generated/session_events.py index 86d27da..73acf72 100644 --- a/python/copilot/generated/session_events.py +++ b/python/copilot/generated/session_events.py @@ -655,6 +655,7 @@ class SessionEventType(Enum): TOOL_EXECUTION_START = "tool.execution_start" TOOL_USER_REQUESTED = "tool.user_requested" USER_MESSAGE = "user.message" + UNKNOWN = "unknown" # For forward compatibility with new event types @dataclass @@ -672,7 +673,10 @@ def from_dict(obj: Any) -> 'SessionEvent': data = Data.from_dict(obj.get("data")) id = UUID(obj.get("id")) timestamp = from_datetime(obj.get("timestamp")) - type = SessionEventType(obj.get("type")) + try: + type = SessionEventType(obj.get("type")) + except ValueError: + type = SessionEventType.UNKNOWN # Forward compatibility ephemeral = from_union([from_bool, from_none], obj.get("ephemeral")) parent_id = from_union([from_none, lambda x: UUID(x)], obj.get("parentId")) return SessionEvent(data, id, timestamp, type, ephemeral, parent_id) diff --git a/python/copilot/session.py b/python/copilot/session.py index e232dd9..0640964 100644 --- a/python/copilot/session.py +++ b/python/copilot/session.py @@ -5,18 +5,21 @@ conversation sessions with the Copilot CLI. """ +import asyncio import inspect import threading from typing import Any, Callable, Dict, List, Optional, Set -from .generated.session_events import session_event_from_dict +from .generated.session_events import SessionEvent, SessionEventType, session_event_from_dict from .types import ( MessageOptions, PermissionHandler, - SessionEvent, Tool, ToolHandler, ) +from .types import ( + SessionEvent as SessionEventTypeAlias, +) class CopilotSession: @@ -101,6 +104,67 @@ async def send(self, options: MessageOptions) -> str: ) return response["messageId"] + async def send_and_wait( + self, options: MessageOptions, timeout: Optional[float] = None + ) -> Optional[SessionEvent]: + """ + Send a message to this session and wait until the session becomes idle. + + This is a convenience method that combines :meth:`send` with waiting for + the session.idle event. Use this when you want to block until the assistant + has finished processing the message. + + Events are still delivered to handlers registered via :meth:`on` while waiting. + + Args: + options: Message options including the prompt and optional attachments. + timeout: Timeout in seconds (default: 60). Controls how long to wait; + does not abort in-flight agent work. + + Returns: + The final assistant message event, or None if none was received. + + Raises: + asyncio.TimeoutError: If the timeout is reached before session becomes idle. + Exception: If the session has been destroyed or the connection fails. + + Example: + >>> response = await session.send_and_wait({"prompt": "What is 2+2?"}) + >>> if response: + ... print(response.data.content) + """ + effective_timeout = timeout if timeout is not None else 60.0 + + idle_event = asyncio.Event() + error_event: Optional[Exception] = None + last_assistant_message: Optional[SessionEvent] = None + + def handler(event: SessionEventTypeAlias) -> None: + nonlocal last_assistant_message, error_event + if event.type == SessionEventType.ASSISTANT_MESSAGE: + last_assistant_message = event + elif event.type == SessionEventType.SESSION_IDLE: + idle_event.set() + elif event.type == SessionEventType.SESSION_ERROR: + error_event = Exception( + f"Session error: {getattr(event.data, 'message', str(event.data))}" + ) + idle_event.set() + + unsubscribe = self.on(handler) + try: + await self.send(options) + await asyncio.wait_for(idle_event.wait(), timeout=effective_timeout) + if error_event: + raise error_event + return last_assistant_message + except asyncio.TimeoutError: + raise asyncio.TimeoutError( + f"Timeout after {effective_timeout}s waiting for session.idle" + ) + finally: + unsubscribe() + def on(self, handler: Callable[[SessionEvent], None]) -> Callable[[], None]: """ Subscribe to events from this session. diff --git a/python/e2e/test_mcp_and_agents.py b/python/e2e/test_mcp_and_agents.py index 9db515a..95738d5 100644 --- a/python/e2e/test_mcp_and_agents.py +++ b/python/e2e/test_mcp_and_agents.py @@ -28,8 +28,8 @@ async def test_accept_mcp_server_config_on_create(self, ctx: E2ETestContext): assert session.session_id is not None # Simple interaction to verify session works - await session.send({"prompt": "What is 2+2?"}) - message = await get_final_assistant_message(session) + message = await session.send_and_wait({"prompt": "What is 2+2?"}) + assert message is not None assert "4" in message.data.content await session.destroy() @@ -39,8 +39,7 @@ async def test_accept_mcp_server_config_on_resume(self, ctx: E2ETestContext): # Create a session first session1 = await ctx.client.create_session() session_id = session1.session_id - await session1.send({"prompt": "What is 1+1?"}) - await get_final_assistant_message(session1) + await session1.send_and_wait({"prompt": "What is 1+1?"}) # Resume with MCP servers mcp_servers: dict[str, MCPServerConfig] = { @@ -56,8 +55,8 @@ async def test_accept_mcp_server_config_on_resume(self, ctx: E2ETestContext): assert session2.session_id == session_id - await session2.send({"prompt": "What is 3+3?"}) - message = await get_final_assistant_message(session2) + message = await session2.send_and_wait({"prompt": "What is 3+3?"}) + assert message is not None assert "6" in message.data.content await session2.destroy() @@ -103,8 +102,8 @@ async def test_accept_custom_agent_config_on_create(self, ctx: E2ETestContext): assert session.session_id is not None # Simple interaction to verify session works - await session.send({"prompt": "What is 5+5?"}) - message = await get_final_assistant_message(session) + message = await session.send_and_wait({"prompt": "What is 5+5?"}) + assert message is not None assert "10" in message.data.content await session.destroy() @@ -114,8 +113,7 @@ async def test_accept_custom_agent_config_on_resume(self, ctx: E2ETestContext): # Create a session first session1 = await ctx.client.create_session() session_id = session1.session_id - await session1.send({"prompt": "What is 1+1?"}) - await get_final_assistant_message(session1) + await session1.send_and_wait({"prompt": "What is 1+1?"}) # Resume with custom agents custom_agents: list[CustomAgentConfig] = [ @@ -131,8 +129,8 @@ async def test_accept_custom_agent_config_on_resume(self, ctx: E2ETestContext): assert session2.session_id == session_id - await session2.send({"prompt": "What is 6+6?"}) - message = await get_final_assistant_message(session2) + message = await session2.send_and_wait({"prompt": "What is 6+6?"}) + assert message is not None assert "12" in message.data.content await session2.destroy() diff --git a/python/e2e/test_permissions.py b/python/e2e/test_permissions.py index 7e2502e..d8543d4 100644 --- a/python/e2e/test_permissions.py +++ b/python/e2e/test_permissions.py @@ -8,7 +8,7 @@ from copilot import PermissionRequest, PermissionRequestResult -from .testharness import E2ETestContext, get_final_assistant_message +from .testharness import E2ETestContext from .testharness.helper import read_file, write_file pytestmark = pytest.mark.asyncio(loop_scope="module") @@ -31,8 +31,9 @@ def on_permission_request( write_file(ctx.work_dir, "test.txt", "original content") - await session.send({"prompt": "Edit test.txt and replace 'original' with 'modified'"}) - await get_final_assistant_message(session) + await session.send_and_wait( + {"prompt": "Edit test.txt and replace 'original' with 'modified'"} + ) # Should have received at least one permission request assert len(permission_requests) > 0 @@ -56,8 +57,7 @@ def on_permission_request( session = await ctx.client.create_session({"on_permission_request": on_permission_request}) - await session.send({"prompt": "Run 'echo hello' and tell me the output"}) - await get_final_assistant_message(session) + await session.send_and_wait({"prompt": "Run 'echo hello world' and tell me the output"}) # Should have received at least one shell permission request shell_requests = [req for req in permission_requests if req.get("kind") == "shell"] @@ -79,8 +79,9 @@ def on_permission_request( original_content = "protected content" write_file(ctx.work_dir, "protected.txt", original_content) - await session.send({"prompt": "Edit protected.txt and replace 'protected' with 'hacked'."}) - await get_final_assistant_message(session) + await session.send_and_wait( + {"prompt": "Edit protected.txt and replace 'protected' with 'hacked'."} + ) # Verify the file was NOT modified content = read_file(ctx.work_dir, "protected.txt") @@ -93,9 +94,9 @@ async def test_without_permission_handler(self, ctx: E2ETestContext): # Create session without on_permission_request handler session = await ctx.client.create_session() - await session.send({"prompt": "What is 2+2?"}) - message = await get_final_assistant_message(session) + message = await session.send_and_wait({"prompt": "What is 2+2?"}) + assert message is not None assert "4" in message.data.content await session.destroy() @@ -114,8 +115,7 @@ async def on_permission_request( session = await ctx.client.create_session({"on_permission_request": on_permission_request}) - await session.send({"prompt": "Run 'echo test' and tell me what happens"}) - await get_final_assistant_message(session) + await session.send_and_wait({"prompt": "Run 'echo test' and tell me what happens"}) assert len(permission_requests) > 0 @@ -128,8 +128,7 @@ async def test_resume_session_with_permission_handler(self, ctx: E2ETestContext) # Create session without permission handler session1 = await ctx.client.create_session() session_id = session1.session_id - await session1.send({"prompt": "What is 1+1?"}) - await get_final_assistant_message(session1) + await session1.send_and_wait({"prompt": "What is 1+1?"}) # Resume with permission handler def on_permission_request( @@ -142,8 +141,7 @@ def on_permission_request( session_id, {"on_permission_request": on_permission_request} ) - await session2.send({"prompt": "Run 'echo resumed' for me"}) - await get_final_assistant_message(session2) + await session2.send_and_wait({"prompt": "Run 'echo resumed' for me"}) # Should have permission requests from resumed session assert len(permission_requests) > 0 @@ -160,10 +158,12 @@ def on_permission_request( session = await ctx.client.create_session({"on_permission_request": on_permission_request}) - await session.send({"prompt": "Run 'echo test'. If you can't, say 'failed'."}) - message = await get_final_assistant_message(session) + message = await session.send_and_wait( + {"prompt": "Run 'echo test'. If you can't, say 'failed'."} + ) # Should handle the error and deny permission + assert message is not None content_lower = message.data.content.lower() assert any(word in content_lower for word in ["fail", "cannot", "unable", "permission"]) @@ -185,8 +185,7 @@ def on_permission_request( session = await ctx.client.create_session({"on_permission_request": on_permission_request}) - await session.send({"prompt": "Run 'echo test'"}) - await get_final_assistant_message(session) + await session.send_and_wait({"prompt": "Run 'echo test'"}) assert received_tool_call_id diff --git a/python/e2e/test_session.py b/python/e2e/test_session.py index 30d24f6..e54465e 100644 --- a/python/e2e/test_session.py +++ b/python/e2e/test_session.py @@ -29,12 +29,14 @@ async def test_should_create_and_destroy_sessions(self, ctx: E2ETestContext): async def test_should_have_stateful_conversation(self, ctx: E2ETestContext): session = await ctx.client.create_session() - await session.send({"prompt": "What is 1+1?"}) - assistant_message = await get_final_assistant_message(session) + assistant_message = await session.send_and_wait({"prompt": "What is 1+1?"}) + assert assistant_message is not None assert "2" in assistant_message.data.content - await session.send({"prompt": "Now if you double that, what do you get?"}) - second_message = await get_final_assistant_message(session) + second_message = await session.send_and_wait( + {"prompt": "Now if you double that, what do you get?"} + ) + assert second_message is not None assert "4" in second_message.data.content async def test_should_create_a_session_with_appended_systemMessage_config( @@ -137,8 +139,8 @@ async def test_should_resume_a_session_using_the_same_client(self, ctx: E2ETestC # Create initial session session1 = await ctx.client.create_session() session_id = session1.session_id - await session1.send({"prompt": "What is 1+1?"}) - answer = await get_final_assistant_message(session1) + answer = await session1.send_and_wait({"prompt": "What is 1+1?"}) + assert answer is not None assert "2" in answer.data.content # Resume using the same client @@ -151,8 +153,8 @@ async def test_should_resume_a_session_using_a_new_client(self, ctx: E2ETestCont # Create initial session session1 = await ctx.client.create_session() session_id = session1.session_id - await session1.send({"prompt": "What is 1+1?"}) - answer = await get_final_assistant_message(session1) + answer = await session1.send_and_wait({"prompt": "What is 1+1?"}) + assert answer is not None assert "2" in answer.data.content # Resume using a new client @@ -204,8 +206,8 @@ def get_secret_number_handler(invocation): } ) - await session.send({"prompt": "What is the secret number for key ALPHA?"}) - answer = await get_final_assistant_message(session) + answer = await session.send_and_wait({"prompt": "What is the secret number for key ALPHA?"}) + assert answer is not None assert "54321" in answer.data.content async def test_should_create_session_with_custom_provider(self, ctx: E2ETestContext): diff --git a/test/snapshots/permissions/should_handle_async_permission_handler.yaml b/test/snapshots/permissions/should_handle_async_permission_handler.yaml index 75d97fc..f28c96c 100644 --- a/test/snapshots/permissions/should_handle_async_permission_handler.yaml +++ b/test/snapshots/permissions/should_handle_async_permission_handler.yaml @@ -46,4 +46,5 @@ conversations: test - role: assistant - content: The command executed successfully and printed "test" to the console, then exited with code 0 (success). + content: The command successfully executed and printed "test" to the console, then exited with exit code 0 (indicating + success). diff --git a/test/snapshots/session/send_returns_immediately_while_events_stream_in_background.yaml b/test/snapshots/session/send_returns_immediately_while_events_stream_in_background.yaml new file mode 100644 index 0000000..8deef90 --- /dev/null +++ b/test/snapshots/session/send_returns_immediately_while_events_stream_in_background.yaml @@ -0,0 +1,49 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Run 'sleep 2 && echo done' + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Running sleep command"}' + - role: assistant + tool_calls: + - id: toolcall_1 + type: function + function: + name: ${shell} + arguments: '{"command":"sleep 2 && echo done","description":"Run sleep 2 and echo done","initial_wait":5}' + - messages: + - role: system + content: ${system} + - role: user + content: Run 'sleep 2 && echo done' + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Running sleep command"}' + - id: toolcall_1 + type: function + function: + name: ${shell} + arguments: '{"command":"sleep 2 && echo done","description":"Run sleep 2 and echo done","initial_wait":5}' + - role: tool + tool_call_id: toolcall_0 + content: Intent logged + - role: tool + tool_call_id: toolcall_1 + content: |- + done + + - role: assistant + content: The command completed successfully after a 2-second sleep and output "done". diff --git a/test/snapshots/session/sendandwait_blocks_until_session_idle_and_returns_final_assistant_message.yaml b/test/snapshots/session/sendandwait_blocks_until_session_idle_and_returns_final_assistant_message.yaml new file mode 100644 index 0000000..9fe2fcd --- /dev/null +++ b/test/snapshots/session/sendandwait_blocks_until_session_idle_and_returns_final_assistant_message.yaml @@ -0,0 +1,10 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: What is 2+2? + - role: assistant + content: 2 + 2 = 4