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.
This commit is contained in:
@@ -7,41 +7,69 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fakeAPIServer — минимальный фейк opencode serve HTTP API v1.18.
|
||||
// fakeAPIServer — минимальный фейк opencode serve experimental HTTP API
|
||||
// (пути БЕЗ префикса /api).
|
||||
type fakeAPIServer struct {
|
||||
messages []sessionMessage
|
||||
failCreate bool
|
||||
failVerify bool
|
||||
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("/api/session", func(w http.ResponseWriter, r *http.Request) {
|
||||
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
|
||||
}
|
||||
writeJSON(w, map[string]any{"data": map[string]any{"id": "sess-fake"}})
|
||||
// experimental: голая Session (без обёртки {data}).
|
||||
writeJSON(w, map[string]any{"id": "sess-fake"})
|
||||
})
|
||||
mux.HandleFunc("/api/session/{id}/wait", func(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, map[string]any{})
|
||||
})
|
||||
mux.HandleFunc("/api/session/{id}/interrupt", func(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, map[string]any{})
|
||||
})
|
||||
// POST отправка промпта (v1.18) — путь БЕЗ /api.
|
||||
mux.HandleFunc("/session/{id}/message", func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, map[string]any{"data": map[string]any{}})
|
||||
})
|
||||
// GET чтение сообщений — путь с /api.
|
||||
mux.HandleFunc("/api/session/{id}/message", func(w http.ResponseWriter, r *http.Request) {
|
||||
if len(f.messages) == 0 {
|
||||
writeJSON(w, map[string]any{"data": []sessionMessage{}})
|
||||
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{"data": f.messages})
|
||||
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
|
||||
}
|
||||
@@ -51,17 +79,7 @@ func writeJSON(w http.ResponseWriter, v any) {
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
// textPart — анонимная text-часть assistant-сообщения.
|
||||
func textPart(s string) struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
} {
|
||||
return struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
}{Type: "text", Text: s}
|
||||
}
|
||||
|
||||
// fakeClient — клиент к фейк-серверу.
|
||||
func fakeClient(t *testing.T, f *fakeAPIServer) *Client {
|
||||
t.Helper()
|
||||
ts := httptest.NewServer(f.handler())
|
||||
@@ -87,30 +105,44 @@ func TestClient_CreateSessionFail(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_Verdict(t *testing.T) {
|
||||
func TestClient_Send(t *testing.T) {
|
||||
c := fakeClient(t, &fakeAPIServer{
|
||||
messages: []sessionMessage{{Type: "assistant", Content: []struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
}{textPart(`{"phase":"ready"}`)}}},
|
||||
verdictParts: []part{{Type: "text", Text: `{"phase":"ready"}`}},
|
||||
})
|
||||
vd, err := c.Verdict(context.Background(), "sess-fake")
|
||||
vd, err := c.Send(context.Background(), "sess-fake", "почини x")
|
||||
if err != nil {
|
||||
t.Fatalf("Verdict err: %v", err)
|
||||
t.Fatalf("Send err: %v", err)
|
||||
}
|
||||
if vd != `{"phase":"ready"}` {
|
||||
t.Errorf("verdict = %q, want вердикт модели", vd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_VerdictNoText(t *testing.T) {
|
||||
c := fakeClient(t, &fakeAPIServer{}) // нет assistant-сообщения с text
|
||||
if _, err := c.Verdict(context.Background(), "sess-fake"); err == nil {
|
||||
t.Fatal("Verdict должен упасть, когда нет text-части")
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user