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