Skip to content

Commit 70817fa

Browse files
Add equivalent abort test improvements to Go, Python, and .NET
1 parent 40ae753 commit 70817fa

File tree

12 files changed

+189
-50
lines changed

12 files changed

+189
-50
lines changed

dotnet/test/Harness/TestHelper.cs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,4 +73,29 @@ async void CheckExistingMessages()
7373

7474
return null;
7575
}
76+
77+
public static async Task<T> GetNextEventOfTypeAsync<T>(
78+
CopilotSession session,
79+
TimeSpan? timeout = null) where T : SessionEvent
80+
{
81+
var tcs = new TaskCompletionSource<T>();
82+
using var cts = new CancellationTokenSource(timeout ?? TimeSpan.FromSeconds(60));
83+
84+
using var subscription = session.On(evt =>
85+
{
86+
if (evt is T matched)
87+
{
88+
tcs.TrySetResult(matched);
89+
}
90+
else if (evt is SessionErrorEvent error)
91+
{
92+
tcs.TrySetException(new Exception(error.Data.Message ?? "session error"));
93+
}
94+
});
95+
96+
cts.Token.Register(() => tcs.TrySetException(
97+
new TimeoutException($"Timeout waiting for event of type '{typeof(T).Name}'")));
98+
99+
return await tcs.Task;
100+
}
76101
}

dotnet/test/SessionTests.cs

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -201,23 +201,33 @@ public async Task Should_Abort_A_Session()
201201
{
202202
var session = await Client.CreateSessionAsync();
203203

204+
// Set up wait for tool execution to start BEFORE sending
205+
var toolStartTask = TestHelper.GetNextEventOfTypeAsync<ToolExecutionStartEvent>(session);
206+
204207
// Send a message that will take some time to process
205-
await session.SendAsync(new MessageOptions { Prompt = "What is 1+1?" });
208+
await session.SendAsync(new MessageOptions
209+
{
210+
Prompt = "run the shell command 'sleep 100' (note this works on both bash and PowerShell)"
211+
});
206212

207-
// Abort the session immediately
213+
// Wait for tool execution to start
214+
await toolStartTask;
215+
216+
// Abort the session
208217
await session.AbortAsync();
209218

219+
// Wait for session to become idle after abort
220+
await TestHelper.GetNextEventOfTypeAsync<SessionIdleEvent>(session);
221+
210222
// The session should still be alive and usable after abort
211223
var messages = await session.GetMessagesAsync();
212224
Assert.NotEmpty(messages);
213225

214-
// TODO: We should do something to verify it really did abort (e.g., is there an abort event we can see,
215-
// or can we check that the session became idle without receiving an assistant message?). Right now
216-
// I'm not seeing any evidence that it actually does abort.
226+
// Verify an abort event exists in messages
227+
Assert.Contains(messages, m => m is AbortEvent);
217228

218229
// We should be able to send another message
219-
await session.SendAsync(new MessageOptions { Prompt = "What is 2+2?" });
220-
var answer = await TestHelper.GetFinalAssistantMessageAsync(session);
230+
var answer = await session.SendAndWaitAsync(new MessageOptions { Prompt = "What is 2+2?" });
221231
Assert.NotNull(answer);
222232
Assert.Contains("4", answer!.Data.Content ?? string.Empty);
223233
}

go/client.go

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,6 @@ import (
3939
"strings"
4040
"sync"
4141
"time"
42-
43-
"github.com/github/copilot-sdk/go/generated"
4442
)
4543

4644
// Client manages the connection to the Copilot CLI server and provides session management.
@@ -923,7 +921,7 @@ func (c *Client) setupNotificationHandler() {
923921
return
924922
}
925923

926-
event, err := generated.UnmarshalSessionEvent(eventJSON)
924+
event, err := UnmarshalSessionEvent(eventJSON)
927925
if err != nil {
928926
return
929927
}

go/e2e/session_test.go

Lines changed: 42 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -472,18 +472,44 @@ func TestSession(t *testing.T) {
472472
t.Fatalf("Failed to create session: %v", err)
473473
}
474474

475-
// Send a message that will take some time to process
476-
_, err = session.Send(copilot.MessageOptions{Prompt: "What is 1+1?"})
475+
// Set up wait for tool.execution_start BEFORE sending
476+
toolStartCh := make(chan *copilot.SessionEvent, 1)
477+
toolStartErrCh := make(chan error, 1)
478+
go func() {
479+
evt, err := testharness.GetNextEventOfType(session, copilot.ToolExecutionStart, 60*time.Second)
480+
if err != nil {
481+
toolStartErrCh <- err
482+
} else {
483+
toolStartCh <- evt
484+
}
485+
}()
486+
487+
// Send a message that triggers a long-running shell command
488+
_, err = session.Send(copilot.MessageOptions{Prompt: "run the shell command 'sleep 100' (note this works on both bash and PowerShell)"})
477489
if err != nil {
478490
t.Fatalf("Failed to send message: %v", err)
479491
}
480492

