Корень проблемы «не получаем результаты»: клиент смешивал два слоя 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.
148 lines
4.3 KiB
Go
148 lines
4.3 KiB
Go
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)
|
||
}
|
||
} |