Repository navigation
Expand file tree
/
Copy pathmulti_turn_e2e_test.go
More file actions
209 lines (184 loc) · 7.29 KB
/
Copy pathmulti_turn_e2e_test.go
File metadata and controls
209 lines (184 loc) · 7.29 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
package e2e
import (
"os"
"path/filepath"
"strings"
"sync"
"testing"
copilot "github.com/github/copilot-sdk/go"
"github.com/github/copilot-sdk/go/internal/e2e/testharness"
)
func TestMultiTurnE2E(t *testing.T) {
ctx := testharness.NewTestContext(t)
client := ctx.NewClient()
t.Cleanup(func() { client.ForceStop() })
t.Run("should use tool results from previous turns", func(t *testing.T) {
ctx.ConfigureForTest(t)
if err := os.WriteFile(filepath.Join(ctx.WorkDir, "secret.txt"), []byte("The magic number is 42."), 0644); err != nil {
t.Fatalf("Failed to write secret.txt: %v", err)
}
session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{
OnPermissionRequest: copilot.PermissionHandler.ApproveAll,
})
if err != nil {
t.Fatalf("Failed to create session: %v", err)
}
t.Cleanup(func() { _ = session.Disconnect() })
var mu sync.Mutex
var events []copilot.SessionEvent
session.On(func(event copilot.SessionEvent) {
mu.Lock()
events = append(events, event)
mu.Unlock()
})
msg1, err := session.SendAndWait(t.Context(), copilot.MessageOptions{
Prompt: "Read the file 'secret.txt' and tell me what the magic number is.",
})
if err != nil {
t.Fatalf("First SendAndWait failed: %v", err)
}
if content := assistantContent(t, msg1); !strings.Contains(content, "42") {
t.Fatalf("Expected first response to contain 42, got %q", content)
}
assertToolTurnOrdering(t, snapshotAndClearMultiTurnEvents(&mu, &events), "file read turn")
msg2, err := session.SendAndWait(t.Context(), copilot.MessageOptions{
Prompt: "What is that magic number multiplied by 2?",
})
if err != nil {
t.Fatalf("Second SendAndWait failed: %v", err)
}
if content := assistantContent(t, msg2); !strings.Contains(content, "84") {
t.Fatalf("Expected second response to contain 84, got %q", content)
}
})
t.Run("should handle file creation then reading across turns", func(t *testing.T) {
ctx.ConfigureForTest(t)
session, err := client.CreateSession(t.Context(), &copilot.SessionConfig{
OnPermissionRequest: copilot.PermissionHandler.ApproveAll,
})
if err != nil {
t.Fatalf("Failed to create session: %v", err)
}
t.Cleanup(func() { _ = session.Disconnect() })
var mu sync.Mutex
var events []copilot.SessionEvent
session.On(func(event copilot.SessionEvent) {
mu.Lock()
events = append(events, event)
mu.Unlock()
})
if _, err := session.SendAndWait(t.Context(), copilot.MessageOptions{
Prompt: "Create a file called 'greeting.txt' with the content 'Hello from multi-turn test'.",
}); err != nil {
t.Fatalf("First SendAndWait failed: %v", err)
}
// File should have been created with the expected content
greetingContent, err := os.ReadFile(filepath.Join(ctx.WorkDir, "greeting.txt"))
if err != nil {
t.Fatalf("Failed to read greeting.txt: %v", err)
}
if !strings.Contains(string(greetingContent), "Hello from multi-turn test") {
t.Errorf("Expected greeting.txt to contain 'Hello from multi-turn test', got %q", string(greetingContent))
}
assertToolTurnOrdering(t, snapshotAndClearMultiTurnEvents(&mu, &events), "file creation turn")
msg, err := session.SendAndWait(t.Context(), copilot.MessageOptions{
Prompt: "Read the file 'greeting.txt' and tell me its exact contents.",
})
if err != nil {
t.Fatalf("Second SendAndWait failed: %v", err)
}
if content := assistantContent(t, msg); !strings.Contains(content, "Hello from multi-turn test") {
t.Fatalf("Expected response to contain created file contents, got %q", content)
}
assertToolTurnOrdering(t, snapshotAndClearMultiTurnEvents(&mu, &events), "file read turn")
})
}
func snapshotAndClearMultiTurnEvents(mu *sync.Mutex, events *[]copilot.SessionEvent) []copilot.SessionEvent {
mu.Lock()
defer mu.Unlock()
snapshot := make([]copilot.SessionEvent, len(*events))
copy(snapshot, *events)
*events = (*events)[:0]
return snapshot
}
// assertToolTurnOrdering verifies that for a turn with tool use the events arrive in the
// expected order: user.message → tool.execution_start(s) → tool.execution_complete(s)
// → assistant.message → session.idle.
func assertToolTurnOrdering(t *testing.T, events []copilot.SessionEvent, turnDescription string) {
t.Helper()
observedTypes := make([]copilot.SessionEventType, 0, len(events))
for _, e := range events {
observedTypes = append(observedTypes, e.Type())
}
userMessageIdx := indexOfEventType(events, copilot.SessionEventTypeUserMessage, 0)
if userMessageIdx < 0 {
// A turn without a tool call (e.g., pure text answer) may not need ordering.
// Only assert if tool events are present.
if !containsEventType(events, copilot.SessionEventTypeToolExecutionStart) {
return
}
t.Errorf("Expected user.message in %s but none found; types=%v", turnDescription, observedTypes)
return
}
firstToolStartIdx := indexOfEventType(events, copilot.SessionEventTypeToolExecutionStart, 0)
if firstToolStartIdx < 0 {
// No tool use in this turn — nothing to assert.
return
}
lastToolCompleteIdx := lastIndexOfEventType(events, copilot.SessionEventTypeToolExecutionComplete)
assistantAfterToolsIdx := indexOfEventType(events, copilot.SessionEventTypeAssistantMessage, lastToolCompleteIdx+1)
sessionIdleIdx := indexOfEventType(events, copilot.SessionEventTypeSessionIdle, 0)
if userMessageIdx >= firstToolStartIdx {
t.Errorf("[%s] Expected user.message before first tool start; types=%v", turnDescription, observedTypes)
}
// Match each tool.execution_complete to a preceding tool.execution_start with the same ToolCallID.
starts := make(map[string]int)
for i, e := range events {
if e.Type() == copilot.SessionEventTypeToolExecutionStart {
if d, ok := e.Data.(*copilot.ToolExecutionStartData); ok {
starts[d.ToolCallID] = i
}
}
}
for _, e := range events {
if e.Type() == copilot.SessionEventTypeToolExecutionComplete {
if d, ok := e.Data.(*copilot.ToolExecutionCompleteData); ok {
if _, found := starts[d.ToolCallID]; !found {
t.Errorf("[%s] tool.execution_complete for %q has no matching tool.execution_start; types=%v",
turnDescription, d.ToolCallID, observedTypes)
}
}
}
}
if assistantAfterToolsIdx < 0 {
t.Errorf("[%s] Expected assistant.message after final tool completion; types=%v", turnDescription, observedTypes)
}
if sessionIdleIdx < 0 {
t.Errorf("[%s] Expected session.idle; types=%v", turnDescription, observedTypes)
}
if assistantAfterToolsIdx >= 0 && lastToolCompleteIdx >= assistantAfterToolsIdx {
t.Errorf("[%s] Expected final tool completion before final assistant.message; types=%v", turnDescription, observedTypes)
}
if assistantAfterToolsIdx >= 0 && sessionIdleIdx >= 0 && assistantAfterToolsIdx >= sessionIdleIdx {
t.Errorf("[%s] Expected assistant.message before session.idle; types=%v", turnDescription, observedTypes)
}
}
func indexOfEventType(events []copilot.SessionEvent, typ copilot.SessionEventType, startIdx int) int {
for i := startIdx; i < len(events); i++ {
if events[i].Type() == typ {
return i
}
}
return -1
}
func lastIndexOfEventType(events []copilot.SessionEvent, typ copilot.SessionEventType) int {
for i := len(events) - 1; i >= 0; i-- {
if events[i].Type() == typ {
return i
}
}
return -1
}
func containsEventType(events []copilot.SessionEvent, typ copilot.SessionEventType) bool {
return indexOfEventType(events, typ, 0) >= 0
}