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)
This commit is contained in:
@@ -10,18 +10,29 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// fakeAPIServer — минимальный фейк opencode serve experimental HTTP API
|
||||
// (пути БЕЗ префикса /api).
|
||||
// fakeAPIServer — минимальный фейк opencode serve v2 HTTP API (пути /api/*).
|
||||
//
|
||||
// Сценарии:
|
||||
// - нормальный: Prompt ставит active=false и в messages кладётся финальное
|
||||
// assistant-сообщение (verdictText) → Runner собирает вердикт;
|
||||
// - blockPrompt: «агент завис» — active=true всегда, сообщений нет → idle abort;
|
||||
// - failCreate / failMessages — имитация ошибок.
|
||||
type fakeAPIServer struct {
|
||||
messages []message
|
||||
failCreate bool
|
||||
verdictParts []part // ответ на POST /session/{id}/message (вердикт)
|
||||
blockPrompt bool // POST /message блокируется до отмены ctx (эмуляция зависания)
|
||||
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("/session", func(w http.ResponseWriter, r *http.Request) {
|
||||
mux.HandleFunc("/api/session", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
@@ -30,50 +41,85 @@ func (f *fakeAPIServer) handler() http.Handler {
|
||||
http.Error(w, "boom", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
// experimental: голая Session (без обёртки {data}).
|
||||
writeJSON(w, map[string]any{"id": "sess-fake"})
|
||||
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("/session/{id}/abort", func(w http.ResponseWriter, r *http.Request) {
|
||||
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
|
||||
}
|
||||
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)
|
||||
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)
|
||||
@@ -88,61 +134,135 @@ func fakeClient(t *testing.T, f *fakeAPIServer) *Client {
|
||||
}
|
||||
|
||||
func TestClient_CreateSession(t *testing.T) {
|
||||
c := fakeClient(t, &fakeAPIServer{})
|
||||
id, err := c.CreateSession(context.Background(), "ratatoskr-analyst")
|
||||
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(), "x"); err == nil {
|
||||
if _, err := c.CreateSession(context.Background(), nil); 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")
|
||||
func TestClient_Prompt(t *testing.T) {
|
||||
c := fakeClient(t, &fakeAPIServer{})
|
||||
adm, err := c.Prompt(context.Background(), "sess-fake", "почини x")
|
||||
if err != nil {
|
||||
t.Fatalf("Send err: %v", err)
|
||||
t.Fatalf("Prompt err: %v", err)
|
||||
}
|
||||
if vd != `{"phase":"ready"}` {
|
||||
t.Errorf("verdict = %q, want вердикт модели", vd)
|
||||
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_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"}},
|
||||
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)
|
||||
n, err := c.textCount(context.Background(), "sess-fake")
|
||||
msgs, err := c.Messages(context.Background(), "sess-fake")
|
||||
if err != nil {
|
||||
t.Fatalf("textCount err: %v", err)
|
||||
t.Fatalf("Messages err: %v", err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Errorf("textCount = %d, want 1 (одна text-часть в assistant)", n)
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user