blob: 0d28fca3835358c787f5c3147399d09317dd416f [file] [log] [blame]
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00001package server_test
2
3import (
4 "bufio"
5 "context"
Sean McCullough138ec242025-06-02 22:42:06 +00006 "encoding/json"
Philip Zeyliger25f6ff12025-05-02 04:24:10 +00007 "net/http"
8 "net/http/httptest"
9 "slices"
10 "strings"
11 "sync"
12 "testing"
13 "time"
14
15 "sketch.dev/llm/conversation"
16 "sketch.dev/loop"
17 "sketch.dev/loop/server"
18)
19
20// mockAgent is a mock implementation of loop.CodingAgent for testing
21type mockAgent struct {
Philip Zeyligereab12de2025-05-14 02:35:53 +000022 mu sync.RWMutex
23 messages []loop.AgentMessage
24 messageCount int
25 currentState string
26 subscribers []chan *loop.AgentMessage
27 stateTransitionListeners []chan loop.StateTransition
28 initialCommit string
Philip Zeyligereab12de2025-05-14 02:35:53 +000029 branchName string
Philip Zeyligerbe7802a2025-06-04 20:15:25 +000030 branchPrefix string
Philip Zeyligerd3ac1122025-05-14 02:54:18 +000031 workingDir string
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -070032 sessionID string
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -070033 slug string
34 retryNumber int
Philip Zeyliger25f6ff12025-05-02 04:24:10 +000035}
36
37func (m *mockAgent) NewIterator(ctx context.Context, nextMessageIdx int) loop.MessageIterator {
38 m.mu.RLock()
39 // Send existing messages that should be available immediately
40 ch := make(chan *loop.AgentMessage, 100)
41 iter := &mockIterator{
42 agent: m,
43 ctx: ctx,
44 nextMessageIdx: nextMessageIdx,
45 ch: ch,
46 }
47 m.mu.RUnlock()
48 return iter
49}
50
51type mockIterator struct {
52 agent *mockAgent
53 ctx context.Context
54 nextMessageIdx int
55 ch chan *loop.AgentMessage
56 subscribed bool
57}
58
59func (m *mockIterator) Next() *loop.AgentMessage {
60 if !m.subscribed {
61 m.agent.mu.Lock()
62 m.agent.subscribers = append(m.agent.subscribers, m.ch)
63 m.agent.mu.Unlock()
64 m.subscribed = true
65 }
66
67 for {
68 select {
69 case <-m.ctx.Done():
70 return nil
71 case msg := <-m.ch:
72 return msg
73 }
74 }
75}
76
77func (m *mockIterator) Close() {
78 // Remove from subscribers using slices.Delete
79 m.agent.mu.Lock()
80 for i, ch := range m.agent.subscribers {
81 if ch == m.ch {
82 m.agent.subscribers = slices.Delete(m.agent.subscribers, i, i+1)
83 break
84 }
85 }
86 m.agent.mu.Unlock()
87 close(m.ch)
88}
89
90func (m *mockAgent) Messages(start int, end int) []loop.AgentMessage {
91 m.mu.RLock()
92 defer m.mu.RUnlock()
93
94 if start >= len(m.messages) || end > len(m.messages) || start < 0 || end < 0 {
95 return []loop.AgentMessage{}
96 }
97 return slices.Clone(m.messages[start:end])
98}
99
100func (m *mockAgent) MessageCount() int {
101 m.mu.RLock()
102 defer m.mu.RUnlock()
103 return m.messageCount
104}
105
106func (m *mockAgent) AddMessage(msg loop.AgentMessage) {
107 m.mu.Lock()
108 msg.Idx = m.messageCount
109 m.messages = append(m.messages, msg)
110 m.messageCount++
111
112 // Create a copy of subscribers to avoid holding the lock while sending
113 subscribers := make([]chan *loop.AgentMessage, len(m.subscribers))
114 copy(subscribers, m.subscribers)
115 m.mu.Unlock()
116
117 // Notify subscribers
118 msgCopy := msg // Create a copy to avoid race conditions
119 for _, ch := range subscribers {
120 ch <- &msgCopy
121 }
122}
123
Philip Zeyligereab12de2025-05-14 02:35:53 +0000124func (m *mockAgent) NewStateTransitionIterator(ctx context.Context) loop.StateTransitionIterator {
125 m.mu.Lock()
126 ch := make(chan loop.StateTransition, 10)
127 m.stateTransitionListeners = append(m.stateTransitionListeners, ch)
128 m.mu.Unlock()
129
130 return &mockStateTransitionIterator{
131 agent: m,
132 ctx: ctx,
133 ch: ch,
134 }
135}
136
137type mockStateTransitionIterator struct {
138 agent *mockAgent
139 ctx context.Context
140 ch chan loop.StateTransition
141}
142
143func (m *mockStateTransitionIterator) Next() *loop.StateTransition {
144 select {
145 case <-m.ctx.Done():
146 return nil
147 case transition, ok := <-m.ch:
148 if !ok {
149 return nil
150 }
151 transitionCopy := transition
152 return &transitionCopy
153 }
154}
155
156func (m *mockStateTransitionIterator) Close() {
157 m.agent.mu.Lock()
158 for i, ch := range m.agent.stateTransitionListeners {
159 if ch == m.ch {
160 m.agent.stateTransitionListeners = slices.Delete(m.agent.stateTransitionListeners, i, i+1)
161 break
162 }
163 }
164 m.agent.mu.Unlock()
165 close(m.ch)
166}
167
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000168func (m *mockAgent) CurrentStateName() string {
169 m.mu.RLock()
170 defer m.mu.RUnlock()
171 return m.currentState
172}
173
Philip Zeyligereab12de2025-05-14 02:35:53 +0000174func (m *mockAgent) TriggerStateTransition(from, to loop.State, event loop.TransitionEvent) {
175 m.mu.Lock()
176 m.currentState = to.String()
177 transition := loop.StateTransition{
178 From: from,
179 To: to,
180 Event: event,
181 }
182
183 // Create a copy of listeners to avoid holding the lock while sending
184 listeners := make([]chan loop.StateTransition, len(m.stateTransitionListeners))
185 copy(listeners, m.stateTransitionListeners)
186 m.mu.Unlock()
187
188 // Notify listeners
189 for _, ch := range listeners {
190 ch <- transition
191 }
192}
193
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000194func (m *mockAgent) InitialCommit() string {
195 m.mu.RLock()
196 defer m.mu.RUnlock()
197 return m.initialCommit
198}
199
Philip Zeyliger49edc922025-05-14 09:45:45 -0700200func (m *mockAgent) SketchGitBase() string {
201 m.mu.RLock()
202 defer m.mu.RUnlock()
203 return m.initialCommit
204}
205
Philip Zeyligerd3ac1122025-05-14 02:54:18 +0000206func (m *mockAgent) SketchGitBaseRef() string {
207 return "sketch-base-test-session"
208}
209
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000210func (m *mockAgent) BranchName() string {
211 m.mu.RLock()
212 defer m.mu.RUnlock()
213 return m.branchName
214}
215
216// Other required methods of loop.CodingAgent with minimal implementation
217func (m *mockAgent) Init(loop.AgentInit) error { return nil }
218func (m *mockAgent) Ready() <-chan struct{} { ch := make(chan struct{}); close(ch); return ch }
219func (m *mockAgent) URL() string { return "http://localhost:8080" }
220func (m *mockAgent) UserMessage(ctx context.Context, msg string) {}
221func (m *mockAgent) Loop(ctx context.Context) {}
222func (m *mockAgent) CancelTurn(cause error) {}
223func (m *mockAgent) CancelToolUse(id string, cause error) error { return nil }
224func (m *mockAgent) TotalUsage() conversation.CumulativeUsage { return conversation.CumulativeUsage{} }
225func (m *mockAgent) OriginalBudget() conversation.Budget { return conversation.Budget{} }
Philip Zeyligerd3ac1122025-05-14 02:54:18 +0000226func (m *mockAgent) WorkingDir() string { return m.workingDir }
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +0000227func (m *mockAgent) RepoRoot() string { return m.workingDir }
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000228func (m *mockAgent) Diff(commit *string) (string, error) { return "", nil }
229func (m *mockAgent) OS() string { return "linux" }
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700230func (m *mockAgent) SessionID() string { return m.sessionID }
Philip Zeyligerbe7802a2025-06-04 20:15:25 +0000231func (m *mockAgent) BranchPrefix() string { return m.branchPrefix }
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700232func (m *mockAgent) CurrentTodoContent() string { return "" } // Mock returns empty for simplicity
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000233func (m *mockAgent) OutstandingLLMCallCount() int { return 0 }
234func (m *mockAgent) OutstandingToolCalls() []string { return nil }
235func (m *mockAgent) OutsideOS() string { return "linux" }
236func (m *mockAgent) OutsideHostname() string { return "test-host" }
237func (m *mockAgent) OutsideWorkingDir() string { return "/app" }
238func (m *mockAgent) GitOrigin() string { return "" }
239func (m *mockAgent) OpenBrowser(url string) {}
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700240func (m *mockAgent) CompactConversation(ctx context.Context) error {
241 // Mock implementation - just return nil
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000242 return nil
243}
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700244func (m *mockAgent) IsInContainer() bool { return false }
245func (m *mockAgent) FirstMessageIndex() int { return 0 }
246func (m *mockAgent) DetectGitChanges(ctx context.Context) error { return nil }
Philip Zeyligerb5739402025-06-02 07:04:34 -0700247
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700248func (m *mockAgent) Slug() string {
249 m.mu.RLock()
250 defer m.mu.RUnlock()
251 return m.slug
252}
253
254func (m *mockAgent) IncrementRetryNumber() {
255 m.mu.Lock()
256 defer m.mu.Unlock()
257 m.retryNumber++
258}
259
Philip Zeyliger16098932025-06-04 11:02:55 -0700260func (m *mockAgent) GetPortMonitor() *loop.PortMonitor { return loop.NewPortMonitor() }
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700261
262// TestSSEStream tests the SSE stream endpoint
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000263func TestSSEStream(t *testing.T) {
264 // Create a mock agent with initial messages
265 mockAgent := &mockAgent{
Philip Zeyligereab12de2025-05-14 02:35:53 +0000266 messages: []loop.AgentMessage{},
267 messageCount: 0,
268 currentState: "Ready",
269 subscribers: []chan *loop.AgentMessage{},
270 stateTransitionListeners: []chan loop.StateTransition{},
271 initialCommit: "abcd1234",
Philip Zeyligereab12de2025-05-14 02:35:53 +0000272 branchName: "sketch/test-branch",
Philip Zeyligerbe7802a2025-06-04 20:15:25 +0000273 branchPrefix: "sketch/",
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700274 slug: "test-slug",
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000275 }
276
277 // Add the initial messages before creating the server
278 // to ensure they're available in the Messages slice
279 msg1 := loop.AgentMessage{
280 Type: loop.UserMessageType,
281 Content: "Hello, this is a test message",
282 Timestamp: time.Now(),
283 }
284 mockAgent.messages = append(mockAgent.messages, msg1)
285 msg1.Idx = mockAgent.messageCount
286 mockAgent.messageCount++
287
288 msg2 := loop.AgentMessage{
289 Type: loop.AgentMessageType,
290 Content: "This is a response message",
291 Timestamp: time.Now(),
292 EndOfTurn: true,
293 }
294 mockAgent.messages = append(mockAgent.messages, msg2)
295 msg2.Idx = mockAgent.messageCount
296 mockAgent.messageCount++
297
298 // Create a server with the mock agent
299 srv, err := server.New(mockAgent, nil)
300 if err != nil {
301 t.Fatalf("Failed to create server: %v", err)
302 }
303
304 // Create a test server
305 ts := httptest.NewServer(srv)
306 defer ts.Close()
307
308 // Create a context with cancellation for the client request
309 ctx, cancel := context.WithCancel(context.Background())
310
311 // Create a request to the /stream endpoint
312 req, err := http.NewRequestWithContext(ctx, "GET", ts.URL+"/stream?from=0", nil)
313 if err != nil {
314 t.Fatalf("Failed to create request: %v", err)
315 }
316
317 // Execute the request
318 res, err := http.DefaultClient.Do(req)
319 if err != nil {
320 t.Fatalf("Failed to execute request: %v", err)
321 }
322 defer res.Body.Close()
323
324 // Check response status
325 if res.StatusCode != http.StatusOK {
326 t.Fatalf("Expected status OK, got %v", res.Status)
327 }
328
329 // Check content type
330 if contentType := res.Header.Get("Content-Type"); contentType != "text/event-stream" {
331 t.Fatalf("Expected Content-Type text/event-stream, got %s", contentType)
332 }
333
334 // Read response events using a scanner
335 scanner := bufio.NewScanner(res.Body)
336
337 // Track events received
338 eventsReceived := map[string]int{
339 "state": 0,
340 "message": 0,
341 "heartbeat": 0,
342 }
343
344 // Read for a short time to capture initial state and messages
345 dataLines := []string{}
346 eventType := ""
347
348 go func() {
349 // After reading for a while, add a new message to test real-time updates
350 time.Sleep(500 * time.Millisecond)
351
352 mockAgent.AddMessage(loop.AgentMessage{
353 Type: loop.ToolUseMessageType,
354 Content: "This is a new real-time message",
355 Timestamp: time.Now(),
356 ToolName: "test_tool",
357 })
358
Philip Zeyligereab12de2025-05-14 02:35:53 +0000359 // Trigger a state transition to test state updates
360 time.Sleep(200 * time.Millisecond)
361 mockAgent.TriggerStateTransition(loop.StateReady, loop.StateSendingToLLM, loop.TransitionEvent{
362 Description: "Agent started thinking",
363 Data: "start_thinking",
364 })
365
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000366 // Let it process for longer
367 time.Sleep(1000 * time.Millisecond)
368 cancel() // Cancel to end the test
369 }()
370
371 // Read events
372 for scanner.Scan() {
373 line := scanner.Text()
374
375 if strings.HasPrefix(line, "event: ") {
376 eventType = strings.TrimPrefix(line, "event: ")
377 eventsReceived[eventType]++
378 } else if strings.HasPrefix(line, "data: ") {
379 dataLines = append(dataLines, line)
380 } else if line == "" && eventType != "" {
381 // End of event
382 eventType = ""
383 }
384
385 // Break if context is done
386 if ctx.Err() != nil {
387 break
388 }
389 }
390
391 if err := scanner.Err(); err != nil && ctx.Err() == nil {
392 t.Fatalf("Scanner error: %v", err)
393 }
394
395 // Simplified validation - just make sure we received something
396 t.Logf("Events received: %v", eventsReceived)
397 t.Logf("Data lines received: %d", len(dataLines))
398
399 // Basic validation that we received at least some events
400 if eventsReceived["state"] == 0 && eventsReceived["message"] == 0 {
401 t.Errorf("Did not receive any events")
402 }
403}
Philip Zeyligerd3ac1122025-05-14 02:54:18 +0000404
405func TestGitRawDiffHandler(t *testing.T) {
406 // Create a mock agent
407 mockAgent := &mockAgent{
Philip Zeyligerbe7802a2025-06-04 20:15:25 +0000408 workingDir: t.TempDir(), // Use a temp directory
409 branchPrefix: "sketch/",
Philip Zeyligerd3ac1122025-05-14 02:54:18 +0000410 }
411
412 // Create the server with the mock agent
413 server, err := server.New(mockAgent, nil)
414 if err != nil {
415 t.Fatalf("Failed to create server: %v", err)
416 }
417
418 // Create a test HTTP server
419 testServer := httptest.NewServer(server)
420 defer testServer.Close()
421
422 // Test missing parameters
423 resp, err := http.Get(testServer.URL + "/git/rawdiff")
424 if err != nil {
425 t.Fatalf("Failed to make HTTP request: %v", err)
426 }
427 if resp.StatusCode != http.StatusBadRequest {
428 t.Errorf("Expected status bad request, got: %d", resp.StatusCode)
429 }
430
431 // Test with commit parameter (this will fail due to no git repo, but we're testing the API, not git)
432 resp, err = http.Get(testServer.URL + "/git/rawdiff?commit=HEAD")
433 if err != nil {
434 t.Fatalf("Failed to make HTTP request: %v", err)
435 }
436 // We expect an error since there's no git repository, but the request should be processed
437 if resp.StatusCode != http.StatusInternalServerError {
438 t.Errorf("Expected status 500, got: %d", resp.StatusCode)
439 }
440
441 // Test with from/to parameters
442 resp, err = http.Get(testServer.URL + "/git/rawdiff?from=HEAD~1&to=HEAD")
443 if err != nil {
444 t.Fatalf("Failed to make HTTP request: %v", err)
445 }
446 // We expect an error since there's no git repository, but the request should be processed
447 if resp.StatusCode != http.StatusInternalServerError {
448 t.Errorf("Expected status 500, got: %d", resp.StatusCode)
449 }
450}
451
452func TestGitShowHandler(t *testing.T) {
453 // Create a mock agent
454 mockAgent := &mockAgent{
Philip Zeyligerbe7802a2025-06-04 20:15:25 +0000455 workingDir: t.TempDir(), // Use a temp directory
456 branchPrefix: "sketch/",
Philip Zeyligerd3ac1122025-05-14 02:54:18 +0000457 }
458
459 // Create the server with the mock agent
460 server, err := server.New(mockAgent, nil)
461 if err != nil {
462 t.Fatalf("Failed to create server: %v", err)
463 }
464
465 // Create a test HTTP server
466 testServer := httptest.NewServer(server)
467 defer testServer.Close()
468
469 // Test missing parameter
470 resp, err := http.Get(testServer.URL + "/git/show")
471 if err != nil {
472 t.Fatalf("Failed to make HTTP request: %v", err)
473 }
474 if resp.StatusCode != http.StatusBadRequest {
475 t.Errorf("Expected status bad request, got: %d", resp.StatusCode)
476 }
477
478 // Test with hash parameter (this will fail due to no git repo, but we're testing the API, not git)
479 resp, err = http.Get(testServer.URL + "/git/show?hash=HEAD")
480 if err != nil {
481 t.Fatalf("Failed to make HTTP request: %v", err)
482 }
483 // We expect an error since there's no git repository, but the request should be processed
484 if resp.StatusCode != http.StatusInternalServerError {
485 t.Errorf("Expected status 500, got: %d", resp.StatusCode)
486 }
487}
488
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700489func TestCompactHandler(t *testing.T) {
490 // Test that mock CompactConversation works
491 mockAgent := &mockAgent{
492 messages: []loop.AgentMessage{},
493 messageCount: 0,
494 sessionID: "test-session",
Philip Zeyligerbe7802a2025-06-04 20:15:25 +0000495 branchPrefix: "sketch/",
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700496 }
497
498 ctx := context.Background()
499 err := mockAgent.CompactConversation(ctx)
500 if err != nil {
501 t.Errorf("Mock CompactConversation failed: %v", err)
502 }
503
504 // No HTTP endpoint to test anymore - compaction is done via /compact message
505 t.Log("Mock CompactConversation works correctly")
506}
Sean McCullough138ec242025-06-02 22:42:06 +0000507
508// TestPortEventsEndpoint tests the /port-events HTTP endpoint
509func TestPortEventsEndpoint(t *testing.T) {
510 // Create a mock agent that implements the CodingAgent interface
Philip Zeyligerbe7802a2025-06-04 20:15:25 +0000511 agent := &mockAgent{
512 branchPrefix: "sketch/",
513 }
Sean McCullough138ec242025-06-02 22:42:06 +0000514
515 // Create a server with the mock agent
516 server, err := server.New(agent, nil)
517 if err != nil {
518 t.Fatalf("Failed to create server: %v", err)
519 }
520
521 // Test GET /port-events
522 req, err := http.NewRequest("GET", "/port-events", nil)
523 if err != nil {
524 t.Fatalf("Failed to create request: %v", err)
525 }
526
527 rr := httptest.NewRecorder()
528 server.ServeHTTP(rr, req)
529
530 // Should return 200 OK
531 if status := rr.Code; status != http.StatusOK {
532 t.Errorf("Expected status code %d, got %d", http.StatusOK, status)
533 }
534
535 // Should return JSON content type
536 contentType := rr.Header().Get("Content-Type")
537 if contentType != "application/json" {
538 t.Errorf("Expected Content-Type application/json, got %s", contentType)
539 }
540
541 // Should return valid JSON (empty array since mock returns no events)
542 var events []any
543 if err := json.Unmarshal(rr.Body.Bytes(), &events); err != nil {
544 t.Errorf("Failed to parse JSON response: %v", err)
545 }
546
547 // Should be empty array for mock agent
548 if len(events) != 0 {
549 t.Errorf("Expected empty events array, got %d events", len(events))
550 }
551}
552
553// TestPortEventsEndpointMethodNotAllowed tests that non-GET requests are rejected
554func TestPortEventsEndpointMethodNotAllowed(t *testing.T) {
Philip Zeyligerbe7802a2025-06-04 20:15:25 +0000555 agent := &mockAgent{
556 branchPrefix: "sketch/",
557 }
Sean McCullough138ec242025-06-02 22:42:06 +0000558 server, err := server.New(agent, nil)
559 if err != nil {
560 t.Fatalf("Failed to create server: %v", err)
561 }
562
563 // Test POST /port-events (should be rejected)
564 req, err := http.NewRequest("POST", "/port-events", nil)
565 if err != nil {
566 t.Fatalf("Failed to create request: %v", err)
567 }
568
569 rr := httptest.NewRecorder()
570 server.ServeHTTP(rr, req)
571
572 // Should return 405 Method Not Allowed
573 if status := rr.Code; status != http.StatusMethodNotAllowed {
574 t.Errorf("Expected status code %d, got %d", http.StatusMethodNotAllowed, status)
575 }
576}