blob: cedf6aa7f17331e1dcf60571e8a3656f51dba742 [file] [log] [blame]
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001package server_test
2
3import (
4 "bufio"
5 "context"
6 "net/http"
7 "net/http/httptest"
8 "slices"
9 "strings"
10 "sync"
11 "testing"
12 "time"
13
14 "sketch.dev/llm/conversation"
15 "sketch.dev/loop"
16 "sketch.dev/loop/server"
17)
18
19// mockAgent is a mock implementation of loop.CodingAgent for testing
20type mockAgent struct {
Philip Zeyligereab12de2025-05-14 02:35:53 +000021 mu sync.RWMutex
22 messages []loop.AgentMessage
23 messageCount int
24 currentState string
25 subscribers []chan *loop.AgentMessage
26 stateTransitionListeners []chan loop.StateTransition
27 initialCommit string
28 title string
29 branchName string
Philip Zeyligerd3ac1122025-05-14 02:54:18 +000030 workingDir string
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -070031 sessionID string
Philip Zeyliger25f6ff12025-05-02 04:24:10 +000032}
33
34func (m *mockAgent) NewIterator(ctx context.Context, nextMessageIdx int) loop.MessageIterator {
35 m.mu.RLock()
36 // Send existing messages that should be available immediately
37 ch := make(chan *loop.AgentMessage, 100)
38 iter := &mockIterator{
39 agent: m,
40 ctx: ctx,
41 nextMessageIdx: nextMessageIdx,
42 ch: ch,
43 }
44 m.mu.RUnlock()
45 return iter
46}
47
48type mockIterator struct {
49 agent *mockAgent
50 ctx context.Context
51 nextMessageIdx int
52 ch chan *loop.AgentMessage
53 subscribed bool
54}
55
56func (m *mockIterator) Next() *loop.AgentMessage {
57 if !m.subscribed {
58 m.agent.mu.Lock()
59 m.agent.subscribers = append(m.agent.subscribers, m.ch)
60 m.agent.mu.Unlock()
61 m.subscribed = true
62 }
63
64 for {
65 select {
66 case <-m.ctx.Done():
67 return nil
68 case msg := <-m.ch:
69 return msg
70 }
71 }
72}
73
74func (m *mockIterator) Close() {
75 // Remove from subscribers using slices.Delete
76 m.agent.mu.Lock()
77 for i, ch := range m.agent.subscribers {
78 if ch == m.ch {
79 m.agent.subscribers = slices.Delete(m.agent.subscribers, i, i+1)
80 break
81 }
82 }
83 m.agent.mu.Unlock()
84 close(m.ch)
85}
86
87func (m *mockAgent) Messages(start int, end int) []loop.AgentMessage {
88 m.mu.RLock()
89 defer m.mu.RUnlock()
90
91 if start >= len(m.messages) || end > len(m.messages) || start < 0 || end < 0 {
92 return []loop.AgentMessage{}
93 }
94 return slices.Clone(m.messages[start:end])
95}
96
97func (m *mockAgent) MessageCount() int {
98 m.mu.RLock()
99 defer m.mu.RUnlock()
100 return m.messageCount
101}
102
103func (m *mockAgent) AddMessage(msg loop.AgentMessage) {
104 m.mu.Lock()
105 msg.Idx = m.messageCount
106 m.messages = append(m.messages, msg)
107 m.messageCount++
108
109 // Create a copy of subscribers to avoid holding the lock while sending
110 subscribers := make([]chan *loop.AgentMessage, len(m.subscribers))
111 copy(subscribers, m.subscribers)
112 m.mu.Unlock()
113
114 // Notify subscribers
115 msgCopy := msg // Create a copy to avoid race conditions
116 for _, ch := range subscribers {
117 ch <- &msgCopy
118 }
119}
120
Philip Zeyligereab12de2025-05-14 02:35:53 +0000121func (m *mockAgent) NewStateTransitionIterator(ctx context.Context) loop.StateTransitionIterator {
122 m.mu.Lock()
123 ch := make(chan loop.StateTransition, 10)
124 m.stateTransitionListeners = append(m.stateTransitionListeners, ch)
125 m.mu.Unlock()
126
127 return &mockStateTransitionIterator{
128 agent: m,
129 ctx: ctx,
130 ch: ch,
131 }
132}
133
134type mockStateTransitionIterator struct {
135 agent *mockAgent
136 ctx context.Context
137 ch chan loop.StateTransition
138}
139
140func (m *mockStateTransitionIterator) Next() *loop.StateTransition {
141 select {
142 case <-m.ctx.Done():
143 return nil
144 case transition, ok := <-m.ch:
145 if !ok {
146 return nil
147 }
148 transitionCopy := transition
149 return &transitionCopy
150 }
151}
152
153func (m *mockStateTransitionIterator) Close() {
154 m.agent.mu.Lock()
155 for i, ch := range m.agent.stateTransitionListeners {
156 if ch == m.ch {
157 m.agent.stateTransitionListeners = slices.Delete(m.agent.stateTransitionListeners, i, i+1)
158 break
159 }
160 }
161 m.agent.mu.Unlock()
162 close(m.ch)
163}
164
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000165func (m *mockAgent) CurrentStateName() string {
166 m.mu.RLock()
167 defer m.mu.RUnlock()
168 return m.currentState
169}
170
Philip Zeyligereab12de2025-05-14 02:35:53 +0000171func (m *mockAgent) TriggerStateTransition(from, to loop.State, event loop.TransitionEvent) {
172 m.mu.Lock()
173 m.currentState = to.String()
174 transition := loop.StateTransition{
175 From: from,
176 To: to,
177 Event: event,
178 }
179
180 // Create a copy of listeners to avoid holding the lock while sending
181 listeners := make([]chan loop.StateTransition, len(m.stateTransitionListeners))
182 copy(listeners, m.stateTransitionListeners)
183 m.mu.Unlock()
184
185 // Notify listeners
186 for _, ch := range listeners {
187 ch <- transition
188 }
189}
190
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000191func (m *mockAgent) InitialCommit() string {
192 m.mu.RLock()
193 defer m.mu.RUnlock()
194 return m.initialCommit
195}
196
Philip Zeyliger49edc922025-05-14 09:45:45 -0700197func (m *mockAgent) SketchGitBase() string {
198 m.mu.RLock()
199 defer m.mu.RUnlock()
200 return m.initialCommit
201}
202
Philip Zeyligerd3ac1122025-05-14 02:54:18 +0000203func (m *mockAgent) SketchGitBaseRef() string {
204 return "sketch-base-test-session"
205}
206
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000207func (m *mockAgent) Title() string {
208 m.mu.RLock()
209 defer m.mu.RUnlock()
210 return m.title
211}
212
213func (m *mockAgent) BranchName() string {
214 m.mu.RLock()
215 defer m.mu.RUnlock()
216 return m.branchName
217}
218
219// Other required methods of loop.CodingAgent with minimal implementation
220func (m *mockAgent) Init(loop.AgentInit) error { return nil }
221func (m *mockAgent) Ready() <-chan struct{} { ch := make(chan struct{}); close(ch); return ch }
222func (m *mockAgent) URL() string { return "http://localhost:8080" }
223func (m *mockAgent) UserMessage(ctx context.Context, msg string) {}
224func (m *mockAgent) Loop(ctx context.Context) {}
225func (m *mockAgent) CancelTurn(cause error) {}
226func (m *mockAgent) CancelToolUse(id string, cause error) error { return nil }
227func (m *mockAgent) TotalUsage() conversation.CumulativeUsage { return conversation.CumulativeUsage{} }
228func (m *mockAgent) OriginalBudget() conversation.Budget { return conversation.Budget{} }
Philip Zeyligerd3ac1122025-05-14 02:54:18 +0000229func (m *mockAgent) WorkingDir() string { return m.workingDir }
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +0000230func (m *mockAgent) RepoRoot() string { return m.workingDir }
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000231func (m *mockAgent) Diff(commit *string) (string, error) { return "", nil }
232func (m *mockAgent) OS() string { return "linux" }
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700233func (m *mockAgent) SessionID() string { return m.sessionID }
234func (m *mockAgent) CurrentTodoContent() string { return "" } // Mock returns empty for simplicity
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000235func (m *mockAgent) OutstandingLLMCallCount() int { return 0 }
236func (m *mockAgent) OutstandingToolCalls() []string { return nil }
237func (m *mockAgent) OutsideOS() string { return "linux" }
238func (m *mockAgent) OutsideHostname() string { return "test-host" }
239func (m *mockAgent) OutsideWorkingDir() string { return "/app" }
240func (m *mockAgent) GitOrigin() string { return "" }
241func (m *mockAgent) OpenBrowser(url string) {}
242func (m *mockAgent) RestartConversation(ctx context.Context, rev string, initialPrompt string) error {
243 return nil
244}
245func (m *mockAgent) SuggestReprompt(ctx context.Context) (string, error) { return "", nil }
246func (m *mockAgent) IsInContainer() bool { return false }
247func (m *mockAgent) FirstMessageIndex() int { return 0 }
Philip Zeyliger9bca61e2025-05-22 12:40:06 -0700248func (m *mockAgent) DetectGitChanges(ctx context.Context) error { return nil } // TestSSEStream tests the SSE stream endpoint
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000249func TestSSEStream(t *testing.T) {
250 // Create a mock agent with initial messages
251 mockAgent := &mockAgent{
Philip Zeyligereab12de2025-05-14 02:35:53 +0000252 messages: []loop.AgentMessage{},
253 messageCount: 0,
254 currentState: "Ready",
255 subscribers: []chan *loop.AgentMessage{},
256 stateTransitionListeners: []chan loop.StateTransition{},
257 initialCommit: "abcd1234",
258 title: "Test Title",
259 branchName: "sketch/test-branch",
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000260 }
261
262 // Add the initial messages before creating the server
263 // to ensure they're available in the Messages slice
264 msg1 := loop.AgentMessage{
265 Type: loop.UserMessageType,
266 Content: "Hello, this is a test message",
267 Timestamp: time.Now(),
268 }
269 mockAgent.messages = append(mockAgent.messages, msg1)
270 msg1.Idx = mockAgent.messageCount
271 mockAgent.messageCount++
272
273 msg2 := loop.AgentMessage{
274 Type: loop.AgentMessageType,
275 Content: "This is a response message",
276 Timestamp: time.Now(),
277 EndOfTurn: true,
278 }
279 mockAgent.messages = append(mockAgent.messages, msg2)
280 msg2.Idx = mockAgent.messageCount
281 mockAgent.messageCount++
282
283 // Create a server with the mock agent
284 srv, err := server.New(mockAgent, nil)
285 if err != nil {
286 t.Fatalf("Failed to create server: %v", err)
287 }
288
289 // Create a test server
290 ts := httptest.NewServer(srv)
291 defer ts.Close()
292
293 // Create a context with cancellation for the client request
294 ctx, cancel := context.WithCancel(context.Background())
295
296 // Create a request to the /stream endpoint
297 req, err := http.NewRequestWithContext(ctx, "GET", ts.URL+"/stream?from=0", nil)
298 if err != nil {
299 t.Fatalf("Failed to create request: %v", err)
300 }
301
302 // Execute the request
303 res, err := http.DefaultClient.Do(req)
304 if err != nil {
305 t.Fatalf("Failed to execute request: %v", err)
306 }
307 defer res.Body.Close()
308
309 // Check response status
310 if res.StatusCode != http.StatusOK {
311 t.Fatalf("Expected status OK, got %v", res.Status)
312 }
313
314 // Check content type
315 if contentType := res.Header.Get("Content-Type"); contentType != "text/event-stream" {
316 t.Fatalf("Expected Content-Type text/event-stream, got %s", contentType)
317 }
318
319 // Read response events using a scanner
320 scanner := bufio.NewScanner(res.Body)
321
322 // Track events received
323 eventsReceived := map[string]int{
324 "state": 0,
325 "message": 0,
326 "heartbeat": 0,
327 }
328
329 // Read for a short time to capture initial state and messages
330 dataLines := []string{}
331 eventType := ""
332
333 go func() {
334 // After reading for a while, add a new message to test real-time updates
335 time.Sleep(500 * time.Millisecond)
336
337 mockAgent.AddMessage(loop.AgentMessage{
338 Type: loop.ToolUseMessageType,
339 Content: "This is a new real-time message",
340 Timestamp: time.Now(),
341 ToolName: "test_tool",
342 })
343
Philip Zeyligereab12de2025-05-14 02:35:53 +0000344 // Trigger a state transition to test state updates
345 time.Sleep(200 * time.Millisecond)
346 mockAgent.TriggerStateTransition(loop.StateReady, loop.StateSendingToLLM, loop.TransitionEvent{
347 Description: "Agent started thinking",
348 Data: "start_thinking",
349 })
350
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000351 // Let it process for longer
352 time.Sleep(1000 * time.Millisecond)
353 cancel() // Cancel to end the test
354 }()
355
356 // Read events
357 for scanner.Scan() {
358 line := scanner.Text()
359
360 if strings.HasPrefix(line, "event: ") {
361 eventType = strings.TrimPrefix(line, "event: ")
362 eventsReceived[eventType]++
363 } else if strings.HasPrefix(line, "data: ") {
364 dataLines = append(dataLines, line)
365 } else if line == "" && eventType != "" {
366 // End of event
367 eventType = ""
368 }
369
370 // Break if context is done
371 if ctx.Err() != nil {
372 break
373 }
374 }
375
376 if err := scanner.Err(); err != nil && ctx.Err() == nil {
377 t.Fatalf("Scanner error: %v", err)
378 }
379
380 // Simplified validation - just make sure we received something
381 t.Logf("Events received: %v", eventsReceived)
382 t.Logf("Data lines received: %d", len(dataLines))
383
384 // Basic validation that we received at least some events
385 if eventsReceived["state"] == 0 && eventsReceived["message"] == 0 {
386 t.Errorf("Did not receive any events")
387 }
388}
Philip Zeyligerd3ac1122025-05-14 02:54:18 +0000389
390func TestGitRawDiffHandler(t *testing.T) {
391 // Create a mock agent
392 mockAgent := &mockAgent{
393 workingDir: t.TempDir(), // Use a temp directory
394 }
395
396 // Create the server with the mock agent
397 server, err := server.New(mockAgent, nil)
398 if err != nil {
399 t.Fatalf("Failed to create server: %v", err)
400 }
401
402 // Create a test HTTP server
403 testServer := httptest.NewServer(server)
404 defer testServer.Close()
405
406 // Test missing parameters
407 resp, err := http.Get(testServer.URL + "/git/rawdiff")
408 if err != nil {
409 t.Fatalf("Failed to make HTTP request: %v", err)
410 }
411 if resp.StatusCode != http.StatusBadRequest {
412 t.Errorf("Expected status bad request, got: %d", resp.StatusCode)
413 }
414
415 // Test with commit parameter (this will fail due to no git repo, but we're testing the API, not git)
416 resp, err = http.Get(testServer.URL + "/git/rawdiff?commit=HEAD")
417 if err != nil {
418 t.Fatalf("Failed to make HTTP request: %v", err)
419 }
420 // We expect an error since there's no git repository, but the request should be processed
421 if resp.StatusCode != http.StatusInternalServerError {
422 t.Errorf("Expected status 500, got: %d", resp.StatusCode)
423 }
424
425 // Test with from/to parameters
426 resp, err = http.Get(testServer.URL + "/git/rawdiff?from=HEAD~1&to=HEAD")
427 if err != nil {
428 t.Fatalf("Failed to make HTTP request: %v", err)
429 }
430 // We expect an error since there's no git repository, but the request should be processed
431 if resp.StatusCode != http.StatusInternalServerError {
432 t.Errorf("Expected status 500, got: %d", resp.StatusCode)
433 }
434}
435
436func TestGitShowHandler(t *testing.T) {
437 // Create a mock agent
438 mockAgent := &mockAgent{
439 workingDir: t.TempDir(), // Use a temp directory
440 }
441
442 // Create the server with the mock agent
443 server, err := server.New(mockAgent, nil)
444 if err != nil {
445 t.Fatalf("Failed to create server: %v", err)
446 }
447
448 // Create a test HTTP server
449 testServer := httptest.NewServer(server)
450 defer testServer.Close()
451
452 // Test missing parameter
453 resp, err := http.Get(testServer.URL + "/git/show")
454 if err != nil {
455 t.Fatalf("Failed to make HTTP request: %v", err)
456 }
457 if resp.StatusCode != http.StatusBadRequest {
458 t.Errorf("Expected status bad request, got: %d", resp.StatusCode)
459 }
460
461 // Test with hash parameter (this will fail due to no git repo, but we're testing the API, not git)
462 resp, err = http.Get(testServer.URL + "/git/show?hash=HEAD")
463 if err != nil {
464 t.Fatalf("Failed to make HTTP request: %v", err)
465 }
466 // We expect an error since there's no git repository, but the request should be processed
467 if resp.StatusCode != http.StatusInternalServerError {
468 t.Errorf("Expected status 500, got: %d", resp.StatusCode)
469 }
470}
471
472// Removing duplicate method definition