package opencode import ( "context" "encoding/json" "errors" "net/http" "net/http/httptest" "strings" "testing" "time" ) // fakeAPIServer — минимальный фейк opencode serve v2 HTTP API (пути /api/*). // // Сценарии: // - нормальный: Prompt ставит active=false и в messages кладётся финальное // assistant-сообщение (verdictText) → Runner собирает вердикт; // - blockPrompt: «агент завис» — active=true всегда, сообщений нет → idle abort; // - failCreate / failMessages — имитация ошибок; // - growStream: стрим одного растущего парта — текст/reasoning растёт с // каждым опросом GET /message (streamPolls раз), active=true, затем // active=false + финальное завершённое сообщение. type fakeAPIServer struct { sessionID string created bool active bool blockPrompt bool messages []v2Message verdictText string verdictReasoning string // завершённый ответ только с reasoning-партом (без text) failCreate bool failMessages bool createdAgent string // агент, полученный на POST /api/session promptCalls int // streamGrow: стрим одного растущего парта — текст/reasoning растёт с // каждым опросом GET /message, active=true, пока messageCalls не дойдёт до // streamPolls; затем active=false + финальное завершённое сообщение. streamGrow bool streamReasoning bool // растущий парт — reasoning вместо text streamPolls int // сколько опросов длится «стрим» до завершения messageCalls int } func (f *fakeAPIServer) handler() http.Handler { mux := http.NewServeMux() mux.HandleFunc("/api/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 } var in struct { Agent string `json:"agent"` } _ = json.NewDecoder(r.Body).Decode(&in) f.createdAgent = in.Agent f.sessionID = "sess-fake" f.created = true writeJSON(w, map[string]any{"data": map[string]any{"id": "sess-fake"}}) }) mux.HandleFunc("/api/session/active", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { w.WriteHeader(http.StatusMethodNotAllowed) return } data := map[string]any{} if f.active && f.sessionID != "" { data[f.sessionID] = map[string]any{"type": "running"} } writeJSON(w, map[string]any{"data": data}) }) mux.HandleFunc("/api/session/{id}/prompt", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { w.WriteHeader(http.StatusMethodNotAllowed) return } f.promptCalls++ if f.blockPrompt { // «зависший» агент: активен, но сообщений не появляется. f.active = true } else { f.active = false } writeJSON(w, map[string]any{"data": map[string]any{ "id": "msg_1", "sessionID": f.sessionID, "timeCreated": time.Now().UnixMilli(), }}) }) mux.HandleFunc("/api/session/{id}/interrupt", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { w.WriteHeader(http.StatusMethodNotAllowed) return } f.active = false w.WriteHeader(http.StatusNoContent) }) mux.HandleFunc("/api/session/{id}/message", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { w.WriteHeader(http.StatusMethodNotAllowed) return } if f.failMessages { http.Error(w, "db error", http.StatusInternalServerError) return } if f.streamGrow { f.messageCalls++ now := time.Now().UnixMilli() done := f.messageCalls >= f.streamPolls msg := v2Message{ID: "msg_stream", Type: "assistant", Time: v2Time{Created: &now}} switch { case done: msg.Content = []v2Part{{Type: "text", Text: "done-stream"}} msg.Finish = "end_turn" msg.Time.Completed = &now f.active = false case f.streamReasoning: msg.Content = []v2Part{{Type: "reasoning", Text: strings.Repeat("r", f.messageCalls)}} f.active = true default: msg.Content = []v2Part{{Type: "text", Text: strings.Repeat("x", f.messageCalls)}} f.active = true } writeJSON(w, map[string]any{"data": []v2Message{msg}}) return } msgs := f.messages if msgs == nil && f.verdictText != "" && !f.blockPrompt { msgs = []v2Message{f.assistantMsg(f.verdictText)} } if msgs == nil && f.verdictReasoning != "" && !f.blockPrompt { msgs = []v2Message{f.assistantReasoningMsg(f.verdictReasoning)} } if msgs == nil { msgs = []v2Message{} } writeJSON(w, map[string]any{"data": msgs}) }) return mux } // assistantMsg строит завершённое assistant-сообщение с text-партом. func (f *fakeAPIServer) assistantMsg(text string) v2Message { now := time.Now().UnixMilli() return v2Message{ ID: "msg_a", Type: "assistant", Content: []v2Part{{Type: "text", Text: text}}, Finish: "end_turn", Time: v2Time{Created: &now, Completed: &now}, } } // assistantReasoningMsg строит завершённое assistant-сообщение только с // reasoning-партом (без text) — для проверки fallback-сценария. func (f *fakeAPIServer) assistantReasoningMsg(text string) v2Message { now := time.Now().UnixMilli() return v2Message{ ID: "msg_r", Type: "assistant", Content: []v2Part{{Type: "reasoning", Text: text}}, Finish: "end_turn", Time: v2Time{Created: &now, Completed: &now}, } } 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) { f := &fakeAPIServer{} c := fakeClient(t, f) id, err := c.CreateSession(context.Background(), "dev") if err != nil { t.Fatalf("CreateSession err: %v", err) } if id != "sess-fake" { t.Errorf("id = %q, want sess-fake", id) } if f.createdAgent != "dev" { t.Errorf("createdAgent = %q, want dev", f.createdAgent) } } func TestClient_CreateSessionPassesAgent(t *testing.T) { f := &fakeAPIServer{} c := fakeClient(t, f) if _, err := c.CreateSession(context.Background(), "postmortem"); err != nil { t.Fatalf("CreateSession err: %v", err) } if f.createdAgent != "postmortem" { t.Errorf("createdAgent = %q, want postmortem", f.createdAgent) } } func TestClient_CreateSessionNoAgent(t *testing.T) { f := &fakeAPIServer{} c := fakeClient(t, f) if _, err := c.CreateSession(context.Background(), ""); err != nil { t.Fatalf("CreateSession err: %v", err) } if f.createdAgent != "" { t.Errorf("createdAgent = %q, want пусто", f.createdAgent) } } func TestClient_CreateSessionFail(t *testing.T) { c := fakeClient(t, &fakeAPIServer{failCreate: true}) if _, err := c.CreateSession(context.Background(), "dev"); err == nil { t.Fatal("CreateSession должен упасть при 500, а не nil") } } func TestClient_Prompt(t *testing.T) { c := fakeClient(t, &fakeAPIServer{}) adm, err := c.Prompt(context.Background(), "sess-fake", "почини x") if err != nil { t.Fatalf("Prompt err: %v", err) } if adm.ID != "msg_1" { t.Errorf("adm.ID = %q, want msg_1", adm.ID) } if adm.TimeCreated == 0 { t.Error("adm.TimeCreated = 0, want epoch ms") } } func TestClient_Messages(t *testing.T) { now := time.Now().UnixMilli() f := &fakeAPIServer{messages: []v2Message{{ ID: "msg_a", Type: "assistant", Content: []v2Part{{Type: "text", Text: "a"}, {Type: "reasoning", Text: "x"}}, Finish: "end_turn", Time: v2Time{Created: &now, Completed: &now}, }}} c := fakeClient(t, f) msgs, err := c.Messages(context.Background(), "sess-fake") if err != nil { t.Fatalf("Messages err: %v", err) } if len(msgs) != 1 { t.Fatalf("len(msgs) = %d, want 1", len(msgs)) } if !msgs[0].finished() { t.Error("сообщение должно быть finished (Finish задан)") } } func TestClient_Active(t *testing.T) { f := &fakeAPIServer{active: true, sessionID: "sess-fake"} c := fakeClient(t, f) ok, err := c.Active(context.Background(), "sess-fake") if err != nil { t.Fatalf("Active err: %v", err) } if !ok { t.Error("Active = false, want true") } ok, _ = c.Active(context.Background(), "sess-other") if ok { t.Error("Active(чужой) = true, want false") } } func TestClient_Interrupt(t *testing.T) { c := fakeClient(t, &fakeAPIServer{}) if err := c.Interrupt(context.Background(), "sess-fake"); err != nil { t.Fatalf("Interrupt err: %v", err) } } func Test_newestAssistant(t *testing.T) { older := time.Now().Add(-time.Minute).UnixMilli() newer := time.Now().UnixMilli() msgs := []v2Message{ {ID: "a", Type: "assistant", Content: []v2Part{{Type: "text", Text: "x"}}, Time: v2Time{Created: &newer}}, {ID: "b", Type: "user", Time: v2Time{Created: &newer}}, {ID: "c", Type: "assistant", Content: []v2Part{{Type: "text", Text: "y"}}, Time: v2Time{Created: &older}}, } cur, count := newestAssistant(msgs, older) if cur == nil || cur.ID != "a" { t.Errorf("newest = %v, want a", cur) } if count != 2 { t.Errorf("count = %d, want 2", count) } texts := assistantText(msgs, older) if len(texts) != 2 || texts[0] != "y" || texts[1] != "x" { t.Errorf("assistantText order = %v, want [y x]", texts) } } func Test_assistantVerdict(t *testing.T) { older := time.Now().Add(-time.Minute).UnixMilli() newer := time.Now().UnixMilli() reasoningOf := func(text string, at *int64) v2Message { return v2Message{ID: "r", Type: "assistant", Content: []v2Part{{Type: "reasoning", Text: text}}, Time: v2Time{Created: at}} } // reasoning-only: text-партов нет → fallback на reasoning, usedReasoning=true. // Сообщения приходят новейшими первыми (как из API) → размышление 2 новее. reasoningOnlyMsgs := []v2Message{ reasoningOf("размышление 2", &newer), reasoningOf("размышление 1", &older), } texts, used := assistantVerdict(reasoningOnlyMsgs, older) if !used { t.Error("usedReasoning = false, want true для reasoning-only") } if len(texts) != 2 || texts[0] != "размышление 1" || texts[1] != "размышление 2" { t.Errorf("verdict = %v, want [размышление 1 размышление 2] (хронологически)", texts) } // text + reasoning → берётся text, reasoning игнорируется. mixed := []v2Message{ {ID: "a", Type: "assistant", Content: []v2Part{{Type: "reasoning", Text: "thinking"}, {Type: "text", Text: "ответ"}}, Time: v2Time{Created: &newer}}, } texts, used = assistantVerdict(mixed, older) if used { t.Error("usedReasoning = true, want false (есть text)") } if len(texts) != 1 || texts[0] != "ответ" { t.Errorf("verdict = %v, want [ответ]", texts) } // пусто → пусто и usedReasoning=false. empty := []v2Message{{ID: "u", Type: "user", Time: v2Time{Created: &newer}}} if texts, used := assistantVerdict(empty, older); used || len(texts) != 0 { t.Errorf("пусто: texts=%v usedReasoning=%v, want пусто/false", texts, used) } } func TestClientErr_Unwrap(t *testing.T) { ce := &ClientErr{Op: "prompt", Err: errors.New("boom")} var target *ClientErr if !errors.As(ce, &target) { t.Fatal("expected *ClientErr") } }