feat: opencode через HTTP API — пул serve-серверов вместо spawn/NDJSON
Some checks failed
CI / test (push) Successful in 40s
CI / build-and-package (amd64, linux) (push) Successful in 35s
CI / build-and-package (amd64, windows) (push) Failing after 26s

Runner теперь ходит к постоянным serve по HTTP API (v1.17+, /api):
- клиент Client (create/send/wait/abort/messages/verdict)
- Pool: по одному serve на каталог, ленивый подъём, root-сервер в worktree,
  выделение портов, ReleaseTask при завершении задачи
- Run: CreateSession('ratatoskr-<агент>') -> Send -> поллинг Verdict из
  text-частей assistant-сообщений; idle/hard таймауты дают RC=-1
- вердикт извлекается из последнего assistant text-парта (плоский text)
- тесты: unit на фейковом HTTP-сервере; e2e эмулирует serve через httptest,
  агент определяется по title сессии
This commit is contained in:
Hermes
2026-08-18 13:36:54 +05:00
parent b60978121d
commit 733e63339a
12 changed files with 943 additions and 494 deletions

View File

@@ -0,0 +1,114 @@
package opencode
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
)
// fakeAPIServer — минимальный фейк opencode serve HTTP API v1.17.
type fakeAPIServer struct {
messages []sessionMessage
failCreate bool
failVerify bool
}
func (f *fakeAPIServer) handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/api/session", func(w http.ResponseWriter, r *http.Request) {
if f.failCreate {
http.Error(w, "boom", http.StatusInternalServerError)
return
}
writeJSON(w, map[string]any{"data": map[string]any{"id": "sess-fake"}})
})
mux.HandleFunc("/api/session/{id}/prompt", func(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, map[string]any{"data": map[string]any{}})
})
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{})
})
mux.HandleFunc("/api/session/{id}/message", func(w http.ResponseWriter, _ *http.Request) {
if len(f.messages) == 0 {
writeJSON(w, map[string]any{"data": []sessionMessage{}})
return
}
writeJSON(w, map[string]any{"data": f.messages})
})
return mux
}
func writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
_ = 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}
}
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_Verdict(t *testing.T) {
c := fakeClient(t, &fakeAPIServer{
messages: []sessionMessage{{Type: "assistant", Content: []struct {
Type string `json:"type"`
Text string `json:"text"`
}{textPart(`{"phase":"ready"}`)}}},
})
vd, err := c.Verdict(context.Background(), "sess-fake")
if err != nil {
t.Fatalf("Verdict 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-части")
} else {
var ce *ClientErr
if !errors.As(err, &ce) {
t.Errorf("ожидался *ClientErr, got %T", err)
}
}
}