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:
@@ -11,29 +11,28 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Client — HTTP-взаимодействие с одним opencode serve (режим API).
|
||||
// Client — HTTP-взаимодействие с одним opencode serve (v2 HTTP API).
|
||||
//
|
||||
// Ходит по experimental HTTP API opencode serve (пути БЕЗ префикса /api):
|
||||
// - POST /session создать сессию → голая Session {id}
|
||||
// - POST /session/{id}/message отправить промпт {parts:[{type:"text"}]} →
|
||||
// блокирует и возвращает {info,parts}; вердикт из parts
|
||||
// - GET /session/{id}/message история → голый массив [{info, parts}] (для прогресса)
|
||||
// - POST /session/{id}/abort прервать выполняющийся ответ
|
||||
// Пути v2 начинаются с префикса /api (см. README, минимальная версия opencode):
|
||||
// - POST /api/session создать сессию {model:{...}} → {data: Session.Info}
|
||||
// - POST /api/session/{id}/prompt отправить промпт {prompt:{text}} →
|
||||
// НЕБЛОКИРУЮЩЕ (admit) → {data: Admitted}
|
||||
// - GET /api/session/{id}/message?order=desc → {data:[Message,...]}
|
||||
// - POST /api/session/{id}/interrupt прервать активный ответ (204)
|
||||
// - GET /api/session/active активные дренажи → {data:{sessionID:...}}
|
||||
//
|
||||
// Вердикт собирается из parts[] ответа на POST /message: текст тех частей,
|
||||
// где type == "text".
|
||||
// Prompt не блокирует: вердикт собирается поллингом из content[].type=="text"
|
||||
// новых assistant-сообщений (см. Runner.awaitVerdict).
|
||||
type Client struct {
|
||||
BaseURL string // http://host:port (без завершающего слеша)
|
||||
Password string // basic auth (username "opencode")
|
||||
Debug bool // включать отладочные логи API-вызовов (log.level=debug)
|
||||
http *http.Client // для быстрых операций (create/messages/abort)
|
||||
httpSend *http.Client // для блокирующего Send — без жёсткого таймаута,
|
||||
// отменяется только через контекст (idle/hard)
|
||||
http *http.Client // единый клиент: все операции быстрые (нет блокирующего Send)
|
||||
}
|
||||
|
||||
// ClientErr — классы ошибок клиента.
|
||||
type ClientErr struct {
|
||||
Op string // "connect" | "create" | "prompt" | "messages" | "abort"
|
||||
Op string // "connect" | "create" | "prompt" | "messages" | "active" | "abort"
|
||||
Err error
|
||||
}
|
||||
|
||||
@@ -44,19 +43,10 @@ func (c *Client) defaults() {
|
||||
if c.http == nil {
|
||||
c.http = &http.Client{Timeout: 30 * time.Second}
|
||||
}
|
||||
if c.httpSend == nil {
|
||||
c.httpSend = &http.Client{}
|
||||
}
|
||||
}
|
||||
|
||||
// do выполняет запрос через c.http (с таймаутом 30s) и возвращает тело при 2xx.
|
||||
// do выполняет запрос через c.http и возвращает тело при 2xx.
|
||||
func (c *Client) do(ctx context.Context, method, path, op string, body []byte) ([]byte, error) {
|
||||
c.defaults()
|
||||
return c.doHTTP(ctx, method, path, op, body, c.http)
|
||||
}
|
||||
|
||||
// doHTTP — общая реализация запроса; hc — клиент, которым выполняется запрос.
|
||||
func (c *Client) doHTTP(ctx context.Context, method, path, op string, body []byte, hc *http.Client) ([]byte, error) {
|
||||
c.defaults()
|
||||
var rd io.Reader
|
||||
if body != nil {
|
||||
@@ -78,7 +68,7 @@ func (c *Client) doHTTP(ctx context.Context, method, path, op string, body []byt
|
||||
log.Printf("opencode api %s request body: %s", op, truncateStr(string(body), 5000))
|
||||
}
|
||||
}
|
||||
resp, err := hc.Do(req)
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, &ClientErr{Op: "connect", Err: err}
|
||||
}
|
||||
@@ -99,116 +89,222 @@ func (c *Client) doHTTP(ctx context.Context, method, path, op string, body []byt
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// CreateSession создаёт новую сессию и возвращает её id.
|
||||
func (c *Client) CreateSession(ctx context.Context, title string) (string, error) {
|
||||
body := map[string]string{}
|
||||
if title != "" {
|
||||
body["title"] = title
|
||||
// ModelRef — ссылка на модель (аналог v2 Model.Ref: {providerID, id, variant?}).
|
||||
// providerID — имя провайдера из конфига opencode, id — идентификатор модели.
|
||||
type ModelRef struct {
|
||||
ProviderID string `json:"providerID"`
|
||||
ID string `json:"id"`
|
||||
Variant string `json:"variant,omitempty"`
|
||||
}
|
||||
|
||||
// String возвращает каноничное представление "provider/id[/variant]".
|
||||
func (m *ModelRef) String() string {
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
b, _ := json.Marshal(body)
|
||||
raw, err := c.do(ctx, http.MethodPost, "/session", "create", b)
|
||||
if m.Variant != "" {
|
||||
return m.ProviderID + "/" + m.ID + "/" + m.Variant
|
||||
}
|
||||
return m.ProviderID + "/" + m.ID
|
||||
}
|
||||
|
||||
// CreateSession создаёт новую сессию и возвращает её id. model != nil —
|
||||
// хардпин модели (top-level "model" из конфига opencode), чтобы не зависеть
|
||||
// от fallback-логики выбора модели в самом opencode.
|
||||
func (c *Client) CreateSession(ctx context.Context, model *ModelRef) (string, error) {
|
||||
payload := map[string]any{}
|
||||
if model != nil {
|
||||
payload["model"] = model
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
raw, err := c.do(ctx, http.MethodPost, "/api/session", "create", body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// experimental: ответ — голая Session (без обёртки {data}).
|
||||
var out struct {
|
||||
ID string `json:"id"`
|
||||
Data struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return "", &ClientErr{Op: "create", Err: fmt.Errorf("невалидный ответ: %v", err)}
|
||||
}
|
||||
if out.ID == "" {
|
||||
if out.Data.ID == "" {
|
||||
return "", &ClientErr{Op: "create", Err: fmt.Errorf("пустой id сессии")}
|
||||
}
|
||||
return out.ID, nil
|
||||
return out.Data.ID, nil
|
||||
}
|
||||
|
||||
// Send отправляет промпт в сессию, БЛОКИРУЯСЬ до завершения ответа, и
|
||||
// возвращает вердикт (текст text-частей из parts). Отмена — только через ctx
|
||||
// (используется отдельный клиент без жёсткого таймаута; idle/hard в Runner'е
|
||||
// отменяют контекст, что прерывает этот запрос).
|
||||
func (c *Client) Send(ctx context.Context, sessionID, prompt string) (string, error) {
|
||||
// Admitted — результат admit промпта (SessionInput.Admitted).
|
||||
type Admitted struct {
|
||||
ID string // id user-сообщения
|
||||
TimeCreated int64 // epoch ms создания промпта (граница «новых» ответов)
|
||||
}
|
||||
|
||||
// Prompt неблокирующе отправляет промпт в сессию (durable admit) и возвращает
|
||||
// границу времени, с которой следует считать assistant-сообщения «новыми».
|
||||
func (c *Client) Prompt(ctx context.Context, sessionID, prompt string) (*Admitted, error) {
|
||||
payload := map[string]any{
|
||||
"parts": []map[string]string{{"type": "text", "text": prompt}},
|
||||
"prompt": map[string]string{"text": prompt},
|
||||
}
|
||||
b, _ := json.Marshal(payload)
|
||||
c.defaults()
|
||||
raw, err := c.doHTTP(ctx, http.MethodPost, "/session/"+sessionID+"/message", "prompt", b, c.httpSend)
|
||||
body, _ := json.Marshal(payload)
|
||||
raw, err := c.do(ctx, http.MethodPost, "/api/session/"+sessionID+"/prompt", "prompt", body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return nil, err
|
||||
}
|
||||
var out struct {
|
||||
Parts []part `json:"parts"`
|
||||
Data struct {
|
||||
ID string `json:"id"`
|
||||
TimeCreated int64 `json:"timeCreated"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return "", &ClientErr{Op: "prompt", Err: fmt.Errorf("невалидный ответ: %v", err)}
|
||||
return nil, &ClientErr{Op: "prompt", Err: fmt.Errorf("невалидный ответ: %v", err)}
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
for _, p := range out.Parts {
|
||||
if p.Type == "text" && p.Text != "" {
|
||||
if buf.Len() > 0 {
|
||||
buf.WriteString("\n")
|
||||
}
|
||||
buf.WriteString(p.Text)
|
||||
}
|
||||
if out.Data.ID == "" {
|
||||
return nil, &ClientErr{Op: "prompt", Err: fmt.Errorf("пустой id промпта в ответе")}
|
||||
}
|
||||
if buf.Len() == 0 {
|
||||
return "", &ClientErr{Op: "prompt", Err: fmt.Errorf("нет text-части в ответе")}
|
||||
}
|
||||
return stripFence(buf.String()), nil
|
||||
return &Admitted{ID: out.Data.ID, TimeCreated: out.Data.TimeCreated}, nil
|
||||
}
|
||||
|
||||
// Abort прерывает выполняющийся ответ сессии.
|
||||
func (c *Client) Abort(ctx context.Context, sessionID string) error {
|
||||
_, err := c.do(ctx, http.MethodPost, "/session/"+sessionID+"/abort", "abort", nil)
|
||||
return err
|
||||
// v2Message — минимальная проекция Session.Message (tagged union: тип в "type").
|
||||
// Поле "role" в v2 отсутствует; assistant определяется по type=="assistant".
|
||||
type v2Message struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"` // "assistant" | "user" | "tool" | "system" | ...
|
||||
Content []v2Part `json:"content"`
|
||||
Model *ModelRef `json:"model"`
|
||||
Finish string `json:"finish,omitempty"`
|
||||
Error *v2Error `json:"error,omitempty"`
|
||||
Time v2Time `json:"time"`
|
||||
}
|
||||
|
||||
// part — минимальная часть сообщения (из parts[]).
|
||||
type part struct {
|
||||
type v2Part struct {
|
||||
Type string `json:"type"` // "text" | "reasoning" | "tool" | ...
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// message — элемент голого массива из GET /session/{id}/message.
|
||||
type message struct {
|
||||
Info struct {
|
||||
Role string `json:"role"` // "assistant" | "user" | ...
|
||||
} `json:"info"`
|
||||
Parts []part `json:"parts"`
|
||||
type v2Time struct {
|
||||
Created *int64 `json:"created"`
|
||||
Completed *int64 `json:"completed"`
|
||||
}
|
||||
|
||||
// messages возвращает сырые сообщения сессии (для поллинга прогресса).
|
||||
func (c *Client) messages(ctx context.Context, sessionID string) ([]message, error) {
|
||||
raw, err := c.do(ctx, http.MethodGet, "/session/"+sessionID+"/message", "messages", nil)
|
||||
type v2Error struct {
|
||||
Type string `json:"type"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// finished — завершено ли assistant-сообщение (ответ агента закончен).
|
||||
func (m *v2Message) finished() bool {
|
||||
if m == nil {
|
||||
return false
|
||||
}
|
||||
if m.Error != nil {
|
||||
return true
|
||||
}
|
||||
if m.Finish != "" {
|
||||
return true
|
||||
}
|
||||
return m.Time.Completed != nil && *m.Time.Completed > 0
|
||||
}
|
||||
|
||||
// Messages возвращает сообщения сессии (новейшие первыми, до 200 за запрос).
|
||||
func (c *Client) Messages(ctx context.Context, sessionID string) ([]v2Message, error) {
|
||||
raw, err := c.do(ctx, http.MethodGet, "/api/session/"+sessionID+"/message?order=desc&limit=200", "messages", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []message
|
||||
var out struct {
|
||||
Data []v2Message `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return nil, err
|
||||
return nil, &ClientErr{Op: "messages", Err: fmt.Errorf("невалидный ответ: %v", err)}
|
||||
}
|
||||
return out, nil
|
||||
return out.Data, nil
|
||||
}
|
||||
|
||||
// textCount считает число text-частей в assistant-сообщениях (для progress).
|
||||
func (c *Client) textCount(ctx context.Context, sessionID string) (int, error) {
|
||||
msgs, err := c.messages(ctx, sessionID)
|
||||
// Active возвращает true, если сессия ещё обрабатывается (есть в активных
|
||||
// дренажах этого serve). Сессии вне списка считаются завершёнными.
|
||||
func (c *Client) Active(ctx context.Context, sessionID string) (bool, error) {
|
||||
raw, err := c.do(ctx, http.MethodGet, "/api/session/active", "active", nil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
return false, err
|
||||
}
|
||||
n := 0
|
||||
for _, m := range msgs {
|
||||
if m.Info.Role != "assistant" {
|
||||
var out struct {
|
||||
Data map[string]json.RawMessage `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return false, &ClientErr{Op: "active", Err: fmt.Errorf("невалидный ответ: %v", err)}
|
||||
}
|
||||
if out.Data == nil {
|
||||
return false, nil
|
||||
}
|
||||
_, ok := out.Data[sessionID]
|
||||
return ok, nil
|
||||
}
|
||||
|
||||
// Interrupt прерывает активный ответ сессии (аналог v1 abort).
|
||||
func (c *Client) Interrupt(ctx context.Context, sessionID string) error {
|
||||
_, err := c.do(ctx, http.MethodPost, "/api/session/"+sessionID+"/interrupt", "abort", nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// assistantSince фильтрует assistant-сообщения, созданные не раньше since
|
||||
// (порядок сохраняется — как пришёл из API, новейшие первыми).
|
||||
func assistantSince(msgs []v2Message, since int64) []*v2Message {
|
||||
out := make([]*v2Message, 0, len(msgs))
|
||||
for i := range msgs {
|
||||
m := &msgs[i]
|
||||
if m.Type != "assistant" {
|
||||
continue
|
||||
}
|
||||
for _, p := range m.Parts {
|
||||
if m.Time.Created == nil || *m.Time.Created < since {
|
||||
continue
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// textParts считает text-парты в одном assistant-сообщении (для прогресса).
|
||||
func textParts(m *v2Message) int {
|
||||
n := 0
|
||||
for _, p := range m.Content {
|
||||
if p.Type == "text" && p.Text != "" {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// newestAssistant возвращает самое новое assistant-сообщение (из фильтра) и
|
||||
// суммарное число text-партов. since — граница времени (epoch ms).
|
||||
func newestAssistant(msgs []v2Message, since int64) (*v2Message, int) {
|
||||
ass := assistantSince(msgs, since)
|
||||
var newest *v2Message
|
||||
count := 0
|
||||
for _, m := range ass {
|
||||
count += textParts(m)
|
||||
if newest == nil || *m.Time.Created > *newest.Time.Created {
|
||||
newest = m
|
||||
}
|
||||
}
|
||||
return newest, count
|
||||
}
|
||||
|
||||
// assistantText объединяет text-парты новых assistant-сообщений в хронологическом
|
||||
// порядке (сообщения приходят новейшими первыми → идём с конца).
|
||||
func assistantText(msgs []v2Message, since int64) []string {
|
||||
ass := assistantSince(msgs, since)
|
||||
texts := make([]string, 0, len(ass))
|
||||
for i := len(ass) - 1; i >= 0; i-- {
|
||||
for _, p := range ass[i].Content {
|
||||
if p.Type == "text" && p.Text != "" {
|
||||
n++
|
||||
texts = append(texts, p.Text)
|
||||
}
|
||||
}
|
||||
}
|
||||
return n, nil
|
||||
return texts
|
||||
}
|
||||
|
||||
func truncateStr(s string, n int) string {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
176
internal/opencode/config.go
Normal file
176
internal/opencode/config.go
Normal file
@@ -0,0 +1,176 @@
|
||||
package opencode
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Чтение top-level "model" из эффективного конфига opencode.
|
||||
//
|
||||
// Зачем: ratatoskr хардпинит модель в сессии (CreateSession), чтобы не зависеть
|
||||
// от fallback-логики opencode. Если в конфиге модель не задана (или конфиг
|
||||
// написан по старой v1-схеме — npm/options, которые v2 молча игнорирует),
|
||||
// opencode сам выберет «дефолтную» модельную запись, и это может оказаться не
|
||||
// той моделью. Поэтому мы явно логируем предупреждение (класс O5 WARN).
|
||||
|
||||
// opencodeConfigPath определяет путь к конфигу opencode, который видит
|
||||
// serve-процесс этого пула (см. README): (1) явный OPENCODE_CONFIG из Server
|
||||
// или окружения процесса, (2) OPENCODE_CONFIG_DIR / глобальный каталог
|
||||
// ~/.config/opencode. Возвращает "" если ничего не найдено.
|
||||
func opencodeConfigPath(cfgFile, cfgDir string) string {
|
||||
// (1) явный файл конфига — Server.Config или env OPENCODE_CONFIG.
|
||||
p := cfgFile
|
||||
if p == "" {
|
||||
p = os.Getenv("OPENCODE_CONFIG")
|
||||
}
|
||||
if p != "" {
|
||||
if st, err := os.Stat(p); err == nil && !st.IsDir() {
|
||||
return p
|
||||
}
|
||||
}
|
||||
// (2) каталог конфигов.
|
||||
dir := cfgDir
|
||||
if dir == "" {
|
||||
dir = os.Getenv("OPENCODE_CONFIG_DIR")
|
||||
}
|
||||
if dir == "" {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil || home == "" {
|
||||
return ""
|
||||
}
|
||||
dir = filepath.Join(home, ".config", "opencode")
|
||||
if x := os.Getenv("XDG_CONFIG_HOME"); x != "" {
|
||||
dir = filepath.Join(x, "opencode")
|
||||
}
|
||||
}
|
||||
for _, name := range []string{"opencode.json", "opencode.jsonc"} {
|
||||
cand := filepath.Join(dir, name)
|
||||
if st, err := os.Stat(cand); err == nil && !st.IsDir() {
|
||||
return cand
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ReadModelRef извлекает top-level "model" из конфига opencode и возвращает
|
||||
// его как ModelRef. Модель не задана — вернёт (nil, nil); ошибка чтения/парсинга
|
||||
// возвращается (вызывающий логирует warning и продолжает без хардпина).
|
||||
func ReadModelRef(cfgFile, cfgDir string) (*ModelRef, error) {
|
||||
path := opencodeConfigPath(cfgFile, cfgDir)
|
||||
if path == "" {
|
||||
return nil, nil
|
||||
}
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("config: читать %s: %w", path, err)
|
||||
}
|
||||
doc := struct {
|
||||
Model json.RawMessage `json:"model"`
|
||||
}{}
|
||||
if err := json.Unmarshal(stripJSONC(b), &doc); err != nil {
|
||||
return nil, fmt.Errorf("config: парсить %s: %w", path, err)
|
||||
}
|
||||
if len(doc.Model) == 0 || strings.TrimSpace(string(doc.Model)) == "null" {
|
||||
return nil, nil
|
||||
}
|
||||
// "model" может быть строкой "provider/id" или объектом {providerID, id}.
|
||||
var s string
|
||||
if err := json.Unmarshal(doc.Model, &s); err == nil {
|
||||
ref := parseModelString(s)
|
||||
if ref == nil {
|
||||
return nil, fmt.Errorf("config: некорректная model %q в %s (ожидается provider/id)", s, path)
|
||||
}
|
||||
return ref, nil
|
||||
}
|
||||
var ref ModelRef
|
||||
if err := json.Unmarshal(doc.Model, &ref); err != nil {
|
||||
return nil, fmt.Errorf("config: некорректная model в %s", path)
|
||||
}
|
||||
if ref.ProviderID == "" || ref.ID == "" {
|
||||
return nil, fmt.Errorf("config: model без providerID/id в %s", path)
|
||||
}
|
||||
return &ref, nil
|
||||
}
|
||||
|
||||
// parseModelString разбирает "provider/id" (как ModelV2.parse: провайдер — всё
|
||||
// до первого '/', id — остаток). Возвращает nil при пустой/некорректной строке.
|
||||
func parseModelString(s string) *ModelRef {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
i := strings.IndexByte(s, '/')
|
||||
if i <= 0 || i == len(s)-1 {
|
||||
return nil
|
||||
}
|
||||
return &ModelRef{ProviderID: s[:i], ID: s[i+1:]}
|
||||
}
|
||||
|
||||
// stripJSONC удаляет // и /* */ комментарии (вне строк), сохраняя позиции
|
||||
// переводов строк, чтобы json.Unmarshal не споткнулся о trailing-комма.
|
||||
func stripJSONC(b []byte) []byte {
|
||||
out := make([]byte, 0, len(b))
|
||||
inStr := false
|
||||
esc := false
|
||||
i := 0
|
||||
for i < len(b) {
|
||||
c := b[i]
|
||||
if inStr {
|
||||
out = append(out, c)
|
||||
if esc {
|
||||
esc = false
|
||||
} else if c == '\\' {
|
||||
esc = true
|
||||
} else if c == '"' {
|
||||
inStr = false
|
||||
}
|
||||
i++
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case c == '"':
|
||||
inStr = true
|
||||
out = append(out, c)
|
||||
i++
|
||||
case c == '/' && i+1 < len(b) && b[i+1] == '/':
|
||||
for i < len(b) && b[i] != '\n' {
|
||||
i++
|
||||
}
|
||||
if i < len(b) {
|
||||
out = append(out, '\n')
|
||||
i++
|
||||
}
|
||||
case c == '/' && i+1 < len(b) && b[i+1] == '*':
|
||||
i += 2
|
||||
for i+1 < len(b) && !(b[i] == '*' && b[i+1] == '/') {
|
||||
i++
|
||||
}
|
||||
i += 2
|
||||
default:
|
||||
out = append(out, c)
|
||||
i++
|
||||
}
|
||||
}
|
||||
return dropTrailingCommas(out)
|
||||
}
|
||||
|
||||
// dropTrailingCommas убирает запятые перед '}' / ']' (допускаются в JSONC).
|
||||
func dropTrailingCommas(b []byte) []byte {
|
||||
out := make([]byte, 0, len(b))
|
||||
for i := 0; i < len(b); i++ {
|
||||
if b[i] == ',' {
|
||||
j := i + 1
|
||||
for j < len(b) && (b[j] == ' ' || b[j] == '\t' || b[j] == '\n' || b[j] == '\r') {
|
||||
j++
|
||||
}
|
||||
if j < len(b) && (b[j] == '}' || b[j] == ']') {
|
||||
continue
|
||||
}
|
||||
}
|
||||
out = append(out, b[i])
|
||||
}
|
||||
return out
|
||||
}
|
||||
85
internal/opencode/config_test.go
Normal file
85
internal/opencode/config_test.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package opencode
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestReadModelRef_String(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "opencode.jsonc")
|
||||
// конфиг с комментариями и trailing-запятыми (JSONC).
|
||||
src := `{
|
||||
// комментарий
|
||||
"model": "tokentool/deepseek/deepseek-v4-flash-0731", /* и блочный */
|
||||
"provider": {
|
||||
"tokentool": {"api": {"type": "aisdk", "package": "@ai-sdk/openai-compatible", "url": "https://x"}},
|
||||
},
|
||||
}`
|
||||
if err := os.WriteFile(path, []byte(src), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
m, err := ReadModelRef(path, "")
|
||||
if err != nil {
|
||||
t.Fatalf("ReadModelRef err: %v", err)
|
||||
}
|
||||
if m == nil || m.ProviderID != "tokentool" || m.ID != "deepseek/deepseek-v4-flash-0731" {
|
||||
t.Errorf("model = %+v, want tokentool/deepseek-v4-flash-0731", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadModelRef_Object(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "opencode.json")
|
||||
src := `{"model": {"providerID": "tokentool", "id": "deepseek/deepseek-v4-flash-0731"}}`
|
||||
if err := os.WriteFile(path, []byte(src), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
m, err := ReadModelRef(path, "")
|
||||
if err != nil {
|
||||
t.Fatalf("ReadModelRef err: %v", err)
|
||||
}
|
||||
if m == nil || m.ID != "deepseek/deepseek-v4-flash-0731" {
|
||||
t.Errorf("model = %+v, want object-форма", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadModelRef_Missing(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "opencode.json")
|
||||
src := `{"provider": {}}`
|
||||
if err := os.WriteFile(path, []byte(src), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
m, err := ReadModelRef(path, "")
|
||||
if err != nil {
|
||||
t.Fatalf("ReadModelRef err: %v", err)
|
||||
}
|
||||
if m != nil {
|
||||
t.Errorf("model = %+v, want nil (model не задан)", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadModelRef_NoFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
m, err := ReadModelRef(filepath.Join(dir, "nope.json"), dir)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadModelRef err: %v", err)
|
||||
}
|
||||
if m != nil {
|
||||
t.Errorf("model = %+v, want nil", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadModelRef_Bad(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "opencode.json")
|
||||
src := `{"model": 12345}`
|
||||
if err := os.WriteFile(path, []byte(src), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
if _, err := ReadModelRef(path, ""); err == nil {
|
||||
t.Error("ReadModelRef должен упасть на некорректной model")
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"sync"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -20,11 +20,13 @@ type Result struct {
|
||||
SessionID string
|
||||
}
|
||||
|
||||
// Runner — запуск opencode-субагентов через HTTP API serve.
|
||||
// Runner — запуск opencode-субагентов через v2 HTTP API serve.
|
||||
//
|
||||
// Полный переход на API: Runner ходит к opencode serve через Pool→Client
|
||||
// (нет spawn-модели, нет NDJSON). Агент идёт в сервер пула для своего каталога
|
||||
// (в нём запущен serve → он его project).
|
||||
// Runner ходит к opencode serve через Pool→Client (пути /api/*, см. README,
|
||||
// минимальная версия opencode). Промпт отправляется неблокирующе (durable
|
||||
// admit), вердикт собирается поллингом новых assistant-сообщений; завершение
|
||||
// ответа определяется по схеме «сессия больше не в активных дренажах» + финальное
|
||||
// assistant-сообщение.
|
||||
type Runner struct {
|
||||
Pool *Pool // пул serve-серверов (обязательный)
|
||||
IdleTimeout time.Duration
|
||||
@@ -73,49 +75,63 @@ func (r *Runner) Run(ctx context.Context, prompt, cwd, agent, sessionID string)
|
||||
}
|
||||
c := &Client{BaseURL: srv.Addr(), Password: srv.Password, Debug: r.Debug}
|
||||
|
||||
// Модель по умолчанию из конфига opencode — хардпиним её в сессии, чтобы
|
||||
// не зависеть от fallback-логики opencode (класс O5 WARN: если модель не
|
||||
// считывается/не задана — предупреждаем и работаем без явного указания).
|
||||
model, mErr := ReadModelRef(srv.Config, srv.ConfigDir)
|
||||
if mErr != nil {
|
||||
r.logf("WARN opencode: не удалось прочитать model из конфига: %v", mErr)
|
||||
} else if model == nil {
|
||||
r.logf("WARN opencode: в конфиге opencode не задан top-level model — модель не хардпинится (риск fallback)")
|
||||
} else {
|
||||
r.logf("opencode(%s) model=%s", agent, model)
|
||||
}
|
||||
|
||||
// Сессия: заданная (resume) или новая.
|
||||
sid := sessionID
|
||||
if sid == "" {
|
||||
sid, err = c.CreateSession(ctx, "ratatoskr-"+agent)
|
||||
sid, err = c.CreateSession(ctx, model)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("opencode: create session: %w", err)
|
||||
}
|
||||
r.logf("opencode(%s) session=%s на %s", agent, sid, srv.Addr())
|
||||
}
|
||||
|
||||
// Отправляем промпт (блокирующий Send в горутине; вердикт придёт из него),
|
||||
// параллельно поллим прогресс и контролируем idle/hard таймауты.
|
||||
return r.awaitVerdict(ctx, c, sid, agent, prompt)
|
||||
return r.awaitVerdict(ctx, c, model, sid, agent, prompt)
|
||||
}
|
||||
|
||||
// awaitVerdict запускает блокирующий Send и параллельно поллит прогресс
|
||||
// (рост числа text-частей = агент жив, сбрасывает idle). Возвращается вердикт
|
||||
// из ответа Send, либо rc=-1 при idle/hard таймауте (тогда Abort + отмена ctx).
|
||||
func (r *Runner) awaitVerdict(ctx context.Context, c *Client, sid, agent, prompt string) (*Result, error) {
|
||||
sendCtx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
type sendOut struct {
|
||||
vd string
|
||||
err error
|
||||
}
|
||||
sendCh := make(chan sendOut, 1)
|
||||
go func() {
|
||||
vd, err := c.Send(sendCtx, sid, prompt)
|
||||
sendCh <- sendOut{vd: vd, err: err}
|
||||
}()
|
||||
// settlePolls — сколько подряд опросов должно подтвердить завершение ответа,
|
||||
// прежде чем считать вердикт финальным (устойчивость к гонке между удалением
|
||||
// сессии из активных дренажей и финализацией последнего сообщения).
|
||||
const settlePolls = 2
|
||||
|
||||
// Прогресс = сумма text-частей во всех assistant-сообщениях сессии. Рост
|
||||
// сбрасывает idle-таймер (LLM стримит = жив).
|
||||
var mu sync.Mutex
|
||||
// awaitVerdict отправляет промпт (неблокирующе) и поллит новые assistant-сообщения,
|
||||
// контролируя idle/hard таймауты. Завершение: сессия ушла из активных дренажей
|
||||
// И есть новое завершённое assistant-сообщение, стабильное в течение settlePolls
|
||||
// опросов. Возвращает вердикт (текст text-партов), либо rc=-1 при таймауте.
|
||||
func (r *Runner) awaitVerdict(ctx context.Context, c *Client, model *ModelRef, sid, agent, prompt string) (*Result, error) {
|
||||
// admit промпта; граница «новых» сообщений — время создания user-сообщения.
|
||||
admittedAt := time.Now().UnixMilli()
|
||||
adm, err := c.Prompt(ctx, sid, prompt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if adm != nil && adm.TimeCreated > 0 {
|
||||
admittedAt = adm.TimeCreated
|
||||
}
|
||||
|
||||
// Прогресс = число text-партов в новых assistant-сообщениях. Рост сбрасывает
|
||||
// idle-таймер (LLM стримит = жив).
|
||||
lastCount := -1
|
||||
lastProgress := time.Now()
|
||||
launch := time.Now()
|
||||
|
||||
doneSeen, emptySeen := 0, 0
|
||||
|
||||
abortAnd := func(rc int, why string) (*Result, error) {
|
||||
if err := c.Abort(ctx, sid); err != nil {
|
||||
r.logf("opencode(%s) abort %s: %v", agent, why, err)
|
||||
if err := c.Interrupt(ctx, sid); err != nil {
|
||||
r.logf("opencode(%s) interrupt %s: %v", agent, why, err)
|
||||
}
|
||||
cancel()
|
||||
return &Result{RC: rc, Stdout: "", SessionID: sid}, nil
|
||||
}
|
||||
|
||||
@@ -125,43 +141,84 @@ func (r *Runner) awaitVerdict(ctx context.Context, c *Client, sid, agent, prompt
|
||||
return abortAnd(-1, "ctx")
|
||||
}
|
||||
|
||||
count, _ := c.textCount(ctx, sid)
|
||||
mu.Lock()
|
||||
msgs, err := c.Messages(ctx, sid)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return abortAnd(-1, "ctx")
|
||||
}
|
||||
var ce *ClientErr
|
||||
if errors.As(err, &ce) && ce.Op == "connect" {
|
||||
return nil, fmt.Errorf("opencode: %w", err)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
active, err := c.Active(ctx, sid)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return abortAnd(-1, "ctx")
|
||||
}
|
||||
var ce *ClientErr
|
||||
if errors.As(err, &ce) && ce.Op == "connect" {
|
||||
return nil, fmt.Errorf("opencode: %w", err)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cur, count := newestAssistant(msgs, admittedAt)
|
||||
if count != lastCount {
|
||||
lastProgress = time.Now()
|
||||
lastCount = count
|
||||
}
|
||||
mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
if now.Sub(lastProgress) > r.IdleTimeout {
|
||||
r.logf("opencode(%s) idle %.0fs — abort", agent, r.IdleTimeout.Seconds())
|
||||
return abortAnd(-1, "idle")
|
||||
}
|
||||
// hard — общий бюджет от старта запуска.
|
||||
if now.Sub(launch) > r.HardTimeout {
|
||||
r.logf("opencode(%s) hard timeout %.0fs — abort", agent, r.HardTimeout.Seconds())
|
||||
return abortAnd(-1, "hard")
|
||||
}
|
||||
|
||||
select {
|
||||
case out := <-sendCh:
|
||||
// Send завершился. Ошибка — connect (сервер недоступен) и ctx жив →
|
||||
// фатально, не таймаут. Если ctx уже отменён — это обрыв, а не ошибка.
|
||||
if out.err != nil {
|
||||
var ce *ClientErr
|
||||
if errors.As(out.err, &ce) && ce.Op == "connect" && ctx.Err() == nil {
|
||||
return nil, fmt.Errorf("opencode: %w", out.err)
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return abortAnd(-1, "ctx")
|
||||
}
|
||||
return nil, out.err
|
||||
switch {
|
||||
case !active && cur != nil && cur.finished():
|
||||
// ответ закончен — ждём стабильности, затем собираем вердикт
|
||||
doneSeen++
|
||||
emptySeen = 0
|
||||
if doneSeen >= settlePolls {
|
||||
return r.verdict(model, cur, msgs, admittedAt, sid)
|
||||
}
|
||||
r.logf("opencode(%s) вердикт готов (%d байт)", agent, len(out.vd))
|
||||
return &Result{RC: 0, Stdout: out.vd, SessionID: sid}, nil
|
||||
case !active && cur == nil:
|
||||
// сессия завершилась, но нового assistant-сообщения так и нет
|
||||
emptySeen++
|
||||
if emptySeen >= settlePolls {
|
||||
return nil, &ClientErr{Op: "prompt", Err: errors.New("агент не выдал ответ (сессия пуста)")}
|
||||
}
|
||||
default:
|
||||
doneSeen, emptySeen = 0, 0
|
||||
}
|
||||
|
||||
select {
|
||||
case <-time.After(r.PollInterval):
|
||||
case <-ctx.Done():
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// verdict собирает финальный результат из новых assistant-сообщений.
|
||||
// Проверяет фактическую модель ответа и логирует warning при расхождении
|
||||
// с ожидаемой (устойчивость к «не той» модели — класс O5 WARN).
|
||||
func (r *Runner) verdict(model *ModelRef, cur *v2Message, msgs []v2Message, since int64, sid string) (*Result, error) {
|
||||
if model != nil && cur.Model != nil && (model.ProviderID != cur.Model.ProviderID || model.ID != cur.Model.ID) {
|
||||
r.logf("WARN opencode: сессия %s отвечала моделью %s, а не ожидаемой %s — проверь providers в конфиге (v2-схема: provider.api / request, а не npm/options)", sid, cur.Model, model)
|
||||
}
|
||||
if cur.Error != nil && cur.Error.Message != "" {
|
||||
return nil, &ClientErr{Op: "prompt", Err: errors.New(cur.Error.Message)}
|
||||
}
|
||||
texts := assistantText(msgs, since)
|
||||
if len(texts) == 0 {
|
||||
return nil, &ClientErr{Op: "prompt", Err: errors.New("нет text-части в ответе")}
|
||||
}
|
||||
vd := stripFence(strings.Join(texts, "\n"))
|
||||
r.logf("opencode вердикт готов (%d байт)", len(vd))
|
||||
return &Result{RC: 0, Stdout: vd, SessionID: sid}, nil
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package opencode
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -9,6 +10,8 @@ import (
|
||||
|
||||
// fakePool создаёт Pool, в котором уже «живёт» сервер для каталога (без spawn):
|
||||
// Server{URL: fake.URL}, поэтому Runner ходит по HTTP на фейк-API.
|
||||
// XDG_CONFIG_HOME уводится во временный каталог, чтобы ReadModelRef не читал
|
||||
// реальный пользовательский конфиг opencode (детерминизм тестов).
|
||||
func fakePool(t *testing.T, f *fakeAPIServer, dir string) (*Pool, *Client) {
|
||||
t.Helper()
|
||||
ts := httptestURL(t, f)
|
||||
@@ -28,13 +31,12 @@ func httptestURL(t *testing.T, f *fakeAPIServer) string {
|
||||
}
|
||||
|
||||
func TestRun_Success(t *testing.T) {
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
dir := t.TempDir()
|
||||
f := &fakeAPIServer{
|
||||
verdictParts: []part{{Type: "text", Text: "done"}},
|
||||
}
|
||||
f := &fakeAPIServer{verdictText: "done"}
|
||||
p, _ := fakePool(t, f, dir)
|
||||
|
||||
r := &Runner{Pool: p, PollInterval: 5 * time.Millisecond}
|
||||
r := &Runner{Pool: p, PollInterval: 5 * time.Millisecond, Stdout: io.Discard}
|
||||
res, err := r.Run(context.Background(), "task", dir, "dev", "")
|
||||
if err != nil {
|
||||
t.Fatalf("Run err: %v", err)
|
||||
@@ -51,13 +53,14 @@ func TestRun_Success(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRun_IdleTimeout(t *testing.T) {
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
dir := t.TempDir()
|
||||
// prompt блокируется (агент «завис»), прогресс не растёт → idle abort
|
||||
// агент «завис»: active=true, прогресс не растёт → idle abort
|
||||
f := &fakeAPIServer{blockPrompt: true}
|
||||
p, _ := fakePool(t, f, dir)
|
||||
|
||||
r := &Runner{Pool: p, IdleTimeout: 30 * time.Millisecond,
|
||||
PollInterval: 5 * time.Millisecond}
|
||||
PollInterval: 5 * time.Millisecond, Stdout: io.Discard}
|
||||
res, err := r.Run(context.Background(), "task", dir, "dev", "")
|
||||
if err != nil {
|
||||
t.Fatalf("Run err: %v", err)
|
||||
@@ -68,13 +71,14 @@ func TestRun_IdleTimeout(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRun_ContextCancel(t *testing.T) {
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
dir := t.TempDir()
|
||||
f := &fakeAPIServer{blockPrompt: true}
|
||||
p, _ := fakePool(t, f, dir)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
r := &Runner{Pool: p, IdleTimeout: time.Minute, HardTimeout: time.Minute,
|
||||
PollInterval: 5 * time.Millisecond}
|
||||
PollInterval: 5 * time.Millisecond, Stdout: io.Discard}
|
||||
done := make(chan *Result, 1)
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
|
||||
@@ -143,9 +143,15 @@ func (s *Server) serveCmd(ctx context.Context) *exec.Cmd {
|
||||
return cmd
|
||||
}
|
||||
|
||||
// waitHealthy опрашивает /global/health сервера до первого успеха или Connect.
|
||||
// MinVersion — минимальная версия opencode, с которой работает интеграция.
|
||||
// v2 HTTP API (префикс /api/*) присутствует в сборках dev / >=1.18.18.
|
||||
// Более старые бинари отвечают на /global/health и НЕ подходят.
|
||||
const MinVersion = "1.18.18"
|
||||
|
||||
// waitHealthy опрашивает /api/health сервера до первого успеха или Connect.
|
||||
// Возвращает nil, как только сервер ответил {healthy:true} (или 200/401 — сервер
|
||||
// жив, но может требовать авторизации).
|
||||
// жив, но может требовать авторизации). При неудаче — ошибка с подсказкой про
|
||||
// минимальную версию opencode (класс O1: старый бинарь не знает v2-путей).
|
||||
func (s *Server) waitHealthy(ctx context.Context, addr string) error {
|
||||
deadline := time.Now().Add(60 * time.Second)
|
||||
poll := s.PollInterval
|
||||
@@ -154,7 +160,7 @@ func (s *Server) waitHealthy(ctx context.Context, addr string) error {
|
||||
return nil
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return fmt.Errorf("opencode serve %s: не стал доступным (healthcheck)", addr)
|
||||
return fmt.Errorf("opencode serve %s: не стал доступным (v2 healthcheck). Нужен opencode >= %s (v2 HTTP API /api/*), а не старый бинарь", addr, MinVersion)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
@@ -167,7 +173,7 @@ func (s *Server) waitHealthy(ctx context.Context, addr string) error {
|
||||
// healthGET делает GET на адрес и возвращает true, если сервер ответил.
|
||||
// 401 (basic auth требуется) тоже считается «жив» — сервер доступен.
|
||||
func (s *Server) healthGET(ctx context.Context, addr string) bool {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, addr+"/global/health", nil)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, addr+"/api/health", nil)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user