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