blob: a6dab625fc42329247a656c1c742d7ff1beda63a [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
29 title string
30 branchName string
Philip Zeyligerd3ac1122025-05-14 02:54:18 +000031 workingDir string
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -070032 sessionID string
Philip Zeyligerb5739402025-06-02 07:04:34 -070033 endFeedback *loop.EndFeedback
Philip Zeyliger25f6ff12025-05-02 04:24:10 +000034}
35
36func (m *mockAgent) NewIterator(ctx context.Context, nextMessageIdx int) loop.MessageIterator {
37 m.mu.RLock()
38 // Send existing messages that should be available immediately
39 ch := make(chan *loop.AgentMessage, 100)
40 iter := &mockIterator{
41 agent: m,
42 ctx: ctx,
43 nextMessageIdx: nextMessageIdx,
44 ch: ch,
45 }
46 m.mu.RUnlock()
47 return iter
48}
49
50type mockIterator struct {
51 agent *mockAgent
52 ctx context.Context
53 nextMessageIdx int
54 ch chan *loop.AgentMessage
55 subscribed bool
56}
57
58func (m *mockIterator) Next() *loop.AgentMessage {
59 if !m.subscribed {
60 m.agent.mu.Lock()
61 m.agent.subscribers = append(m.agent.subscribers, m.ch)
62 m.agent.mu.Unlock()
63 m.subscribed = true
64 }
65
66 for {
67 select {
68 case <-m.ctx.Done():
69 return nil
70 case msg := <-m.ch:
71 return msg
72 }
73 }
74}
75
76func (m *mockIterator) Close() {
77 // Remove from subscribers using slices.Delete
78 m.agent.mu.Lock()
79 for i, ch := range m.agent.subscribers {
80 if ch == m.ch {
81 m.agent.subscribers = slices.Delete(m.agent.subscribers, i, i+1)
82 break
83 }
84 }
85 m.agent.mu.Unlock()
86 close(m.ch)
87}
88
89func (m *mockAgent) Messages(start int, end int) []loop.AgentMessage {
90 m.mu.RLock()
91 defer m.mu.RUnlock()
92
93 if start >= len(m.messages) || end > len(m.messages) || start < 0 || end < 0 {
94 return []loop.AgentMessage{}
95 }
96 return slices.Clone(m.messages[start:end])
97}
98
99func (m *mockAgent) MessageCount() int {
100 m.mu.RLock()
101 defer m.mu.RUnlock()
102 return m.messageCount
103}
104
105func (m *mockAgent) AddMessage(msg loop.AgentMessage) {
106 m.mu.Lock()
107 msg.Idx = m.messageCount
108 m.messages = append(m.messages, msg)
109 m.messageCount++
110
111 // Create a copy of subscribers to avoid holding the lock while sending
112 subscribers := make([]chan *loop.AgentMessage, len(m.subscribers))
113 copy(subscribers, m.subscribers)
114 m.mu.Unlock()
115
116 // Notify subscribers
117 msgCopy := msg // Create a copy to avoid race conditions
118 for _, ch := range subscribers {
119 ch <- &msgCopy
120 }
121}
122
Philip Zeyligereab12de2025-05-14 02:35:53 +0000123func (m *mockAgent) NewStateTransitionIterator(ctx context.Context) loop.StateTransitionIterator {
124 m.mu.Lock()
125 ch := make(chan loop.StateTransition, 10)
126 m.stateTransitionListeners = append(m.stateTransitionListeners, ch)
127 m.mu.Unlock()
128
129 return &mockStateTransitionIterator{
130 agent: m,
131 ctx: ctx,
132 ch: ch,
133 }
134}
135
136type mockStateTransitionIterator struct {
137 agent *mockAgent
138 ctx context.Context
139 ch chan loop.StateTransition
140}
141
142func (m *mockStateTransitionIterator) Next() *loop.StateTransition {
143 select {
144 case <-m.ctx.Done():
145 return nil
146 case transition, ok := <-m.ch:
147 if !ok {
148 return nil
149 }
150 transitionCopy := transition
151 return &transitionCopy
152 }
153}
154
155func (m *mockStateTransitionIterator) Close() {
156 m.agent.mu.Lock()
157 for i, ch := range m.agent.stateTransitionListeners {
158 if ch == m.ch {
159 m.agent.stateTransitionListeners = slices.Delete(m.agent.stateTransitionListeners, i, i+1)
160 break
161 }
162 }
163 m.agent.mu.Unlock()
164 close(m.ch)
165}
166
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000167func (m *mockAgent) CurrentStateName() string {
168 m.mu.RLock()
169 defer m.mu.RUnlock()
170 return m.currentState
171}
172
Philip Zeyligereab12de2025-05-14 02:35:53 +0000173func (m *mockAgent) TriggerStateTransition(from, to loop.State, event loop.TransitionEvent) {
174 m.mu.Lock()
175 m.currentState = to.String()
176 transition := loop.StateTransition{
177 From: from,
178 To: to,
179 Event: event,
180 }
181
182 // Create a copy of listeners to avoid holding the lock while sending
183 listeners := make([]chan loop.StateTransition, len(m.stateTransitionListeners))
184 copy(listeners, m.stateTransitionListeners)
185 m.mu.Unlock()
186
187 // Notify listeners
188 for _, ch := range listeners {
189 ch <- transition
190 }
191}
192
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000193func (m *mockAgent) InitialCommit() string {
194 m.mu.RLock()
195 defer m.mu.RUnlock()
196 return m.initialCommit
197}
198
Philip Zeyliger49edc922025-05-14 09:45:45 -0700199func (m *mockAgent) SketchGitBase() string {
200 m.mu.RLock()
201 defer m.mu.RUnlock()
202 return m.initialCommit
203}
204
Philip Zeyligerd3ac1122025-05-14 02:54:18 +0000205func (m *mockAgent) SketchGitBaseRef() string {
206 return "sketch-base-test-session"
207}
208
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000209func (m *mockAgent) Title() string {
210 m.mu.RLock()
211 defer m.mu.RUnlock()
212 return m.title
213}
214
215func (m *mockAgent) BranchName() string {
216 m.mu.RLock()
217 defer m.mu.RUnlock()
218 return m.branchName
219}
220
221// Other required methods of loop.CodingAgent with minimal implementation
222func (m *mockAgent) Init(loop.AgentInit) error { return nil }
223func (m *mockAgent) Ready() <-chan struct{} { ch := make(chan struct{}); close(ch); return ch }
224func (m *mockAgent) URL() string { return "http://localhost:8080" }
225func (m *mockAgent) UserMessage(ctx context.Context, msg string) {}
226func (m *mockAgent) Loop(ctx context.Context) {}
227func (m *mockAgent) CancelTurn(cause error) {}
228func (m *mockAgent) CancelToolUse(id string, cause error) error { return nil }
229func (m *mockAgent) TotalUsage() conversation.CumulativeUsage { return conversation.CumulativeUsage{} }
230func (m *mockAgent) OriginalBudget() conversation.Budget { return conversation.Budget{} }
Philip Zeyligerd3ac1122025-05-14 02:54:18 +0000231func (m *mockAgent) WorkingDir() string { return m.workingDir }
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +0000232func (m *mockAgent) RepoRoot() string { return m.workingDir }
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000233func (m *mockAgent) Diff(commit *string) (string, error) { return "", nil }
234func (m *mockAgent) OS() string { return "linux" }
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700235func (m *mockAgent) SessionID() string { return m.sessionID }
236func (m *mockAgent) CurrentTodoContent() string { return "" } // Mock returns empty for simplicity
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000237func (m *mockAgent) OutstandingLLMCallCount() int { return 0 }
238func (m *mockAgent) OutstandingToolCalls() []string { return nil }
239func (m *mockAgent) OutsideOS() string { return "linux" }
240func (m *mockAgent) OutsideHostname() string { return "test-host" }
241func (m *mockAgent) OutsideWorkingDir() string { return "/app" }
242func (m *mockAgent) GitOrigin() string { return "" }
243func (m *mockAgent) OpenBrowser(url string) {}
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700244func (m *mockAgent) CompactConversation(ctx context.Context) error {
245 // Mock implementation - just return nil
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000246 return nil
247}
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700248func (m *mockAgent) IsInContainer() bool { return false }
249func (m *mockAgent) FirstMessageIndex() int { return 0 }
250func (m *mockAgent) DetectGitChanges(ctx context.Context) error { return nil }
251func (m *mockAgent) GetEndFeedback() *loop.EndFeedback { return m.endFeedback }
252func (m *mockAgent) SetEndFeedback(feedback *loop.EndFeedback) { m.endFeedback = feedback }
Sean McCullough138ec242025-06-02 22:42:06 +0000253func (m *mockAgent) GetPortMonitor() *loop.PortMonitor { return loop.NewPortMonitor() }
Philip Zeyligerb5739402025-06-02 07:04:34 -0700254
255// TestEndFeedback tests the end session feedback functionality
256func TestEndFeedback(t *testing.T) {
257 // Test basic EndFeedback struct functionality
258 feedback := &loop.EndFeedback{
259 Happy: true,
260 Comment: "Great experience!",
261 }
262
263 if feedback.Happy != true {
264 t.Errorf("Expected Happy to be true, got %v", feedback.Happy)
265 }
266 if feedback.Comment != "Great experience!" {
267 t.Errorf("Expected Comment to be 'Great experience!', got %s", feedback.Comment)
268 }
269
270 // Test mock agent methods
271 mockAgent := &mockAgent{
272 sessionID: "test-session",
273 workingDir: "/test",
274 messageCount: 0,
275 }
276
277 // Test initial state (no feedback)
278 if mockAgent.GetEndFeedback() != nil {
279 t.Error("Expected initial feedback to be nil")
280 }
281
282 // Test setting feedback
283 mockAgent.SetEndFeedback(feedback)
284 retrieved := mockAgent.GetEndFeedback()
285 if retrieved == nil {
286 t.Error("Expected feedback to be set, got nil")
287 } else {
288 if retrieved.Happy != true {
289 t.Errorf("Expected Happy to be true, got %v", retrieved.Happy)
290 }
291 if retrieved.Comment != "Great experience!" {
292 t.Errorf("Expected Comment to be 'Great experience!', got %s", retrieved.Comment)
293 }
294 }
295
296 // Test setting different feedback
297 negativeFeedback := &loop.EndFeedback{
298 Happy: false,
299 Comment: "Could be better",
300 }
301 mockAgent.SetEndFeedback(negativeFeedback)
302 retrieved = mockAgent.GetEndFeedback()
303 if retrieved == nil {
304 t.Error("Expected feedback to be set, got nil")
305 } else {
306 if retrieved.Happy != false {
307 t.Errorf("Expected Happy to be false, got %v", retrieved.Happy)
308 }
309 if retrieved.Comment != "Could be better" {
310 t.Errorf("Expected Comment to be 'Could be better', got %s", retrieved.Comment)
311 }
312 }
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700313}
314
315// TestSSEStream tests the SSE stream endpoint
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000316func TestSSEStream(t *testing.T) {
317 // Create a mock agent with initial messages
318 mockAgent := &mockAgent{
Philip Zeyligereab12de2025-05-14 02:35:53 +0000319 messages: []loop.AgentMessage{},
320 messageCount: 0,
321 currentState: "Ready",
322 subscribers: []chan *loop.AgentMessage{},
323 stateTransitionListeners: []chan loop.StateTransition{},
324 initialCommit: "abcd1234",
325 title: "Test Title",
326 branchName: "sketch/test-branch",
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000327 }
328
329 // Add the initial messages before creating the server
330 // to ensure they're available in the Messages slice
331 msg1 := loop.AgentMessage{
332 Type: loop.UserMessageType,
333 Content: "Hello, this is a test message",
334 Timestamp: time.Now(),
335 }
336 mockAgent.messages = append(mockAgent.messages, msg1)
337 msg1.Idx = mockAgent.messageCount
338 mockAgent.messageCount++
339
340 msg2 := loop.AgentMessage{
341 Type: loop.AgentMessageType,
342 Content: "This is a response message",
343 Timestamp: time.Now(),
344 EndOfTurn: true,
345 }
346 mockAgent.messages = append(mockAgent.messages, msg2)
347 msg2.Idx = mockAgent.messageCount
348 mockAgent.messageCount++
349
350 // Create a server with the mock agent
351 srv, err := server.New(mockAgent, nil)
352 if err != nil {
353 t.Fatalf("Failed to create server: %v", err)
354 }
355
356 // Create a test server
357 ts := httptest.NewServer(srv)
358 defer ts.Close()
359
360 // Create a context with cancellation for the client request
361 ctx, cancel := context.WithCancel(context.Background())
362
363 // Create a request to the /stream endpoint
364 req, err := http.NewRequestWithContext(ctx, "GET", ts.URL+"/stream?from=0", nil)
365 if err != nil {
366 t.Fatalf("Failed to create request: %v", err)
367 }
368
369 // Execute the request
370 res, err := http.DefaultClient.Do(req)
371 if err != nil {
372 t.Fatalf("Failed to execute request: %v", err)
373 }
374 defer res.Body.Close()
375
376 // Check response status
377 if res.StatusCode != http.StatusOK {
378 t.Fatalf("Expected status OK, got %v", res.Status)
379 }
380
381 // Check content type
382 if contentType := res.Header.Get("Content-Type"); contentType != "text/event-stream" {
383 t.Fatalf("Expected Content-Type text/event-stream, got %s", contentType)
384 }
385
386 // Read response events using a scanner
387 scanner := bufio.NewScanner(res.Body)
388
389 // Track events received
390 eventsReceived := map[string]int{
391 "state": 0,
392 "message": 0,
393 "heartbeat": 0,
394 }
395
396 // Read for a short time to capture initial state and messages
397 dataLines := []string{}
398 eventType := ""
399
400 go func() {
401 // After reading for a while, add a new message to test real-time updates
402 time.Sleep(500 * time.Millisecond)
403
404 mockAgent.AddMessage(loop.AgentMessage{
405 Type: loop.ToolUseMessageType,
406 Content: "This is a new real-time message",
407 Timestamp: time.Now(),
408 ToolName: "test_tool",
409 })
410
Philip Zeyligereab12de2025-05-14 02:35:53 +0000411 // Trigger a state transition to test state updates
412 time.Sleep(200 * time.Millisecond)
413 mockAgent.TriggerStateTransition(loop.StateReady, loop.StateSendingToLLM, loop.TransitionEvent{
414 Description: "Agent started thinking",
415 Data: "start_thinking",
416 })
417
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000418 // Let it process for longer
419 time.Sleep(1000 * time.Millisecond)
420 cancel() // Cancel to end the test
421 }()
422
423 // Read events
424 for scanner.Scan() {
425 line := scanner.Text()
426
427 if strings.HasPrefix(line, "event: ") {
428 eventType = strings.TrimPrefix(line, "event: ")
429 eventsReceived[eventType]++
430 } else if strings.HasPrefix(line, "data: ") {
431 dataLines = append(dataLines, line)
432 } else if line == "" && eventType != "" {
433 // End of event
434 eventType = ""
435 }
436
437 // Break if context is done
438 if ctx.Err() != nil {
439 break
440 }
441 }
442
443 if err := scanner.Err(); err != nil && ctx.Err() == nil {
444 t.Fatalf("Scanner error: %v", err)
445 }
446
447 // Simplified validation - just make sure we received something
448 t.Logf("Events received: %v", eventsReceived)
449 t.Logf("Data lines received: %d", len(dataLines))
450
451 // Basic validation that we received at least some events
452 if eventsReceived["state"] == 0 && eventsReceived["message"] == 0 {
453 t.Errorf("Did not receive any events")
454 }
455}
Philip Zeyligerd3ac1122025-05-14 02:54:18 +0000456
457func TestGitRawDiffHandler(t *testing.T) {
458 // Create a mock agent
459 mockAgent := &mockAgent{
460 workingDir: t.TempDir(), // Use a temp directory
461 }
462
463 // Create the server with the mock agent
464 server, err := server.New(mockAgent, nil)
465 if err != nil {
466 t.Fatalf("Failed to create server: %v", err)
467 }
468
469 // Create a test HTTP server
470 testServer := httptest.NewServer(server)
471 defer testServer.Close()
472
473 // Test missing parameters
474 resp, err := http.Get(testServer.URL + "/git/rawdiff")
475 if err != nil {
476 t.Fatalf("Failed to make HTTP request: %v", err)
477 }
478 if resp.StatusCode != http.StatusBadRequest {
479 t.Errorf("Expected status bad request, got: %d", resp.StatusCode)
480 }
481
482 // Test with commit parameter (this will fail due to no git repo, but we're testing the API, not git)
483 resp, err = http.Get(testServer.URL + "/git/rawdiff?commit=HEAD")
484 if err != nil {
485 t.Fatalf("Failed to make HTTP request: %v", err)
486 }
487 // We expect an error since there's no git repository, but the request should be processed
488 if resp.StatusCode != http.StatusInternalServerError {
489 t.Errorf("Expected status 500, got: %d", resp.StatusCode)
490 }
491
492 // Test with from/to parameters
493 resp, err = http.Get(testServer.URL + "/git/rawdiff?from=HEAD~1&to=HEAD")
494 if err != nil {
495 t.Fatalf("Failed to make HTTP request: %v", err)
496 }
497 // We expect an error since there's no git repository, but the request should be processed
498 if resp.StatusCode != http.StatusInternalServerError {
499 t.Errorf("Expected status 500, got: %d", resp.StatusCode)
500 }
501}
502
503func TestGitShowHandler(t *testing.T) {
504 // Create a mock agent
505 mockAgent := &mockAgent{
506 workingDir: t.TempDir(), // Use a temp directory
507 }
508
509 // Create the server with the mock agent
510 server, err := server.New(mockAgent, nil)
511 if err != nil {
512 t.Fatalf("Failed to create server: %v", err)
513 }
514
515 // Create a test HTTP server
516 testServer := httptest.NewServer(server)
517 defer testServer.Close()
518
519 // Test missing parameter
520 resp, err := http.Get(testServer.URL + "/git/show")
521 if err != nil {
522 t.Fatalf("Failed to make HTTP request: %v", err)
523 }
524 if resp.StatusCode != http.StatusBadRequest {
525 t.Errorf("Expected status bad request, got: %d", resp.StatusCode)
526 }
527
528 // Test with hash parameter (this will fail due to no git repo, but we're testing the API, not git)
529 resp, err = http.Get(testServer.URL + "/git/show?hash=HEAD")
530 if err != nil {
531 t.Fatalf("Failed to make HTTP request: %v", err)
532 }
533 // We expect an error since there's no git repository, but the request should be processed
534 if resp.StatusCode != http.StatusInternalServerError {
535 t.Errorf("Expected status 500, got: %d", resp.StatusCode)
536 }
537}
538
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700539func TestCompactHandler(t *testing.T) {
540 // Test that mock CompactConversation works
541 mockAgent := &mockAgent{
542 messages: []loop.AgentMessage{},
543 messageCount: 0,
544 sessionID: "test-session",
545 }
546
547 ctx := context.Background()
548 err := mockAgent.CompactConversation(ctx)
549 if err != nil {
550 t.Errorf("Mock CompactConversation failed: %v", err)
551 }
552
553 // No HTTP endpoint to test anymore - compaction is done via /compact message
554 t.Log("Mock CompactConversation works correctly")
555}
Sean McCullough138ec242025-06-02 22:42:06 +0000556
557// TestPortEventsEndpoint tests the /port-events HTTP endpoint
558func TestPortEventsEndpoint(t *testing.T) {
559 // Create a mock agent that implements the CodingAgent interface
560 agent := &mockAgent{}
561
562 // Create a server with the mock agent
563 server, err := server.New(agent, nil)
564 if err != nil {
565 t.Fatalf("Failed to create server: %v", err)
566 }
567
568 // Test GET /port-events
569 req, err := http.NewRequest("GET", "/port-events", nil)
570 if err != nil {
571 t.Fatalf("Failed to create request: %v", err)
572 }
573
574 rr := httptest.NewRecorder()
575 server.ServeHTTP(rr, req)
576
577 // Should return 200 OK
578 if status := rr.Code; status != http.StatusOK {
579 t.Errorf("Expected status code %d, got %d", http.StatusOK, status)
580 }
581
582 // Should return JSON content type
583 contentType := rr.Header().Get("Content-Type")
584 if contentType != "application/json" {
585 t.Errorf("Expected Content-Type application/json, got %s", contentType)
586 }
587
588 // Should return valid JSON (empty array since mock returns no events)
589 var events []any
590 if err := json.Unmarshal(rr.Body.Bytes(), &events); err != nil {
591 t.Errorf("Failed to parse JSON response: %v", err)
592 }
593
594 // Should be empty array for mock agent
595 if len(events) != 0 {
596 t.Errorf("Expected empty events array, got %d events", len(events))
597 }
598}
599
600// TestPortEventsEndpointMethodNotAllowed tests that non-GET requests are rejected
601func TestPortEventsEndpointMethodNotAllowed(t *testing.T) {
602 agent := &mockAgent{}
603 server, err := server.New(agent, nil)
604 if err != nil {
605 t.Fatalf("Failed to create server: %v", err)
606 }
607
608 // Test POST /port-events (should be rejected)
609 req, err := http.NewRequest("POST", "/port-events", nil)
610 if err != nil {
611 t.Fatalf("Failed to create request: %v", err)
612 }
613
614 rr := httptest.NewRecorder()
615 server.ServeHTTP(rr, req)
616
617 // Should return 405 Method Not Allowed
618 if status := rr.Code; status != http.StatusMethodNotAllowed {
619 t.Errorf("Expected status code %d, got %d", http.StatusMethodNotAllowed, status)
620 }
621}