feat: opencode через HTTP API — пул serve-серверов вместо spawn/NDJSON
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:
114
internal/opencode/client_test.go
Normal file
114
internal/opencode/client_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user