package opencode import ( "context" "encoding/json" "errors" "net/http" "net/http/httptest" "testing" "time" ) // fakeAPIServer — минимальный фейк opencode serve experimental HTTP API // (пути БЕЗ префикса /api). type fakeAPIServer struct { messages []message failCreate bool verdictParts []part // ответ на POST /session/{id}/message (вердикт) blockPrompt bool // POST /message блокируется до отмены ctx (эмуляция зависания) } func (f *fakeAPIServer) handler() http.Handler { mux := http.NewServeMux() mux.HandleFunc("/session", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { w.WriteHeader(http.StatusMethodNotAllowed) return } if f.failCreate { http.Error(w, "boom", http.StatusInternalServerError) return } // experimental: голая Session (без обёртки {data}). writeJSON(w, map[string]any{"id": "sess-fake"}) }) mux.HandleFunc("/session/{id}/abort", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { w.WriteHeader(http.StatusMethodNotAllowed) return } writeJSON(w, map[string]any{}) }) mux.HandleFunc("/session/{id}/message", func(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodPost: if f.blockPrompt { // Эмуляция «зависшего» агента: ответ приходит позже idle-таймаута, // но handler всё равно завершится, чтобы не блокировать shutdown. select { case <-r.Context().Done(): case <-time.After(2 * time.Second): } w.WriteHeader(http.StatusRequestTimeout) return } // блокирующий ответ: {info, parts}, где вердикт — text-части. info := map[string]any{"role": "assistant"} parts := f.verdictParts if parts == nil { parts = []part{} } writeJSON(w, map[string]any{"info": info, "parts": parts}) case http.MethodGet: // голый массив [{info, parts}]. if f.messages == nil { writeJSON(w, []message{}) return } writeJSON(w, f.messages) default: w.WriteHeader(http.StatusMethodNotAllowed) } }) return mux } func writeJSON(w http.ResponseWriter, v any) { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(v) } // fakeClient — клиент к фейк-серверу. func fakeClient(t *testing.T, f *fakeAPIServer) *Client { t.Helper() ts := httptest.NewServer(f.handler()) t.Cleanup(ts.Close) return &Client{BaseURL: ts.URL} } func TestClient_CreateSession(t *testing.T) { c := fakeClient(t, &fakeAPIServer{}) id, err := c.CreateSession(context.Background(), "ratatoskr-analyst") if err != nil { t.Fatalf("CreateSession err: %v", err) } if id != "sess-fake" { t.Errorf("id = %q, want sess-fake", id) } } func TestClient_CreateSessionFail(t *testing.T) { c := fakeClient(t, &fakeAPIServer{failCreate: true}) if _, err := c.CreateSession(context.Background(), "x"); err == nil { t.Fatal("CreateSession должен упасть при 500, а не nil") } } func TestClient_Send(t *testing.T) { c := fakeClient(t, &fakeAPIServer{ verdictParts: []part{{Type: "text", Text: `{"phase":"ready"}`}}, }) vd, err := c.Send(context.Background(), "sess-fake", "почини x") if err != nil { t.Fatalf("Send err: %v", err) } if vd != `{"phase":"ready"}` { t.Errorf("verdict = %q, want вердикт модели", vd) } } func TestClient_SendNoText(t *testing.T) { c := fakeClient(t, &fakeAPIServer{}) // нет text-части в ответе if _, err := c.Send(context.Background(), "sess-fake", "почини x"); err == nil { t.Fatal("Send должен упасть, когда нет text-части") } else { var ce *ClientErr if !errors.As(err, &ce) { t.Errorf("ожидался *ClientErr, got %T", err) } } } func TestClient_textCount(t *testing.T) { f := &fakeAPIServer{messages: []message{{ Info: struct { Role string `json:"role"` }{Role: "assistant"}, Parts: []part{{Type: "text", Text: "a"}, {Type: "reasoning", Text: "x"}}, }}} c := fakeClient(t, f) n, err := c.textCount(context.Background(), "sess-fake") if err != nil { t.Fatalf("textCount err: %v", err) } if n != 1 { t.Errorf("textCount = %d, want 1 (одна text-часть в assistant)", n) } }