Files
ratatoskr-go/internal/opencode/client.go
Hermes 733e63339a
Some checks failed
CI / test (push) Successful in 40s
CI / build-and-package (amd64, linux) (push) Successful in 35s
CI / build-and-package (amd64, windows) (push) Failing after 26s
feat: opencode через HTTP API — пул serve-серверов вместо spawn/NDJSON
Runner теперь ходит к постоянным serve по HTTP API (v1.17+, /api):
- клиент Client (create/send/wait/abort/messages/verdict)
- Pool: по одному serve на каталог, ленивый подъём, root-сервер в worktree,
  выделение портов, ReleaseTask при завершении задачи
- Run: CreateSession('ratatoskr-<агент>') -> Send -> поллинг Verdict из
  text-частей assistant-сообщений; idle/hard таймауты дают RC=-1
- вердикт извлекается из последнего assistant text-парта (плоский text)
- тесты: unit на фейковом HTTP-сервере; e2e эмулирует serve через httptest,
  агент определяется по title сессии
2026-08-18 13:36:54 +05:00

180 lines
6.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package opencode
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
// Client — HTTP-взаимодействие с одним opencode serve (режим API).
//
// Ходит по HTTP API opencode serve (v1.17+, префикс /api):
// - POST /api/session создать сессию → {data:{id}}
// - POST /api/session/:id/prompt отправить промпт {prompt:{text}}
// - POST /api/session/:id/wait дождаться завершения ответа (блок)
// - GET /api/session/:id/message?order=desc → {data:[{...}]} история ответов
// - POST /api/session/:id/interrupt прервать выполняющийся ответ
//
// Вердикт собирается из последнего assistant-сообщения: его content[] → текст
// тех частей, где type == "text". (В API-режиме текст в part.text — плоско,
// в отличие от NDJSON run, где он был вложен в part.part.text.)
type Client struct {
BaseURL string // http://host:port (без завершающего слеша)
Password string // basic auth (username "opencode")
http *http.Client
}
// ClientErr — классы ошибок клиента.
type ClientErr struct {
Op string // "connect" | "create" | "prompt" | "wait" | "messages" | "verdict"
Err error
}
func (e *ClientErr) Error() string { return fmt.Sprintf("opencode api %s: %v", e.Op, e.Err) }
func (e *ClientErr) Unwrap() error { return e.Err }
func (c *Client) defaults() {
if c.http == nil {
c.http = &http.Client{Timeout: 30 * time.Second}
}
}
// do выполняет запрос и возвращает тело ответа при 2xx. Иначе — ClientErr.
// op — метка операции (create/prompt/wait/messages/abort) для класса ошибки.
func (c *Client) do(ctx context.Context, method, path, op string, body []byte) ([]byte, error) {
c.defaults()
var rd io.Reader
if body != nil {
rd = bytes.NewReader(body)
}
req, err := http.NewRequestWithContext(ctx, method, c.BaseURL+path, rd)
if err != nil {
return nil, &ClientErr{Op: "connect", Err: err}
}
if c.Password != "" {
req.SetBasicAuth("opencode", c.Password)
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.http.Do(req)
if err != nil {
return nil, &ClientErr{Op: "connect", Err: err}
}
defer resp.Body.Close()
b, err := io.ReadAll(resp.Body)
if err != nil {
return nil, &ClientErr{Op: "connect", Err: err}
}
if resp.StatusCode < 200 || resp.StatusCode > 299 {
return nil, &ClientErr{Op: op, Err: fmt.Errorf("status %d: %s", resp.StatusCode, truncateStr(string(b), 300))}
}
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
}
b, _ := json.Marshal(body)
raw, err := c.do(ctx, http.MethodPost, "/api/session", "create", b)
if err != nil {
return "", err
}
var out struct {
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.Data.ID == "" {
return "", &ClientErr{Op: "create", Err: fmt.Errorf("пустой id сессии")}
}
return out.Data.ID, nil
}
// Prompt отправляет промпт в сессию и ждёт завершения ответа (блокирующий wait).
func (c *Client) Prompt(ctx context.Context, sessionID, prompt string) error {
if err := c.Send(ctx, sessionID, prompt); err != nil {
return err
}
// POST /wait блокирует, пока ответ сессии не завершится.
_, err := c.do(ctx, http.MethodPost, "/api/session/"+sessionID+"/wait", "wait", nil)
return err
}
// Send отправляет промпт в сессию (не ждёт ответа — выполнение идёт в фоне).
// Используется Runner'ом для асинхронного ожидания через поллинг сообщений.
func (c *Client) Send(ctx context.Context, sessionID, prompt string) error {
payload := map[string]any{"prompt": map[string]string{"text": prompt}}
b, _ := json.Marshal(payload)
_, err := c.do(ctx, http.MethodPost, "/api/session/"+sessionID+"/prompt", "prompt", b)
return err
}
// Abort прерывает выполняющийся ответ сессии.
func (c *Client) Abort(ctx context.Context, sessionID string) error {
_, err := c.do(ctx, http.MethodPost, "/api/session/"+sessionID+"/interrupt", "abort", nil)
return err
}
// sessionMessage — минимальная структура сообщения из GET /api/session/:id/message.
type sessionMessage struct {
Type string `json:"type"` // "assistant" | "user" | ...
Content []struct {
Type string `json:"type"` // "text" | "reasoning" | "tool"
Text string `json:"text"`
} `json:"content"`
}
// Verdict возвращает текст последнего assistant-сообщения сессии (вердикт).
// Ошибка — класса ErrVerdict (ClientErr{Op:"verdict"}), если нет готового
// assistant-сообщения или в нём нет text-частей.
func (c *Client) Verdict(ctx context.Context, sessionID string) (string, error) {
// order=desc — самые свежие сообщения первыми; идём по ним в поисках
// первого незаконченного assistant-ответа.
raw, err := c.do(ctx, http.MethodGet, "/api/session/"+sessionID+"/message?order=desc&limit=50", "messages", nil)
if err != nil {
return "", err
}
var out struct {
Data []sessionMessage `json:"data"`
}
if err := json.Unmarshal(raw, &out); err != nil {
return "", &ClientErr{Op: "verdict", Err: fmt.Errorf("невалидный ответ: %v", err)}
}
var b bytes.Buffer
for _, m := range out.Data {
if m.Type != "assistant" {
continue
}
b.Reset()
for _, p := range m.Content {
if p.Type == "text" && p.Text != "" {
if b.Len() > 0 {
b.WriteString("\n")
}
b.WriteString(p.Text)
}
}
if b.Len() > 0 {
return stripFence(b.String()), nil
}
}
return "", &ClientErr{Op: "verdict", Err: fmt.Errorf("нет assistant-сообщения с text-частью")}
}
func truncateStr(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "..."
}