From 9f64be4ea5f6435bc06ccc457f65c9dde5bbd80b Mon Sep 17 00:00:00 2001 From: "ki.sagidullin" Date: Wed, 19 Aug 2026 08:29:11 +0500 Subject: [PATCH] =?UTF-8?q?feat(opencode):=20=D0=BF=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D1=85=D0=BE=D0=B4=20=D0=BD=D0=B0=20v2=20HTTP=20API=20opencode?= =?UTF-8?q?=20(=D1=85=D0=B0=D1=80=D0=B4=D0=BF=D0=B8=D0=BD=20=D0=BC=D0=BE?= =?UTF-8?q?=D0=B4=D0=B5=D0=BB=D0=B8,=20=D0=BF=D0=BE=D0=BB=D0=BB=D0=B8?= =?UTF-8?q?=D0=BD=D0=B3=20=D0=B2=D0=B5=D1=80=D0=B4=D0=B8=D0=BA=D1=82=D0=B0?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .serena/memories/core.md | 21 ++- .serena/memories/tech_stack.md | 2 + README.md | 31 +++- internal/opencode/client.go | 270 +++++++++++++++++++++---------- internal/opencode/client_test.go | 268 +++++++++++++++++++++--------- internal/opencode/config.go | 176 ++++++++++++++++++++ internal/opencode/config_test.go | 85 ++++++++++ internal/opencode/runner.go | 159 ++++++++++++------ internal/opencode/runner_test.go | 18 ++- internal/opencode/server.go | 14 +- 10 files changed, 818 insertions(+), 226 deletions(-) create mode 100644 internal/opencode/config.go create mode 100644 internal/opencode/config_test.go diff --git a/.serena/memories/core.md b/.serena/memories/core.md index 6ae8029..822629e 100644 --- a/.serena/memories/core.md +++ b/.serena/memories/core.md @@ -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-/` (не `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)` — сигнатура. diff --git a/.serena/memories/tech_stack.md b/.serena/memories/tech_stack.md index ff27906..f135264 100644 --- a/.serena/memories/tech_stack.md +++ b/.serena/memories/tech_stack.md @@ -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- -X main.updateToken=..." -o ratatoskr ./cmd/ratatoskr/. diff --git a/README.md b/README.md index b5fbc51..34b8f44 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ internal/ analyst/ # аналитик: промпт + разбор JSON-решения (opencode agent, классы A1–A4) worker/ # polling-планировщик + dev/reviewer-конвейер (классы W*, E*, R*) agents/ # встроенные агенты opencode (analyst.md, dev.md, reviewer.md) через go:embed - opencode/ # обёртка запуска opencode-процесса, парсинг вердикта (классы O1–O4) + opencode/ # HTTP-клиент v2 API opencode serve, поллинг вердикта (классы O1–O5) storage/ # SQLite (modernc.org/sqlite, без CGO): tasks, traces, task_history (S1–S5) update/ # автообновление из Gitea Packages (классы U1–U6) ``` @@ -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 | C1–C4 | `internal/config` | | A | A1–A4 | `internal/analyst` | | M | M1–M5 | `internal/chat` | -| O | O1–O4 | `internal/opencode` | +| O | O1–O5 | `internal/opencode` | | S | S1–S5 | `internal/storage` | | W | W1–W5 | `internal/worker` | | E | E1–E4 | `internal/worker` (репозитории) | diff --git a/internal/opencode/client.go b/internal/opencode/client.go index 6c7e795..fad9816 100644 --- a/internal/opencode/client.go +++ b/internal/opencode/client.go @@ -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 { diff --git a/internal/opencode/client_test.go b/internal/opencode/client_test.go index e231f87..50dc6c7 100644 --- a/internal/opencode/client_test.go +++ b/internal/opencode/client_test.go @@ -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") } } \ No newline at end of file diff --git a/internal/opencode/config.go b/internal/opencode/config.go new file mode 100644 index 0000000..f4bde08 --- /dev/null +++ b/internal/opencode/config.go @@ -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 +} diff --git a/internal/opencode/config_test.go b/internal/opencode/config_test.go new file mode 100644 index 0000000..b25c39f --- /dev/null +++ b/internal/opencode/config_test.go @@ -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") + } +} \ No newline at end of file diff --git a/internal/opencode/runner.go b/internal/opencode/runner.go index c8a99f6..41683b9 100644 --- a/internal/opencode/runner.go +++ b/internal/opencode/runner.go @@ -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(): } } -} \ No newline at end of file +} + +// 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 +} diff --git a/internal/opencode/runner_test.go b/internal/opencode/runner_test.go index f02a172..5791131 100644 --- a/internal/opencode/runner_test.go +++ b/internal/opencode/runner_test.go @@ -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() { diff --git a/internal/opencode/server.go b/internal/opencode/server.go index 51b869a..c8aecb8 100644 --- a/internal/opencode/server.go +++ b/internal/opencode/server.go @@ -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 }