481-
// Abort the session immediately
493+
// Wait for tool.execution_start
494+
select {
495+
case <-toolStartCh:
496+
// Tool execution has started
497+
case err := <-toolStartErrCh:
498+
t.Fatalf("Failed waiting for tool.execution_start: %v", err)
499+
}
500+
501+
// Abort the session
482502
err = session.Abort()
483503
if err != nil {
484504
t.Fatalf("Failed to abort session: %v", err)
485505
}
486506

507+
// Wait for session.idle after abort
508+
_, err = testharness.GetNextEventOfType(session, copilot.SessionIdle, 60*time.Second)
509+
if err != nil {
510+
t.Fatalf("Failed waiting for session.idle after abort: %v", err)
511+
}
512+
487513
// The session should still be alive and usable after abort
488514
messages, err := session.GetMessages()
489515
if err != nil {
@@ -493,15 +519,22 @@ func TestSession(t *testing.T) {
493519
t.Error("Expected messages to exist after abort")
494520
}
495521

496-
// We should be able to send another message
497-
_, err = session.Send(copilot.MessageOptions{Prompt: "What is 2+2?"})
498-
if err != nil {
499-
t.Fatalf("Failed to send message after abort: %v", err)
522+
// Verify messages contain an abort event
523+
hasAbortEvent := false
524+
for _, msg := range messages {
525+
if msg.Type == copilot.Abort {
526+
hasAbortEvent = true
527+
break
528+
}
529+
}
530+
if !hasAbortEvent {
531+
t.Error("Expected messages to contain an 'abort' event")
500532
}
501533

502-
answer, err := testharness.GetFinalAssistantMessage(session, 60*time.Second)
534+
// We should be able to send another message
535+
answer, err := session.SendAndWait(copilot.MessageOptions{Prompt: "What is 2+2?"}, 60*time.Second)
503536
if err != nil {
504-
t.Fatalf("Failed to get assistant message after abort: %v", err)
537+
t.Fatalf("Failed to send message after abort: %v", err)
505538
}
506539

507540
if answer.Data.Content == nil || !strings.Contains(*answer.Data.Content, "4") {

go/e2e/testharness/helper.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,40 @@ func GetFinalAssistantMessage(session *copilot.Session, timeout time.Duration) (
5454
}
5555
}
5656

57+
// GetNextEventOfType waits for and returns the next event of the specified type from a session.
58+
func GetNextEventOfType(session *copilot.Session, eventType copilot.SessionEventType, timeout time.Duration) (*copilot.SessionEvent, error) {
59+
result := make(chan *copilot.SessionEvent, 1)
60+
errCh := make(chan error, 1)
61+
62+
unsubscribe := session.On(func(event copilot.SessionEvent) {
63+
if event.Type == eventType {
64+
select {
65+
case result <- &event:
66+
default:
67+
}
68+
} else if event.Type == copilot.SessionError {
69+
msg := "session error"
70+
if event.Data.Message != nil {
71+
msg = *event.Data.Message
72+
}
73+
select {
74+
case errCh <- errors.New(msg):
75+
default:
76+
}
77+
}
78+
})
79+
defer unsubscribe()
80+
81+
select {
82+
case evt := <-result:
83+
return evt, nil
84+
case err := <-errCh:
85+
return nil, err
86+
case <-time.After(timeout):
87+
return nil, errors.New("timeout waiting for event: " + string(eventType))
88+
}
89+
}
90+
5791
func getExistingFinalResponse(session *copilot.Session) (*copilot.SessionEvent, error) {
5892
messages, err := session.GetMessages()
5993
if err != nil {
Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

go/session.go

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,6 @@ import (
66
"fmt"
77
"sync"
88
"time"
9-
10-
"github.com/github/copilot-sdk/go/generated"
119
)
1210

1311
type sessionHandler struct {
@@ -159,17 +157,17 @@ func (s *Session) SendAndWait(options MessageOptions, timeout time.Duration) (*S
159157

160158
unsubscribe := s.On(func(event SessionEvent) {
161159
switch event.Type {
162-
case generated.AssistantMessage:
160+
case AssistantMessage:
163161
mu.Lock()
164162
eventCopy := event
165163
lastAssistantMessage = &eventCopy
166164
mu.Unlock()
167-
case generated.SessionIdle:
165+
case SessionIdle:
168166
select {
169167
case idleCh <- struct{}{}:
170168
default:
171169
}
172-
case generated.SessionError:
170+
case SessionError:
173171
errMsg := "session error"
174172
if event.Data.Message != nil {
175173
errMsg = *event.Data.Message
@@ -387,7 +385,7 @@ func (s *Session) GetMessages() ([]SessionEvent, error) {
387385
continue
388386
}
389387

390-
event, err := generated.UnmarshalSessionEvent(eventJSON)
388+
event, err := UnmarshalSessionEvent(eventJSON)
391389
if err != nil {
392390
continue
393391
}

go/types.go

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

3-
import (
4-
"github.com/github/copilot-sdk/go/generated"
5-
)
6-
7-
type SessionEvent = generated.SessionEvent
8-
93
// ConnectionState represents the client connection state
104
type ConnectionState string
115

@@ -258,13 +252,6 @@ type MessageOptions struct {
258252
Mode string
259253
}
260254

261-
// Attachment represents a file or directory attachment
262-
type Attachment struct {
263-
Type string `json:"type"` // "file" or "directory"
264-
Path string `json:"path"`
265-
DisplayName string `json:"displayName,omitempty"`
266-
}
267-
268255
// SessionEventHandler is a callback for session events
269256
type SessionEventHandler func(event SessionEvent)
270257

nodejs/scripts/generate-session-types.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -272,7 +272,7 @@ async function generateGoTypes(schemaPath: string) {
272272
inputData,
273273
lang: "go",
274274
rendererOptions: {
275-
package: "generated",
275+
package: "copilot",
276276
},
277277
});
278278

@@ -289,7 +289,7 @@ async function generateGoTypes(schemaPath: string) {
289289
290290
`;
291291

292-
const outputPath = path.join(__dirname, "../../go/generated/session_events.go");
292+
const outputPath = path.join(__dirname, "../../go/generated_session_events.go");
293293
await fs.mkdir(path.dirname(outputPath), { recursive: true });
294294
await fs.writeFile(outputPath, banner + generatedCode, "utf-8");
295295

python/e2e/test_session.py

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from copilot import CopilotClient
66
from copilot.types import Tool
77

8-
from .testharness import E2ETestContext, get_final_assistant_message
8+
from .testharness import E2ETestContext, get_final_assistant_message, get_next_event_of_type
99

1010
pytestmark = pytest.mark.asyncio(loop_scope="module")
1111

@@ -256,21 +256,39 @@ async def test_should_resume_session_with_custom_provider(self, ctx: E2ETestCont
256256
assert session2.session_id == session_id
257257

258258
async def test_should_abort_a_session(self, ctx: E2ETestContext):
259+
import asyncio
260+
259261
session = await ctx.client.create_session()
260262

261-
# Send a message that will take some time to process
262-
await session.send({"prompt": "What is 1+1?"})
263+
# Set up wait for tool.execution_start BEFORE sending
264+
wait_for_tool_start = asyncio.create_task(
265+
get_next_event_of_type(session, "tool.execution_start", timeout=60.0)
266+
)
267+
268+
# Send a message that will trigger a long-running shell command
269+
await session.send(
270+
{"prompt": "run the shell command 'sleep 100' (note this works on both bash and PowerShell)"}
271+
)
272+
273+
# Wait for the tool to start executing
274+
await wait_for_tool_start
263275

264-
# Abort the session immediately
276+
# Abort the session while the tool is running
265277
await session.abort()
266278

279+
# Wait for session to become idle after abort
280+
await get_next_event_of_type(session, "session.idle", timeout=30.0)
281+
267282
# The session should still be alive and usable after abort
268283
messages = await session.get_messages()
269284
assert len(messages) > 0
270285

286+
# Verify an abort event exists in messages
287+
abort_events = [m for m in messages if m.type.value == "abort"]
288+
assert len(abort_events) > 0, "Expected an abort event in messages"
289+
271290
# We should be able to send another message
272-
await session.send({"prompt": "What is 2+2?"})
273-
answer = await get_final_assistant_message(session)
291+
answer = await session.send_and_wait({"prompt": "What is 2+2?"})
274292
assert "4" in answer.data.content
275293

276294
async def test_should_receive_streaming_delta_events_when_streaming_is_enabled(

0 commit comments

Comments
 (0)