Files
ratatoskr-go/internal/opencode/client_test.go
Hermes 7eb3a0292c
Some checks failed
CI / test (push) Failing after 32s
CI / build-and-package (amd64, linux) (push) Successful in 38s
CI / build-and-package (amd64, windows) (push) Successful in 38s
fix(opencode): убрать смешение слоёв API — перейти целиком на experimental (/session)
Корень проблемы «не получаем результаты»: клиент смешивал два слоя opencode
serve. CreateSession ходил на /api/session (v2, ждал {data.id}), Verdict — на
/api/session/{id}/message?order=desc и ждал {data:[{type,content}]}, где поле
content[].type/text физически отсутствует, поэтому вердикт никогда не находился
и поллинг уходил в вечный таймаут. Abort и вовсе звал несуществующий /interrupt.

Теперь весь код на experimental-слое, как сверено с sst/opencode (ветка dev):
- CreateSession: POST /session → голая Session, id в .id.
- Send: блокирующий POST /session/{id}/message, тело {parts:[{type:text,text}]},
  вердикт из частей parts[].type=="text" ответа. Это и есть результат — метод
  Verdict и отдельный GET удалены.
- textCount (прогресс): GET /session/{id}/message → голый массив [{info, parts}].
- Abort: POST /session/{id}/abort.

Runner: блокирующий Send запускается в горутине (канал вердикта/ошибки),
параллельно поллим textCount (рост text-частей сбрасывает idle-таймер). При
idle/hard-таймауте или отмене контекста — Abort + cancel() Send-горутины → rc=-1.

Send ходит через отдельный http.Client без жёсткого Timeout (управляется ctx),
чтобы длинная генерация не обрывалась на 30s. Тесты/fakeAPIServer переведены на
экспериментальный формат. Версия → 0.2.2.
2026-08-18 20:49:34 +05:00

148 lines
4.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}