Files
ratatoskr-go/internal/opencode/client_test.go
ki.sagidullin 9f64be4ea5
Some checks failed
CI / test (pull_request) Failing after 32s
CI / build-and-package (amd64, linux) (pull_request) Successful in 42s
CI / build-and-package (amd64, windows) (pull_request) Successful in 42s
feat(opencode): переход на v2 HTTP API opencode (хардпин модели, поллинг вердикта)
- client.go: эндпоинты /api/* (create+model, prompt-admit, message, active, interrupt)
- runner.go: неблокирующий prompt + поллинг новых assistant-сообщений;
  завершение = сессия ушла из активных дренажей + стабильное финальное сообщение
- config.go: чтение top-level model из opencode.jsonc (JSONC-стрип) + хардпин в сессию
- server.go: healthcheck /api/health, MinVersion=1.18.18, понятная ошибка для старого бинаря
- класс O5 WARN: устойчивость к v1-конфигу провайдера (npm/options игнорируются v2)
- README: раздел интеграции, минимальная версия opencode, предупреждения
- .serena: актуализация памяти (core, tech_stack)
2026-08-19 08:29:11 +05:00

268 lines
7.9 KiB
Go
Raw Permalink 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 v2 HTTP API (пути /api/*).
//
// Сценарии:
// - нормальный: Prompt ставит active=false и в messages кладётся финальное
// assistant-сообщение (verdictText) → Runner собирает вердикт;
// - blockPrompt: «агент завис» — active=true всегда, сообщений нет → idle abort;
// - failCreate / failMessages — имитация ошибок.
type fakeAPIServer struct {
sessionID string
created bool
active bool
blockPrompt bool
messages []v2Message
verdictText string
failCreate bool
failMessages bool
createdModel *ModelRef // модель, полученная на POST /api/session
promptCalls int
}
func (f *fakeAPIServer) handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/api/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
}
var in struct {
Model *ModelRef `json:"model"`
}
_ = json.NewDecoder(r.Body).Decode(&in)
f.createdModel = in.Model
f.sessionID = "sess-fake"
f.created = true
writeJSON(w, map[string]any{"data": map[string]any{"id": "sess-fake"}})
})
mux.HandleFunc("/api/session/active", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
data := map[string]any{}
if f.active && f.sessionID != "" {
data[f.sessionID] = map[string]any{"type": "running"}
}
writeJSON(w, map[string]any{"data": data})
})
mux.HandleFunc("/api/session/{id}/prompt", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
f.promptCalls++
if f.blockPrompt {
// «зависший» агент: активен, но сообщений не появляется.
f.active = true
} else {
f.active = false
}
writeJSON(w, map[string]any{"data": map[string]any{
"id": "msg_1",
"sessionID": f.sessionID,
"timeCreated": time.Now().UnixMilli(),
}})
})
mux.HandleFunc("/api/session/{id}/interrupt", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
f.active = false
w.WriteHeader(http.StatusNoContent)
})
mux.HandleFunc("/api/session/{id}/message", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
if f.failMessages {
http.Error(w, "db error", http.StatusInternalServerError)
return
}
msgs := f.messages
if msgs == nil && f.verdictText != "" && !f.blockPrompt {
msgs = []v2Message{f.assistantMsg(f.verdictText)}
}
if msgs == nil {
msgs = []v2Message{}
}
writeJSON(w, map[string]any{"data": msgs})
})
return mux
}
// assistantMsg строит завершённое assistant-сообщение с text-партом.
func (f *fakeAPIServer) assistantMsg(text string) v2Message {
now := time.Now().UnixMilli()
return v2Message{
ID: "msg_a",
Type: "assistant",
Content: []v2Part{{Type: "text", Text: text}},
Finish: "end_turn",
Time: v2Time{Created: &now, Completed: &now},
}
}
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) {
f := &fakeAPIServer{}
c := fakeClient(t, f)
id, err := c.CreateSession(context.Background(), nil)
if err != nil {
t.Fatalf("CreateSession err: %v", err)
}
if id != "sess-fake" {
t.Errorf("id = %q, want sess-fake", id)
}
if f.createdModel != nil {
t.Errorf("createdModel = %+v, want nil", f.createdModel)
}
}
func TestClient_CreateSessionHardpinsModel(t *testing.T) {
want := &ModelRef{ProviderID: "tokentool", ID: "deepseek/deepseek-v4-flash-0731"}
f := &fakeAPIServer{}
c := fakeClient(t, f)
if _, err := c.CreateSession(context.Background(), want); err != nil {
t.Fatalf("CreateSession err: %v", err)
}
if f.createdModel == nil || f.createdModel.ProviderID != want.ProviderID || f.createdModel.ID != want.ID {
t.Errorf("createdModel = %+v, want %+v", f.createdModel, want)
}
}
func TestClient_CreateSessionFail(t *testing.T) {
c := fakeClient(t, &fakeAPIServer{failCreate: true})
if _, err := c.CreateSession(context.Background(), nil); err == nil {
t.Fatal("CreateSession должен упасть при 500, а не nil")
}
}
func TestClient_Prompt(t *testing.T) {
c := fakeClient(t, &fakeAPIServer{})
adm, err := c.Prompt(context.Background(), "sess-fake", "почини x")
if err != nil {
t.Fatalf("Prompt err: %v", err)
}
if adm.ID != "msg_1" {
t.Errorf("adm.ID = %q, want msg_1", adm.ID)
}
if adm.TimeCreated == 0 {
t.Error("adm.TimeCreated = 0, want epoch ms")
}
}
func TestClient_Messages(t *testing.T) {
now := time.Now().UnixMilli()
f := &fakeAPIServer{messages: []v2Message{{
ID: "msg_a", Type: "assistant",
Content: []v2Part{{Type: "text", Text: "a"}, {Type: "reasoning", Text: "x"}},
Finish: "end_turn",
Time: v2Time{Created: &now, Completed: &now},
}}}
c := fakeClient(t, f)
msgs, err := c.Messages(context.Background(), "sess-fake")
if err != nil {
t.Fatalf("Messages err: %v", err)
}
if len(msgs) != 1 {
t.Fatalf("len(msgs) = %d, want 1", len(msgs))
}
if !msgs[0].finished() {
t.Error("сообщение должно быть finished (Finish задан)")
}
}
func TestClient_Active(t *testing.T) {
f := &fakeAPIServer{active: true, sessionID: "sess-fake"}
c := fakeClient(t, f)
ok, err := c.Active(context.Background(), "sess-fake")
if err != nil {
t.Fatalf("Active err: %v", err)
}
if !ok {
t.Error("Active = false, want true")
}
ok, _ = c.Active(context.Background(), "sess-other")
if ok {
t.Error("Active(чужой) = true, want false")
}
}
func TestClient_Interrupt(t *testing.T) {
c := fakeClient(t, &fakeAPIServer{})
if err := c.Interrupt(context.Background(), "sess-fake"); err != nil {
t.Fatalf("Interrupt err: %v", err)
}
}
func Test_newestAssistant(t *testing.T) {
older := time.Now().Add(-time.Minute).UnixMilli()
newer := time.Now().UnixMilli()
msgs := []v2Message{
{ID: "a", Type: "assistant", Content: []v2Part{{Type: "text", Text: "x"}}, Time: v2Time{Created: &newer}},
{ID: "b", Type: "user", Time: v2Time{Created: &newer}},
{ID: "c", Type: "assistant", Content: []v2Part{{Type: "text", Text: "y"}}, Time: v2Time{Created: &older}},
}
cur, count := newestAssistant(msgs, older)
if cur == nil || cur.ID != "a" {
t.Errorf("newest = %v, want a", cur)
}
if count != 2 {
t.Errorf("count = %d, want 2", count)
}
texts := assistantText(msgs, older)
if len(texts) != 2 || texts[0] != "y" || texts[1] != "x" {
t.Errorf("assistantText order = %v, want [y x]", texts)
}
}
func Test_parseModelString(t *testing.T) {
m := parseModelString("tokentool/deepseek/deepseek-v4-flash-0731")
if m == nil || m.ProviderID != "tokentool" || m.ID != "deepseek/deepseek-v4-flash-0731" {
t.Errorf("parse = %+v, want tokentool/deepseek-v4-flash-0731", m)
}
if parseModelString("onlyprovider") != nil {
t.Error("parse без '/' должен вернуть nil")
}
if parseModelString("") != nil {
t.Error("parse пустой должен вернуть nil")
}
}
func TestClientErr_Unwrap(t *testing.T) {
ce := &ClientErr{Op: "prompt", Err: errors.New("boom")}
var target *ClientErr
if !errors.As(ce, &target) {
t.Fatal("expected *ClientErr")
}
}