package server import ( "context" "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "github.com/daniel/cursor-adapter/internal/config" ) type mockBridge struct { executeLines []string executeErr error executeSync string executeSyncErr error lastPrompt string lastSessionKey string models []string healthErr error } func (m *mockBridge) Execute(ctx context.Context, prompt string, model string, sessionKey string) (<-chan string, <-chan error) { m.lastPrompt = prompt m.lastSessionKey = sessionKey outputChan := make(chan string, len(m.executeLines)) errChan := make(chan error, 1) go func() { defer close(outputChan) defer close(errChan) for _, line := range m.executeLines { select { case <-ctx.Done(): errChan <- ctx.Err() return case outputChan <- line: } } if m.executeErr != nil { errChan <- m.executeErr } }() return outputChan, errChan } func (m *mockBridge) ListModels(ctx context.Context) ([]string, error) { return m.models, nil } func (m *mockBridge) ExecuteSync(ctx context.Context, prompt string, model string, sessionKey string) (string, error) { m.lastPrompt = prompt m.lastSessionKey = sessionKey if m.executeSyncErr != nil { return "", m.executeSyncErr } if m.executeSync != "" { return m.executeSync, nil } return "", nil } func (m *mockBridge) CheckHealth(ctx context.Context) error { return m.healthErr } func TestAnthropicMessages_NonStreamingResponse(t *testing.T) { cfg := config.Defaults() srv := New(&cfg, &mockBridge{ executeSync: "Hello", }) req := httptest.NewRequest(http.MethodPost, "/v1/messages", strings.NewReader(`{ "model":"auto", "max_tokens":128, "messages":[{"role":"user","content":"Say hello"}], "stream":false }`)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() srv.mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) } var resp struct { ID string `json:"id"` Type string `json:"type"` Role string `json:"role"` Model string `json:"model"` StopReason string `json:"stop_reason"` Content []struct { Type string `json:"type"` Text string `json:"text"` } `json:"content"` Usage struct { InputTokens int `json:"input_tokens"` OutputTokens int `json:"output_tokens"` } `json:"usage"` } if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("unmarshal response: %v", err) } if resp.Type != "message" { t.Fatalf("type = %q, want %q", resp.Type, "message") } if resp.Role != "assistant" { t.Fatalf("role = %q, want %q", resp.Role, "assistant") } if len(resp.Content) != 1 || resp.Content[0].Text != "Hello" { t.Fatalf("content = %+v, want single text block 'Hello'", resp.Content) } if resp.StopReason != "end_turn" { t.Fatalf("stop_reason = %q, want %q", resp.StopReason, "end_turn") } if resp.Usage.InputTokens <= 0 || resp.Usage.OutputTokens <= 0 { t.Fatalf("usage should be estimated and > 0, got %+v", resp.Usage) } } func TestAnthropicMessages_StreamingResponse(t *testing.T) { cfg := config.Defaults() srv := New(&cfg, &mockBridge{ executeLines: []string{ `{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Hi"}]}}`, `{"type":"result","subtype":"success","usage":{"inputTokens":9,"outputTokens":1}}`, }, }) req := httptest.NewRequest(http.MethodPost, "/v1/messages", strings.NewReader(`{ "model":"auto", "max_tokens":128, "messages":[{"role":"user","content":"Say hi"}], "stream":true }`)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() srv.mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) } body := rec.Body.String() for _, want := range []string{ `"type":"message_start"`, `"type":"content_block_start"`, `"type":"content_block_delta"`, `"text":"Hi"`, `"type":"content_block_stop"`, `"type":"message_delta"`, `"stop_reason":"end_turn"`, `"type":"message_stop"`, } { if !strings.Contains(body, want) { t.Fatalf("stream body missing %q: %s", want, body) } } } func TestChatCompletions_ForwardsProvidedSessionHeader(t *testing.T) { cfg := config.Defaults() br := &mockBridge{executeSync: "Hello"} srv := New(&cfg, br) req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ "model":"auto", "messages":[{"role":"user","content":"hello"}], "stream":false }`)) req.Header.Set("Content-Type", "application/json") req.Header.Set(sessionHeaderName, "sess_frontend_123") rec := httptest.NewRecorder() srv.mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) } if br.lastSessionKey != "sess_frontend_123" { t.Fatalf("bridge session key = %q, want %q", br.lastSessionKey, "sess_frontend_123") } if got := rec.Header().Get(sessionHeaderName); got != "sess_frontend_123" { t.Fatalf("response session header = %q, want %q", got, "sess_frontend_123") } } func TestChatCompletions_AcceptsArrayContentBlocks(t *testing.T) { cfg := config.Defaults() br := &mockBridge{executeSync: "Hello"} srv := New(&cfg, br) req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ "model":"auto", "messages":[ {"role":"system","content":[{"type":"text","text":"You are terse."}]}, {"role":"user","content":[{"type":"text","text":"hello"},{"type":"text","text":" world"}]} ], "stream":false }`)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() srv.mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) } // Client system messages should be DROPPED (pure brain mode). if strings.Contains(br.lastPrompt, "You are terse.") { t.Fatalf("prompt should NOT contain client system message, got: %q", br.lastPrompt) } // User text should still be present and concatenated. if !strings.Contains(br.lastPrompt, "user: hello world") { t.Fatalf("prompt = %q, want concatenated user text content", br.lastPrompt) } } func TestChatCompletions_StreamingEmitsRoleFinishReasonAndUsage(t *testing.T) { cfg := config.Defaults() srv := New(&cfg, &mockBridge{ executeLines: []string{ // system + user chunks should be skipped entirely, never echoed as content `{"type":"system","subtype":"init","session_id":"abc","cwd":"/tmp"}`, `{"type":"user","message":{"role":"user","content":[{"type":"text","text":"user: hello"}]}}`, // incremental assistant fragments `{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"你"}]}}`, `{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"好"}]}}`, // cumulative duplicate (Cursor CLI sometimes finalises with the full text) `{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"你好"}]}}`, `{"type":"result","subtype":"success","result":"你好","usage":{"inputTokens":3,"outputTokens":2}}`, }, }) req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ "model":"auto", "messages":[{"role":"user","content":"hi"}], "stream":true }`)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() srv.mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String()) } body := rec.Body.String() // Must never leak system/user JSON as "content" if strings.Contains(body, `"subtype":"init"`) || strings.Contains(body, `"type":"user"`) { t.Fatalf("stream body leaked system/user JSON into SSE content: %s", body) } // First delta chunk must carry role:assistant (not content) if !strings.Contains(body, `"delta":{"role":"assistant"}`) { t.Fatalf("first chunk missing role=assistant delta: %s", body) } // Content deltas must be plain text — not JSON-stringified Cursor lines if !strings.Contains(body, `"delta":{"content":"你"}`) { t.Fatalf("first content delta not plain text: %s", body) } if !strings.Contains(body, `"delta":{"content":"好"}`) { t.Fatalf("second content delta missing: %s", body) } // Final cumulative message that equals accumulated text must be suppressed // (accumulated = "你好" after the two fragments; final "你好" should be Skip'd) count := strings.Count(body, `"你好"`) if count > 0 { t.Fatalf("duplicate final cumulative message should have been skipped (found %d occurrences of full text as delta): %s", count, body) } // Final chunk must have finish_reason=stop and usage at top level if !strings.Contains(body, `"finish_reason":"stop"`) { t.Fatalf("final chunk missing finish_reason=stop: %s", body) } if !strings.Contains(body, `"usage":{`) { t.Fatalf("final chunk missing usage: %s", body) } if !strings.Contains(body, `data: [DONE]`) { t.Fatalf("stream missing [DONE] terminator: %s", body) } } func TestAnthropicMessages_StreamingEmitsNoDuplicateFinalText(t *testing.T) { cfg := config.Defaults() srv := New(&cfg, &mockBridge{ executeLines: []string{ `{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"你"}]}}`, `{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"好"}]}}`, `{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"你好"}]}}`, `{"type":"result","subtype":"success","usage":{"inputTokens":3,"outputTokens":2}}`, }, }) req := httptest.NewRequest(http.MethodPost, "/v1/messages", strings.NewReader(`{ "model":"auto", "max_tokens":128, "messages":[{"role":"user","content":"hi"}], "stream":true }`)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() srv.mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String()) } body := rec.Body.String() if strings.Count(body, `"text":"你好"`) > 0 { t.Fatalf("final cumulative duplicate should be suppressed: %s", body) } for _, want := range []string{ `"text":"你"`, `"text":"好"`, `"type":"message_stop"`, } { if !strings.Contains(body, want) { t.Fatalf("missing %q in stream: %s", want, body) } } } func TestAnthropicMessages_PromptIncludesToolsAndToolHistory(t *testing.T) { cfg := config.Defaults() br := &mockBridge{executeSync: "ok"} srv := New(&cfg, br) body := `{ "model":"auto", "max_tokens":128, "tools":[{"name":"bash","description":"Run a shell command","input_schema":{"type":"object","properties":{"command":{"type":"string"}}}}], "messages":[ {"role":"user","content":[{"type":"text","text":"clean up my desktop"}]}, {"role":"assistant","content":[{"type":"tool_use","id":"toolu_1","name":"bash","input":{"command":"ls ~/Desktop"}}]}, {"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"a.png\nb.txt"}]} ], "stream":false }` req := httptest.NewRequest(http.MethodPost, "/v1/messages", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() srv.mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String()) } prompt := br.lastPrompt for _, want := range []string{ "Available executors", "- bash", "Run a shell command", "", "clean up my desktop", `[tool_call name="bash" input=`, "[tool_result for=toolu_1 status=ok]", "a.png", } { if !strings.Contains(prompt, want) { t.Fatalf("prompt missing %q\nprompt:\n%s", want, prompt) } } } func TestAnthropicMessages_NonStreamTranslatesToolCallToToolUse(t *testing.T) { cfg := config.Defaults() br := &mockBridge{ executeSync: "I'll run it now.\n\n{\"name\":\"bash\",\"input\":{\"command\":\"mkdir -p ~/Desktop/screenshots\"}}\n", } srv := New(&cfg, br) req := httptest.NewRequest(http.MethodPost, "/v1/messages", strings.NewReader(`{ "model":"auto", "max_tokens":128, "tools":[{"name":"bash"}], "messages":[{"role":"user","content":"organize desktop"}], "stream":false }`)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() srv.mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String()) } body := rec.Body.String() for _, want := range []string{ `"stop_reason":"tool_use"`, `"type":"tool_use"`, `"name":"bash"`, `"command":"mkdir -p ~/Desktop/screenshots"`, `"type":"text"`, `I'll run it now.`, } { if !strings.Contains(body, want) { t.Fatalf("response missing %q\nbody=%s", want, body) } } } func TestAnthropicMessages_StreamTranslatesToolCallToToolUseSSE(t *testing.T) { cfg := config.Defaults() srv := New(&cfg, &mockBridge{ executeLines: []string{ `{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"running\n"}]}}`, `{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"running\n\n"}]}}`, `{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"running\n\n{\"name\":\"bash\",\"input\":{\"command\":\"ls\"}}\n"}]}}`, `{"type":"result","subtype":"success","usage":{"inputTokens":3,"outputTokens":2}}`, }, }) req := httptest.NewRequest(http.MethodPost, "/v1/messages", strings.NewReader(`{ "model":"auto", "max_tokens":128, "tools":[{"name":"bash"}], "messages":[{"role":"user","content":"go"}], "stream":true }`)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() srv.mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String()) } body := rec.Body.String() for _, want := range []string{ `"type":"message_start"`, `"type":"content_block_start"`, `"type":"text"`, `"text":"running`, `"type":"tool_use"`, `"name":"bash"`, `"type":"input_json_delta"`, `\"command\":\"ls\"`, `"stop_reason":"tool_use"`, `"type":"message_stop"`, } { if !strings.Contains(body, want) { t.Fatalf("stream missing %q\nbody=%s", want, body) } } if strings.Contains(body, "") { t.Fatalf("stream leaked raw sentinel: %s", body) } } func TestAnthropicMessages_GeneratesSessionHeaderWhenMissing(t *testing.T) { cfg := config.Defaults() br := &mockBridge{executeSync: "Hello"} srv := New(&cfg, br) req := httptest.NewRequest(http.MethodPost, "/v1/messages", strings.NewReader(`{ "model":"auto", "max_tokens":128, "messages":[{"role":"user","content":"hello"}], "stream":false }`)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() srv.mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) } if br.lastSessionKey == "" { t.Fatal("expected generated session key to be forwarded to bridge") } if got := rec.Header().Get(sessionHeaderName); got == "" { t.Fatal("expected generated session header in response") } if got := rec.Header().Get(exposeHeadersName); !strings.Contains(got, sessionHeaderName) { t.Fatalf("expose headers = %q, want to contain %q", got, sessionHeaderName) } }