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 5ed3188..f242b97 100644 --- a/dotnet/src/Generated/SessionEvents.cs +++ b/dotnet/src/Generated/SessionEvents.cs @@ -77,7 +77,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..bb7a7ea 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())) + .Where(e => e != null) + .ToList()!; } /// diff --git a/dotnet/test/Harness/CapiProxy.cs b/dotnet/test/Harness/CapiProxy.cs index 18c97a4..dd7ba31 100644 --- a/dotnet/test/Harness/CapiProxy.cs +++ b/dotnet/test/Harness/CapiProxy.cs @@ -84,15 +84,16 @@ async Task StartCoreAsync() } } - public async Task StopAsync() + public async Task StopAsync(bool skipWritingCache = false) { if (_startupTask != null) { try { var url = await _startupTask; + var stopUrl = skipWritingCache ? $"{url}/stop?skipWritingCache=true" : $"{url}/stop"; using var client = new HttpClient(); - await client.PostAsync($"{url}/stop", null); + await client.PostAsync(stopUrl, null); } catch { /* Best effort */ } } diff --git a/dotnet/test/Harness/E2ETestContext.cs b/dotnet/test/Harness/E2ETestContext.cs index dcecc04..d9d47a4 100644 --- a/dotnet/test/Harness/E2ETestContext.cs +++ b/dotnet/test/Harness/E2ETestContext.cs @@ -101,7 +101,9 @@ public IReadOnlyDictionary GetEnvironment() public async ValueTask DisposeAsync() { - await _proxy.DisposeAsync(); + // Skip writing snapshots in CI to avoid corrupting them on test failures + var isCI = !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CI")); + await _proxy.StopAsync(skipWritingCache: isCI); try { if (Directory.Exists(HomeDir)) Directory.Delete(HomeDir, true); } catch { } try { if (Directory.Exists(WorkDir)) Directory.Delete(WorkDir, true); } catch { } 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..d90b451 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,53 @@ 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)); + + await session.SendAsync(new MessageOptions { Prompt = "What is 1+1?" }); + + // send() 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("2", 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(); + + var ex = await Assert.ThrowsAsync(() => + session.SendAndWaitAsync(new MessageOptions { Prompt = "What is 3+3?" }, TimeSpan.FromMilliseconds(1))); + + 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 fa5cae1..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{ + _, 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/e2e/testharness/context.go b/go/e2e/testharness/context.go index b8cc3a3..dbc89c5 100644 --- a/go/e2e/testharness/context.go +++ b/go/e2e/testharness/context.go @@ -42,7 +42,8 @@ type TestContext struct { WorkDir string ProxyURL string - proxy *CapiProxy + proxy *CapiProxy + testFailed bool } // NewTestContext creates a new test context with isolated directories and a replaying proxy. @@ -82,7 +83,7 @@ func NewTestContext(t *testing.T) *TestContext { } t.Cleanup(func() { - ctx.Close() + ctx.Close(t.Failed()) }) return ctx @@ -113,9 +114,9 @@ func (c *TestContext) ConfigureForTest(t *testing.T) { } // Close cleans up the test context resources. -func (c *TestContext) Close() { +func (c *TestContext) Close(testFailed bool) { if c.proxy != nil { - c.proxy.Stop() + c.proxy.StopWithOptions(testFailed) } if c.HomeDir != "" { os.RemoveAll(c.HomeDir) diff --git a/go/e2e/testharness/proxy.go b/go/e2e/testharness/proxy.go index 71f4dc9..298700e 100644 --- a/go/e2e/testharness/proxy.go +++ b/go/e2e/testharness/proxy.go @@ -75,6 +75,12 @@ func (p *CapiProxy) Start() (string, error) { // Stop gracefully shuts down the proxy server. func (p *CapiProxy) Stop() error { + return p.StopWithOptions(false) +} + +// StopWithOptions gracefully shuts down the proxy server. +// If skipWritingCache is true, the proxy won't write captured exchanges to disk. +func (p *CapiProxy) StopWithOptions(skipWritingCache bool) error { p.mu.Lock() defer p.mu.Unlock() @@ -84,8 +90,12 @@ func (p *CapiProxy) Stop() error { // Send stop request to the server if p.proxyURL != "" { + stopURL := p.proxyURL + "/stop" + if skipWritingCache { + stopURL += "?skipWritingCache=true" + } // Best effort - ignore errors - resp, err := http.Post(p.proxyURL+"/stop", "application/json", nil) + resp, err := http.Post(stopURL, "application/json", nil) if err == nil { resp.Body.Close() } diff --git a/go/session.go b/go/session.go index b34fe6e..9d6380e 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,85 @@ 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) { + if event.Type == generated.AssistantMessage { + mu.Lock() + eventCopy := event + lastAssistantMessage = &eventCopy + mu.Unlock() + } else if event.Type == generated.SessionIdle { + select { + case idleCh <- struct{}{}: + default: + } + } else if event.Type == 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..75c3fee 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/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..2389d4b 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,72 @@ 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/harness/CapiProxy.ts b/nodejs/test/e2e/harness/CapiProxy.ts index dee498d..f08ffc5 100644 --- a/nodejs/test/e2e/harness/CapiProxy.ts +++ b/nodejs/test/e2e/harness/CapiProxy.ts @@ -43,8 +43,11 @@ export class CapiProxy { return await response.json(); } - async stop(): Promise { - const response = await fetch(`${this.proxyUrl}/stop`, { method: "POST" }); + async stop(skipWritingCache?: boolean): Promise { + const url = skipWritingCache + ? `${this.proxyUrl}/stop?skipWritingCache=true` + : `${this.proxyUrl}/stop`; + const response = await fetch(url, { method: "POST" }); expect(response.ok).toBe(true); } } diff --git a/nodejs/test/e2e/harness/sdkTestContext.ts b/nodejs/test/e2e/harness/sdkTestContext.ts index abb0a99..4df9f01 100644 --- a/nodejs/test/e2e/harness/sdkTestContext.ts +++ b/nodejs/test/e2e/harness/sdkTestContext.ts @@ -8,7 +8,7 @@ import os from "os"; import { basename, dirname, join, resolve } from "path"; import { rimraf } from "rimraf"; import { fileURLToPath } from "url"; -import { afterAll, afterEach, beforeEach, TestContext } from "vitest"; +import { afterAll, afterEach, beforeEach, onTestFailed, TestContext } from "vitest"; import { CopilotClient } from "../../../src"; import { CapiProxy } from "./CapiProxy"; import { retry } from "./sdkTestHelper"; @@ -17,7 +17,7 @@ const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const SNAPSHOTS_DIR = resolve(__dirname, "../../../../test/snapshots"); -export const CLI_PATH = resolve(__dirname, "../../../node_modules/@github/copilot/index.js"); +export const CLI_PATH = process.env.COPILOT_CLI_PATH || resolve(__dirname, "../../../node_modules/@github/copilot/index.js"); export async function createSdkTestContext() { const homeDir = realpathSync(fs.mkdtempSync(join(os.tmpdir(), "copilot-test-config-"))); @@ -44,8 +44,15 @@ export async function createSdkTestContext() { const harness = { homeDir, workDir, openAiEndpoint, copilotClient, env }; + // Track if any test fails to avoid writing corrupted snapshots + let anyTestFailed = false; + // Wire up to Vitest lifecycle beforeEach(async (testContext) => { + onTestFailed(() => { + anyTestFailed = true; + }); + await openAiEndpoint.updateConfig({ filePath: getTrafficCapturePath(testContext), workDir, @@ -63,7 +70,7 @@ export async function createSdkTestContext() { afterAll(async () => { await copilotClient.stop(); - await openAiEndpoint.stop(); + await openAiEndpoint.stop(anyTestFailed); await rmDir("remove e2e test homeDir", homeDir); await rmDir("remove e2e test workDir", workDir); }); 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..42aaab4 100644 --- a/nodejs/test/e2e/session.test.ts +++ b/nodejs/test/e2e/session.test.ts @@ -24,13 +24,11 @@ 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 +40,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 +57,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 +72,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 +87,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 +135,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 +203,8 @@ 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 +227,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 +237,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 +261,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 +270,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 +285,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 +307,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 +317,54 @@ 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); + }); + + await session.send({ prompt: "What is 1+1?" }); + + // 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("2"); + 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(); + + await expect(session.sendAndWait({ prompt: "What is 3+3?" }, 1)).rejects.toThrow( + /Timeout after 1ms/ + ); + }); +}); diff --git a/nodejs/test/e2e/tools.test.ts b/nodejs/test/e2e/tools.test.ts index ede9d02..89f17c7 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,7 @@ 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 +33,7 @@ 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 +49,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 +104,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 36791c1..365d5a4 100644 --- a/python/copilot/generated/session_events.py +++ b/python/copilot/generated/session_events.py @@ -646,6 +646,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 @@ -663,7 +664,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..b258abb 100644 --- a/python/copilot/session.py +++ b/python/copilot/session.py @@ -5,15 +5,16 @@ 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, + SessionEvent as SessionEventTypeAlias, Tool, ToolHandler, ) @@ -101,6 +102,65 @@ 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/conftest.py b/python/e2e/conftest.py index d7e7717..567a333 100644 --- a/python/e2e/conftest.py +++ b/python/e2e/conftest.py @@ -1,17 +1,34 @@ """Shared pytest fixtures for e2e tests.""" +import pytest import pytest_asyncio from .testharness import E2ETestContext +# Track if any test failed to avoid writing corrupted snapshots +_any_test_failed = False + + +@pytest.hookimpl(tryfirst=True, hookwrapper=True) +def pytest_runtest_makereport(item, call): + """Track test failures to avoid writing corrupted snapshots.""" + global _any_test_failed + outcome = yield + rep = outcome.get_result() + if rep.when == "call" and rep.failed: + _any_test_failed = True + + @pytest_asyncio.fixture(scope="module", loop_scope="module") async def ctx(): """Create and teardown a test context shared across all tests in this module.""" + global _any_test_failed + _any_test_failed = False # Reset for each module context = E2ETestContext() await context.setup() yield context - await context.teardown() + await context.teardown(test_failed=_any_test_failed) @pytest_asyncio.fixture(autouse=True, loop_scope="module") 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 c585ee0..bde5745 100644 --- a/python/e2e/test_permissions.py +++ b/python/e2e/test_permissions.py @@ -31,8 +31,7 @@ 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 +55,7 @@ def on_permission_request( session = await ctx.client.create_session({"on_permission_request": on_permission_request}) - await session.send({"prompt": "Run 'echo hello world' 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 +77,7 @@ 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 +90,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 +111,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 +124,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 +137,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 +154,10 @@ 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 +179,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..40e3e92 100644 --- a/python/e2e/test_session.py +++ b/python/e2e/test_session.py @@ -29,12 +29,12 @@ 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 +137,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 +151,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 +204,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/python/e2e/testharness/context.py b/python/e2e/testharness/context.py index b1150a0..4bb7153 100644 --- a/python/e2e/testharness/context.py +++ b/python/e2e/testharness/context.py @@ -73,14 +73,18 @@ async def setup(self): } ) - async def teardown(self): - """Clean up the test context.""" + async def teardown(self, test_failed: bool = False): + """Clean up the test context. + + Args: + test_failed: If True, skip writing snapshots to avoid corruption. + """ if self._client: await self._client.stop() self._client = None if self._proxy: - await self._proxy.stop() + await self._proxy.stop(skip_writing_cache=test_failed) self._proxy = None if self.home_dir and os.path.exists(self.home_dir): diff --git a/python/e2e/testharness/proxy.py b/python/e2e/testharness/proxy.py index f0fd9a4..0c2311c 100644 --- a/python/e2e/testharness/proxy.py +++ b/python/e2e/testharness/proxy.py @@ -59,16 +59,23 @@ async def start(self) -> str: self._proxy_url = match.group(1) return self._proxy_url - async def stop(self): - """Gracefully shut down the proxy server.""" + async def stop(self, skip_writing_cache: bool = False): + """Gracefully shut down the proxy server. + + Args: + skip_writing_cache: If True, the proxy won't write captured exchanges to disk. + """ if not self._process: return # Send stop request to the server if self._proxy_url: try: + stop_url = f"{self._proxy_url}/stop" + if skip_writing_cache: + stop_url += "?skipWritingCache=true" async with httpx.AsyncClient() as client: - await client.post(f"{self._proxy_url}/stop") + await client.post(stop_url) except Exception: pass # Best effort diff --git a/test/harness/replayingCapiProxy.ts b/test/harness/replayingCapiProxy.ts index ba8df91..b48a5b5 100644 --- a/test/harness/replayingCapiProxy.ts +++ b/test/harness/replayingCapiProxy.ts @@ -146,12 +146,13 @@ export class ReplayingCapiProxy extends CapturingHttpProxy { // Handle /stop endpoint for stopping the proxy if ( - options.requestOptions.path === "/stop" && + options.requestOptions.path?.startsWith("/stop") && options.requestOptions.method === "POST" ) { + const skipWritingCache = options.requestOptions.path.includes("skipWritingCache=true"); options.onResponseStart(200, {}); options.onResponseEnd(); - await this.stop(); + await this.stop(skipWritingCache); process.exit(0); } diff --git a/test/snapshots/mcp-and-agents/should_accept_custom_agent_configuration_on_session_resume.yaml b/test/snapshots/mcp-and-agents/should_accept_custom_agent_configuration_on_session_resume.yaml index 16db486..9703495 100644 --- a/test/snapshots/mcp-and-agents/should_accept_custom_agent_configuration_on_session_resume.yaml +++ b/test/snapshots/mcp-and-agents/should_accept_custom_agent_configuration_on_session_resume.yaml @@ -7,8 +7,8 @@ conversations: - role: user content: What is 1+1? - role: assistant - content: 1 + 1 = 2 + content: 1+1 equals 2. - role: user content: What is 6+6? - role: assistant - content: 6 + 6 = 12 + content: 6+6 equals 12. diff --git a/test/snapshots/permissions/async_permission_handler.yaml b/test/snapshots/permissions/async_permission_handler.yaml index 38cbf14..5cc63c0 100644 --- a/test/snapshots/permissions/async_permission_handler.yaml +++ b/test/snapshots/permissions/async_permission_handler.yaml @@ -19,7 +19,24 @@ conversations: type: function function: name: ${shell} - arguments: '{"command":"echo test","description":"Run echo test"}' + arguments: '{"command":"echo test","description":"Run echo test command"}' + - messages: + - role: system + content: ${system} + - role: user + content: Run 'echo test' and tell me what happens + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Running echo command"}' + - id: toolcall_1 + type: function + function: + name: ${shell} + arguments: '{"command":"echo test","description":"Run echo test command"}' - role: tool tool_call_id: toolcall_0 content: Intent logged @@ -29,4 +46,5 @@ conversations: test - role: assistant - content: The command printed "test" to the console and exited successfully with exit code 0. + content: The command successfully executed and outputted "test" to the console, then exited with code 0 (indicating + success). diff --git a/test/snapshots/permissions/permission_handler_errors.yaml b/test/snapshots/permissions/permission_handler_errors.yaml index 8b3467f..cee78a0 100644 --- a/test/snapshots/permissions/permission_handler_errors.yaml +++ b/test/snapshots/permissions/permission_handler_errors.yaml @@ -20,6 +20,23 @@ conversations: function: name: ${shell} arguments: '{"command":"echo test","description":"Run echo test"}' + - messages: + - role: system + content: ${system} + - role: user + content: Run 'echo test'. If you can't, say 'failed'. + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Running echo command"}' + - id: toolcall_1 + type: function + function: + name: ${shell} + arguments: '{"command":"echo test","description":"Run echo test"}' - role: tool tool_call_id: toolcall_0 content: Intent logged diff --git a/test/snapshots/permissions/permission_handler_for_shell_commands.yaml b/test/snapshots/permissions/permission_handler_for_shell_commands.yaml index e034afe..067c2da 100644 --- a/test/snapshots/permissions/permission_handler_for_shell_commands.yaml +++ b/test/snapshots/permissions/permission_handler_for_shell_commands.yaml @@ -20,13 +20,44 @@ conversations: function: name: ${shell} arguments: '{"command":"echo hello world","description":"Run echo hello world"}' + - messages: + - role: system + content: ${system} + - role: user + content: Run 'echo hello world' and tell me the output + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Running echo command"}' + - id: toolcall_1 + type: function + function: + name: ${shell} + arguments: '{"command":"echo hello world","description":"Run echo hello world"}' - role: tool tool_call_id: toolcall_0 content: Intent logged - role: tool tool_call_id: toolcall_1 content: |- - hello world + hello + world - role: assistant - content: "The output is: **hello world**" + content: >- + The output is: + + ``` + + hello + + world + + ``` + + + Note: In PowerShell, `echo` treats each argument separately, so "hello" and "world" are printed on separate + lines. The command completed successfully with exit code 0. diff --git a/test/snapshots/permissions/permission_handler_for_write_operations.yaml b/test/snapshots/permissions/permission_handler_for_write_operations.yaml index f5a75a5..a086398 100644 --- a/test/snapshots/permissions/permission_handler_for_write_operations.yaml +++ b/test/snapshots/permissions/permission_handler_for_write_operations.yaml @@ -6,6 +6,8 @@ conversations: content: ${system} - role: user content: Edit test.txt and replace 'original' with 'modified' + - role: assistant + content: I'll view the file first to see its contents, then make the replacement. - role: assistant tool_calls: - id: toolcall_0 @@ -20,6 +22,24 @@ conversations: function: name: view arguments: '{"path":"${workdir}/test.txt"}' + - messages: + - role: system + content: ${system} + - role: user + content: Edit test.txt and replace 'original' with 'modified' + - role: assistant + content: I'll view the file first to see its contents, then make the replacement. + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Editing test.txt file"}' + - id: toolcall_1 + type: function + function: + name: view + arguments: '{"path":"${workdir}/test.txt"}' - role: tool tool_call_id: toolcall_0 content: Intent logged @@ -37,4 +57,4 @@ conversations: tool_call_id: toolcall_2 content: File ${workdir}/test.txt updated with changes. - role: assistant - content: Done! Replaced 'original' with 'modified' in test.txt. + content: Done! I've replaced 'original' with 'modified' in test.txt. diff --git a/test/snapshots/permissions/resume_session_with_permission_handler.yaml b/test/snapshots/permissions/resume_session_with_permission_handler.yaml index df6b2ed..3eb0555 100644 --- a/test/snapshots/permissions/resume_session_with_permission_handler.yaml +++ b/test/snapshots/permissions/resume_session_with_permission_handler.yaml @@ -7,7 +7,7 @@ conversations: - role: user content: What is 1+1? - role: assistant - content: 1 + 1 = 2 + content: 1+1 equals 2. - role: user content: Run 'echo resumed' for me - role: assistant @@ -24,6 +24,27 @@ conversations: function: name: ${shell} arguments: '{"command":"echo resumed","description":"Run echo resumed"}' + - messages: + - role: system + content: ${system} + - role: user + content: What is 1+1? + - role: assistant + content: 1+1 equals 2. + - role: user + content: Run 'echo resumed' for me + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Running echo command"}' + - id: toolcall_1 + type: function + function: + name: ${shell} + arguments: '{"command":"echo resumed","description":"Run echo resumed"}' - role: tool tool_call_id: toolcall_0 content: Intent logged @@ -33,4 +54,4 @@ conversations: resumed - role: assistant - content: "The command executed successfully and output: `resumed`" + content: 'Command executed successfully - output: "resumed"' diff --git a/test/snapshots/permissions/should_handle_async_permission_handler.yaml b/test/snapshots/permissions/should_handle_async_permission_handler.yaml index 3280fc9..75d97fc 100644 --- a/test/snapshots/permissions/should_handle_async_permission_handler.yaml +++ b/test/snapshots/permissions/should_handle_async_permission_handler.yaml @@ -6,8 +6,6 @@ conversations: content: ${system} - role: user content: Run 'echo test' and tell me what happens - - role: assistant - content: I'll run the echo command for you. - role: assistant tool_calls: - id: toolcall_0 @@ -22,6 +20,23 @@ conversations: function: name: ${shell} arguments: '{"command":"echo test","description":"Run echo test"}' + - messages: + - role: system + content: ${system} + - role: user + content: Run 'echo test' and tell me what happens + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Running echo command"}' + - id: toolcall_1 + type: function + function: + name: ${shell} + arguments: '{"command":"echo test","description":"Run echo test"}' - role: tool tool_call_id: toolcall_0 content: Intent logged @@ -31,5 +46,4 @@ conversations: test - role: assistant - content: The command executed successfully and printed "test" to the output, then exited with code 0 (indicating - success). + content: The command executed successfully and printed "test" to the console, then exited with code 0 (success). diff --git a/test/snapshots/permissions/should_handle_permission_handler_errors_gracefully.yaml b/test/snapshots/permissions/should_handle_permission_handler_errors_gracefully.yaml index c12f966..30b556f 100644 --- a/test/snapshots/permissions/should_handle_permission_handler_errors_gracefully.yaml +++ b/test/snapshots/permissions/should_handle_permission_handler_errors_gracefully.yaml @@ -20,6 +20,23 @@ conversations: function: name: ${shell} arguments: '{"command":"echo test","description":"Run echo test command"}' + - messages: + - role: system + content: ${system} + - role: user + content: Run 'echo test'. If you can't, say 'failed'. + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Running echo command"}' + - id: toolcall_1 + type: function + function: + name: ${shell} + arguments: '{"command":"echo test","description":"Run echo test command"}' - role: tool tool_call_id: toolcall_0 content: Intent logged diff --git a/test/snapshots/permissions/should_invoke_permission_handler_for_write_operations.yaml b/test/snapshots/permissions/should_invoke_permission_handler_for_write_operations.yaml index f5a75a5..3ce2ee4 100644 --- a/test/snapshots/permissions/should_invoke_permission_handler_for_write_operations.yaml +++ b/test/snapshots/permissions/should_invoke_permission_handler_for_write_operations.yaml @@ -20,6 +20,23 @@ conversations: function: name: view arguments: '{"path":"${workdir}/test.txt"}' + - messages: + - role: system + content: ${system} + - role: user + content: Edit test.txt and replace 'original' with 'modified' + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Editing test.txt file"}' + - id: toolcall_1 + type: function + function: + name: view + arguments: '{"path":"${workdir}/test.txt"}' - role: tool tool_call_id: toolcall_0 content: Intent logged @@ -37,4 +54,4 @@ conversations: tool_call_id: toolcall_2 content: File ${workdir}/test.txt updated with changes. - role: assistant - content: Done! Replaced 'original' with 'modified' in test.txt. + content: Done! I've replaced 'original' with 'modified' in test.txt. diff --git a/test/snapshots/permissions/should_receive_toolcallid_in_permission_requests.yaml b/test/snapshots/permissions/should_receive_toolcallid_in_permission_requests.yaml index c95028a..63fde59 100644 --- a/test/snapshots/permissions/should_receive_toolcallid_in_permission_requests.yaml +++ b/test/snapshots/permissions/should_receive_toolcallid_in_permission_requests.yaml @@ -20,6 +20,23 @@ conversations: function: name: ${shell} arguments: '{"command":"echo test","description":"Run echo test"}' + - messages: + - role: system + content: ${system} + - role: user + content: Run 'echo test' + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Running echo command"}' + - id: toolcall_1 + type: function + function: + name: ${shell} + arguments: '{"command":"echo test","description":"Run echo test"}' - role: tool tool_call_id: toolcall_0 content: Intent logged @@ -29,4 +46,4 @@ conversations: test - role: assistant - content: "The command executed successfully and output: **test**" + content: The command executed successfully and output "test". diff --git a/test/snapshots/permissions/should_resume_session_with_permission_handler.yaml b/test/snapshots/permissions/should_resume_session_with_permission_handler.yaml index ef80b03..69a52be 100644 --- a/test/snapshots/permissions/should_resume_session_with_permission_handler.yaml +++ b/test/snapshots/permissions/should_resume_session_with_permission_handler.yaml @@ -7,7 +7,7 @@ conversations: - role: user content: What is 1+1? - role: assistant - content: 1 + 1 = 2 + content: 1+1 = 2 - role: user content: Run 'echo resumed' for me - role: assistant @@ -23,7 +23,28 @@ conversations: type: function function: name: ${shell} - arguments: '{"command":"echo resumed","description":"Run echo resumed"}' + arguments: '{"description":"Run echo resumed","command":"echo resumed"}' + - messages: + - role: system + content: ${system} + - role: user + content: What is 1+1? + - role: assistant + content: 1+1 = 2 + - role: user + content: Run 'echo resumed' for me + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Running echo command"}' + - id: toolcall_1 + type: function + function: + name: ${shell} + arguments: '{"description":"Run echo resumed","command":"echo resumed"}' - role: tool tool_call_id: toolcall_0 content: Intent logged @@ -33,4 +54,4 @@ conversations: resumed - role: assistant - content: The command completed successfully and output "resumed". + content: "The command executed successfully and output: **resumed**" diff --git a/test/snapshots/permissions/tool_call_id_in_permission_requests.yaml b/test/snapshots/permissions/tool_call_id_in_permission_requests.yaml index ebde8aa..3620c2c 100644 --- a/test/snapshots/permissions/tool_call_id_in_permission_requests.yaml +++ b/test/snapshots/permissions/tool_call_id_in_permission_requests.yaml @@ -20,6 +20,23 @@ conversations: function: name: ${shell} arguments: '{"command":"echo test","description":"Run echo test"}' + - messages: + - role: system + content: ${system} + - role: user + content: Run 'echo test' + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Running echo command"}' + - id: toolcall_1 + type: function + function: + name: ${shell} + arguments: '{"command":"echo test","description":"Run echo test"}' - role: tool tool_call_id: toolcall_0 content: Intent logged @@ -29,4 +46,4 @@ conversations: test - role: assistant - content: "Command executed successfully. Output: `test`" + content: "The command executed successfully and output: `test`" 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..2504021 --- /dev/null +++ b/test/snapshots/session/send_returns_immediately_while_events_stream_in_background.yaml @@ -0,0 +1,10 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: What is 1+1? + - role: assistant + content: 1 + 1 = 2 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 diff --git a/test/snapshots/session/should_abort_a_session.yaml b/test/snapshots/session/should_abort_a_session.yaml index a618779..de6c928 100644 --- a/test/snapshots/session/should_abort_a_session.yaml +++ b/test/snapshots/session/should_abort_a_session.yaml @@ -6,9 +6,10 @@ conversations: content: ${system} - role: user content: What is 1+1? - - role: assistant - content: 1 + 1 = 2 - role: user content: What is 2+2? - role: assistant - content: 2 + 2 = 4 + content: |- + 1+1 = 2 + + 2+2 = 4 diff --git a/test/snapshots/session/should_create_a_session_with_appended_systemmessage_config.yaml b/test/snapshots/session/should_create_a_session_with_appended_systemmessage_config.yaml index 3950fb0..3bf4a39 100644 --- a/test/snapshots/session/should_create_a_session_with_appended_systemmessage_config.yaml +++ b/test/snapshots/session/should_create_a_session_with_appended_systemmessage_config.yaml @@ -8,8 +8,8 @@ conversations: content: What is your full name? - role: assistant content: >- - My full name is **GitHub Copilot CLI**. I'm a terminal assistant built by GitHub to help you with software - engineering tasks directly from the command line. + I am the GitHub Copilot CLI, a terminal assistant built by GitHub. I'm an interactive command-line tool + designed to help with software engineering tasks. Have a nice day! diff --git a/test/snapshots/session/should_create_session_with_custom_tool.yaml b/test/snapshots/session/should_create_session_with_custom_tool.yaml index 69f50e6..4ae6dab 100644 --- a/test/snapshots/session/should_create_session_with_custom_tool.yaml +++ b/test/snapshots/session/should_create_session_with_custom_tool.yaml @@ -6,8 +6,6 @@ conversations: content: ${system} - role: user content: What is the secret number for key ALPHA? - - role: assistant - content: I'll get the secret number for key ALPHA. - role: assistant tool_calls: - id: toolcall_0 diff --git a/test/snapshots/session/should_have_stateful_conversation.yaml b/test/snapshots/session/should_have_stateful_conversation.yaml index bd02858..39d3c5a 100644 --- a/test/snapshots/session/should_have_stateful_conversation.yaml +++ b/test/snapshots/session/should_have_stateful_conversation.yaml @@ -7,7 +7,7 @@ conversations: - role: user content: What is 1+1? - role: assistant - content: 1 + 1 = 2 + content: 1+1 = 2 - role: user content: Now if you double that, what do you get? - role: assistant diff --git a/test/snapshots/tools/invokes_built_in_tools.yaml b/test/snapshots/tools/invokes_built_in_tools.yaml index 466d4bb..fc60c8b 100644 --- a/test/snapshots/tools/invokes_built_in_tools.yaml +++ b/test/snapshots/tools/invokes_built_in_tools.yaml @@ -12,14 +12,31 @@ conversations: type: function function: name: report_intent - arguments: '{"intent":"Reading README file"}' + arguments: '{"intent":"Reading README.md file"}' - role: assistant tool_calls: - id: toolcall_1 type: function function: name: view - arguments: '{"path":"${workdir}/README.md","view_range":[1,1]}' + arguments: '{"path":"${workdir}/README.md"}' + - messages: + - role: system + content: ${system} + - role: user + content: What's the first line of README.md in this directory? + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: report_intent + arguments: '{"intent":"Reading README.md file"}' + - id: toolcall_1 + type: function + function: + name: view + arguments: '{"path":"${workdir}/README.md"}' - role: tool tool_call_id: toolcall_0 content: Intent logged @@ -27,4 +44,9 @@ conversations: tool_call_id: toolcall_1 content: "1. # ELIZA, the only chatbot you'll ever need" - role: assistant - content: "The first line of README.md is: `# ELIZA, the only chatbot you'll ever need`" + content: |- + The first line of README.md is: + + ``` + # ELIZA, the only chatbot you'll ever need + ```