blob: d755717eb2e8890c2edadbb3120cab7fdfd2870c [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 Zeyligerb5739402025-06-02 07:04:34 -070032 endFeedback *loop.EndFeedback
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 }
250func (m *mockAgent) GetEndFeedback() *loop.EndFeedback { return m.endFeedback }
251func (m *mockAgent) SetEndFeedback(feedback *loop.EndFeedback) { m.endFeedback = feedback }
Philip Zeyligerb5739402025-06-02 07:04:34 -0700252
253// TestEndFeedback tests the end session feedback functionality
254func TestEndFeedback(t *testing.T) {
255 // Test basic EndFeedback struct functionality
256 feedback := &loop.EndFeedback{
257 Happy: true,
258 Comment: "Great experience!",
259 }
260
261 if feedback.Happy != true {
262 t.Errorf("Expected Happy to be true, got %v", feedback.Happy)
263 }
264 if feedback.Comment != "Great experience!" {
265 t.Errorf("Expected Comment to be 'Great experience!', got %s", feedback.Comment)
266 }
267
268 // Test mock agent methods
269 mockAgent := &mockAgent{
270 sessionID: "test-session",
271 workingDir: "/test",
272 messageCount: 0,
273 }
274
275 // Test initial state (no feedback)
276 if mockAgent.GetEndFeedback() != nil {
277 t.Error("Expected initial feedback to be nil")
278 }
279
280 // Test setting feedback
281 mockAgent.SetEndFeedback(feedback)
282 retrieved := mockAgent.GetEndFeedback()
283 if retrieved == nil {
284 t.Error("Expected feedback to be set, got nil")
285 } else {
286 if retrieved.Happy != true {
287 t.Errorf("Expected Happy to be true, got %v", retrieved.Happy)
288 }
289 if retrieved.Comment != "Great experience!" {
290 t.Errorf("Expected Comment to be 'Great experience!', got %s", retrieved.Comment)
291 }
292 }
293
294 // Test setting different feedback
295 negativeFeedback := &loop.EndFeedback{
296 Happy: false,
297 Comment: "Could be better",
298 }
299 mockAgent.SetEndFeedback(negativeFeedback)
300 retrieved = mockAgent.GetEndFeedback()
301 if retrieved == nil {
302 t.Error("Expected feedback to be set, got nil")
303 } else {
304 if retrieved.Happy != false {
305 t.Errorf("Expected Happy to be false, got %v", retrieved.Happy)
306 }
307 if retrieved.Comment != "Could be better" {
308 t.Errorf("Expected Comment to be 'Could be better', got %s", retrieved.Comment)
309 }
310 }
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700311}
312
313// TestSSEStream tests the SSE stream endpoint
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000314func TestSSEStream(t *testing.T) {
315 // Create a mock agent with initial messages
316 mockAgent := &mockAgent{
Philip Zeyligereab12de2025-05-14 02:35:53 +0000317 messages: []loop.AgentMessage{},
318 messageCount: 0,
319 currentState: "Ready",
320 subscribers: []chan *loop.AgentMessage{},
321 stateTransitionListeners: []chan loop.StateTransition{},
322 initialCommit: "abcd1234",
323 title: "Test Title",
324 branchName: "sketch/test-branch",
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000325 }
326
327 // Add the initial messages before creating the server
328 // to ensure they're available in the Messages slice
329 msg1 := loop.AgentMessage{
330 Type: loop.UserMessageType,
331 Content: "Hello, this is a test message",
332 Timestamp: time.Now(),
333 }
334 mockAgent.messages = append(mockAgent.messages, msg1)
335 msg1.Idx = mockAgent.messageCount
336 mockAgent.messageCount++
337
338 msg2 := loop.AgentMessage{
339 Type: loop.AgentMessageType,
340 Content: "This is a response message",
341 Timestamp: time.Now(),
342 EndOfTurn: true,
343 }
344 mockAgent.messages = append(mockAgent.messages, msg2)
345 msg2.Idx = mockAgent.messageCount
346 mockAgent.messageCount++
347
348 // Create a server with the mock agent
349 srv, err := server.New(mockAgent, nil)
350 if err != nil {
351 t.Fatalf("Failed to create server: %v", err)
352 }
353
354 // Create a test server
355 ts := httptest.NewServer(srv)
356 defer ts.Close()
357
358 // Create a context with cancellation for the client request
359 ctx, cancel := context.WithCancel(context.Background())
360
361 // Create a request to the /stream endpoint
362 req, err := http.NewRequestWithContext(ctx, "GET", ts.URL+"/stream?from=0", nil)
363 if err != nil {
364 t.Fatalf("Failed to create request: %v", err)
365 }
366
367 // Execute the request
368 res, err := http.DefaultClient.Do(req)
369 if err != nil {
370 t.Fatalf("Failed to execute request: %v", err)
371 }
372 defer res.Body.Close()
373
374 // Check response status
375 if res.StatusCode != http.StatusOK {
376 t.Fatalf("Expected status OK, got %v", res.Status)
377 }
378
379 // Check content type
380 if contentType := res.Header.Get("Content-Type"); contentType != "text/event-stream" {
381 t.Fatalf("Expected Content-Type text/event-stream, got %s", contentType)
382 }
383
384 // Read response events using a scanner
385 scanner := bufio.NewScanner(res.Body)
386
387 // Track events received
388 eventsReceived := map[string]int{
389 "state": 0,
390 "message": 0,
391 "heartbeat": 0,
392 }
393
394 // Read for a short time to capture initial state and messages
395 dataLines := []string{}
396 eventType := ""
397
398 go func() {
399 // After reading for a while, add a new message to test real-time updates
400 time.Sleep(500 * time.Millisecond)
401
402 mockAgent.AddMessage(loop.AgentMessage{
403 Type: loop.ToolUseMessageType,
404 Content: "This is a new real-time message",
405 Timestamp: time.Now(),
406 ToolName: "test_tool",
407 })
408
Philip Zeyligereab12de2025-05-14 02:35:53 +0000409 // Trigger a state transition to test state updates
410 time.Sleep(200 * time.Millisecond)
411 mockAgent.TriggerStateTransition(loop.StateReady, loop.StateSendingToLLM, loop.TransitionEvent{
412 Description: "Agent started thinking",
413 Data: "start_thinking",
414 })
415
Philip Zeyliger25f6ff12025-05-02 04:24:10 +0000416 // Let it process for longer
417 time.Sleep(1000 * time.Millisecond)
418 cancel() // Cancel to end the test
419 }()
420
421 // Read events
422 for scanner.Scan() {
423 line := scanner.Text()
424
425 if strings.HasPrefix(line, "event: ") {
426 eventType = strings.TrimPrefix(line, "event: ")
427 eventsReceived[eventType]++
428 } else if strings.HasPrefix(line, "data: ") {
429 dataLines = append(dataLines, line)
430 } else if line == "" && eventType != "" {
431 // End of event
432 eventType = ""
433 }
434
435 // Break if context is done
436 if ctx.Err() != nil {
437 break
438 }
439 }
440
441 if err := scanner.Err(); err != nil && ctx.Err() == nil {
442 t.Fatalf("Scanner error: %v", err)
443 }
444
445 // Simplified validation - just make sure we received something
446 t.Logf("Events received: %v", eventsReceived)
447 t.Logf("Data lines received: %d", len(dataLines))
448
449 // Basic validation that we received at least some events
450 if eventsReceived["state"] == 0 && eventsReceived["message"] == 0 {
451 t.Errorf("Did not receive any events")
452 }
453}
Philip Zeyligerd3ac1122025-05-14 02:54:18 +0000454
455func TestGitRawDiffHandler(t *testing.T) {
456 // Create a mock agent
457 mockAgent := &mockAgent{
458 workingDir: t.TempDir(), // Use a temp directory
459 }
460
461 // Create the server with the mock agent
462 server, err := server.New(mockAgent, nil)
463 if err != nil {
464 t.Fatalf("Failed to create server: %v", err)
465 }
466
467 // Create a test HTTP server
468 testServer := httptest.NewServer(server)
469 defer testServer.Close()
470
471 // Test missing parameters
472 resp, err := http.Get(testServer.URL + "/git/rawdiff")
473 if err != nil {
474 t.Fatalf("Failed to make HTTP request: %v", err)
475 }
476 if resp.StatusCode != http.StatusBadRequest {
477 t.Errorf("Expected status bad request, got: %d", resp.StatusCode)
478 }
479
480 // Test with commit parameter (this will fail due to no git repo, but we're testing the API, not git)
481 resp, err = http.Get(testServer.URL + "/git/rawdiff?commit=HEAD")
482 if err != nil {
483 t.Fatalf("Failed to make HTTP request: %v", err)
484 }
485 // We expect an error since there's no git repository, but the request should be processed
486 if resp.StatusCode != http.StatusInternalServerError {
487 t.Errorf("Expected status 500, got: %d", resp.StatusCode)
488 }
489
490 // Test with from/to parameters
491 resp, err = http.Get(testServer.URL + "/git/rawdiff?from=HEAD~1&to=HEAD")
492 if err != nil {
493 t.Fatalf("Failed to make HTTP request: %v", err)
494 }
495 // We expect an error since there's no git repository, but the request should be processed
496 if resp.StatusCode != http.StatusInternalServerError {
497 t.Errorf("Expected status 500, got: %d", resp.StatusCode)
498 }
499}
500
501func TestGitShowHandler(t *testing.T) {
502 // Create a mock agent
503 mockAgent := &mockAgent{
504 workingDir: t.TempDir(), // Use a temp directory
505 }
506
507 // Create the server with the mock agent
508 server, err := server.New(mockAgent, nil)
509 if err != nil {
510 t.Fatalf("Failed to create server: %v", err)
511 }
512
513 // Create a test HTTP server
514 testServer := httptest.NewServer(server)
515 defer testServer.Close()
516
517 // Test missing parameter
518 resp, err := http.Get(testServer.URL + "/git/show")
519 if err != nil {
520 t.Fatalf("Failed to make HTTP request: %v", err)
521 }
522 if resp.StatusCode != http.StatusBadRequest {
523 t.Errorf("Expected status bad request, got: %d", resp.StatusCode)
524 }
525
526 // Test with hash parameter (this will fail due to no git repo, but we're testing the API, not git)
527 resp, err = http.Get(testServer.URL + "/git/show?hash=HEAD")
528 if err != nil {
529 t.Fatalf("Failed to make HTTP request: %v", err)
530 }
531 // We expect an error since there's no git repository, but the request should be processed
532 if resp.StatusCode != http.StatusInternalServerError {
533 t.Errorf("Expected status 500, got: %d", resp.StatusCode)
534 }
535}
536
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700537func TestCompactHandler(t *testing.T) {
538 // Test that mock CompactConversation works
539 mockAgent := &mockAgent{
540 messages: []loop.AgentMessage{},
541 messageCount: 0,
542 sessionID: "test-session",
543 }
544
545 ctx := context.Background()
546 err := mockAgent.CompactConversation(ctx)
547 if err != nil {
548 t.Errorf("Mock CompactConversation failed: %v", err)
549 }
550
551 // No HTTP endpoint to test anymore - compaction is done via /compact message
552 t.Log("Mock CompactConversation works correctly")
553}