2 Commits

Author SHA1 Message Date
3c8528dbd9 Merge pull request 'feat(opencode): переход на v2 HTTP API opencode (хардпин модели, поллинг вердикта)' (#5) from feat/ad025c1 into main
Some checks failed
CI / test (push) Failing after 30s
CI / build-and-package (amd64, linux) (push) Successful in 49s
CI / build-and-package (amd64, windows) (push) Successful in 52s
Reviewed-on: http://gitea.hal9000.home/kamelion/ratatoskr-go/pulls/5
2026-08-19 08:55:16 +05:00
ki.sagidullin
9f64be4ea5 feat(opencode): переход на v2 HTTP API opencode (хардпин модели, поллинг вердикта)
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
- 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
10 changed files with 818 additions and 226 deletions

View File

@@ -16,7 +16,7 @@ internal/
analyst/ аналитик: промпт + разбор JSON-вердикта (opencode agent). Коды A1-A4
worker/ polling-планировщик + dev/reviewer-конвейер + gitops. Коды W*, E*, R*
agents/ встроенные opencode-агенты (analyst.md, dev.md, reviewer.md) через go:embed
opencode/ обёртка запуска opencode, LiveRegistry, парсинг вердикта. Коды O1-O4
opencode/ HTTP-клиент v2 API opencode serve, поллинг вердикта, LiveRegistry. Коды O1-O5
storage/ SQLite (modernc.org/sqlite, без CGO): tasks, traces, task_history. Коды S1-S5
update/ автообновление из Gitea Packages. Коды U1-U6
```
@@ -42,6 +42,25 @@ internal/
- **Автообновление:** авто = только Check+уведомление; замена — по /update. Версии в
Gitea Packages `commit-<sha7>/` (не `latest/`). Вердикты агентов — строгий JSON.
## opencode (v2 HTTP API, >= 1.18.18)
- Интеграция с субагентами — через headless `opencode serve`, **v2 API** (префикс `/api/*`).
Минимальная версия opencode **>= 1.18.18** (старый бинарь — только `/global/health`, не годится;
healthcheck падает с понятной ошибкой, класс O1).
- **Хардпин модели:** при создании сессии читается top-level `"model"` из конфига opencode
(`internal/opencode/config.go`, JSONC-стрип `//`/`/* */`/trailing-запятых) и передаётся в
`POST /api/session` как `{"model":{providerID,id}}` (разбор `provider/id` по первому `/`).
- **Класс O5 WARN (устойчивость к v1-конфигу):** конфиг по старой v1-схеме
(`provider.X.npm`/`options`) молча игнорируется v2 → провайдер без api → модель unsupported →
fallback. Раtatoskr не чинит это сам, но логирует warning: конфиг не читается/нет `model`,
и/или фактическая модель ответа (из assistant-сообщения) ≠ ожидаемой. Правильный v2-вид:
`api:{type:"aisdk",package,url}` и `request.headers` вместо `options.headers`.
- **Поллинг вердикта:** `POST /api/session/:id/prompt` (durable admit, неблокирующий) →
`GET /api/session/:id/message?order=desc&limit=200` (новые assistant-сообщения, текст в
`content[].type=="text"`) → завершение = `GET /api/session/active` без сессии + финальное
assistant-сообщение, стабильное `settlePolls=2` опроса. `POST .../interrupt` вместо abort.
- `ModelRef{ProviderID,ID,Variant}` — аналог v2 Model.Ref; `MinVersion="1.18.18"` в server.go.
## Контракты (не ломать)
- `App.New(configPath, version, updateToken string)` — сигнатура.

View File

@@ -14,6 +14,8 @@
- **opencode** (opencode.ai) — внешний процесс для субагентов (analyst/dev/reviewer).
Управляется через internal/opencode (Runner, LiveRegistry). Настраивается в конфиге
(opencode.bin / config / config_dir / hard_timeout / idle_timeout / poll_ms).
Требуемая версия opencode: **>= 1.18.18** (v2 HTTP API `/api/*`; хардпин model из конфига —
`internal/opencode/config.go`, классы O1/O5). Подробно — `mem:core`.
## Сборка / Makefile
- `make build` — go build -ldflags="-s -w -X main.version=commit-<sha7> -X main.updateToken=..." -o ratatoskr ./cmd/ratatoskr/.

View File

@@ -17,7 +17,7 @@ internal/
analyst/ # аналитик: промпт + разбор JSON-решения (opencode agent, классы A1A4)
worker/ # polling-планировщик + dev/reviewer-конвейер (классы W*, E*, R*)
agents/ # встроенные агенты opencode (analyst.md, dev.md, reviewer.md) через go:embed
opencode/ # обёртка запуска opencode-процесса, парсинг вердикта (классы O1O4)
opencode/ # HTTP-клиент v2 API opencode serve, поллинг вердикта (классы O1O5)
storage/ # SQLite (modernc.org/sqlite, без CGO): tasks, traces, task_history (S1S5)
update/ # автообновление из Gitea Packages (классы U1U6)
```
@@ -129,6 +129,33 @@ update:
| `/status` | версия бинаря + есть ли доступное обновление |
| `/help` | справка по всем командам |
## Интеграция с opencode (субагенты)
Субагенты (analyst / dev / reviewer) запускаются через **headless** `opencode serve`
по **v2 HTTP API** (префикс `/api/*`). Требуемая версия opencode: **>= 1.18.18**
(сборки с v2 HTTP API). Старый бинарь, отвечающий только на `/global/health`,
не подходит: healthcheck падает с понятной ошибкой (класс O1).
Что делает обёртка (`internal/opencode`):
- **Хардпин модели.** При создании сессии в конфиге opencode ищется top-level
`"model"` (`internal/opencode/config.go`) и передаётся в `POST /api/session`
как `{"model":{providerID,id}}`. Это убирает зависимость от fallback-логики
opencode (которая молча выбирает «дефолтную» запись, если модель не задана).
- **Весь код резолва модели устойчив к этому классу проблем (класс O5 WARN):**
- если конфиг не читается / в нём нет `model` — в логи пишется warning;
- фактическая модель ответа (из финального assistant-сообщения) сравнивается
с ожидаемой; расхождение логируется как warning;
- конфиг, написанный по **старой v1-схеме** (`provider.X.npm` / `options`),
молча игнорируется v2 — обёртка этого не «чинит» сама, но предупреждает.
Правильный v2-вид провайдера — `api: { type:"aisdk", package, url }` и
`request.headers` вместо `options.headers`.
- **Поллинг вердикта.** Промпт отправляется неблокирующе (`POST .../prompt`
durable admit), вердикт собирается из новых assistant-сообщений
(`GET .../message`); завершение ответа — сессия ушла из активных дренажей
(`GET .../active`) и появилось финальное assistant-сообщение, стабильное
несколько опросов подряд.
## Фазы аналитика
Аналитик (`internal/analyst`) возвращает JSON-вердикт с полем `phase`:
@@ -198,7 +225,7 @@ curl -s -H "Authorization: token $TOKEN" "$B/api/packages/kamelion/generic/ratat
| C | C1C4 | `internal/config` |
| A | A1A4 | `internal/analyst` |
| M | M1M5 | `internal/chat` |
| O | O1O4 | `internal/opencode` |
| O | O1O5 | `internal/opencode` |
| S | S1S5 | `internal/storage` |
| W | W1W5 | `internal/worker` |
| E | E1E4 | `internal/worker` (репозитории) |

View File

@@ -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 {

View File

@@ -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
View 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
}

View 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")
}
}

View File

@@ -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
}

View File

@@ -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() {

View File

@@ -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
}