Files
ratatoskr-go/internal/opencode/client.go
Hermes 7eb3a0292c
Some checks failed
CI / test (push) Failing after 32s
CI / build-and-package (amd64, linux) (push) Successful in 38s
CI / build-and-package (amd64, windows) (push) Successful in 38s
fix(opencode): убрать смешение слоёв API — перейти целиком на experimental (/session)
Корень проблемы «не получаем результаты»: клиент смешивал два слоя opencode
serve. CreateSession ходил на /api/session (v2, ждал {data.id}), Verdict — на
/api/session/{id}/message?order=desc и ждал {data:[{type,content}]}, где поле
content[].type/text физически отсутствует, поэтому вердикт никогда не находился
и поллинг уходил в вечный таймаут. Abort и вовсе звал несуществующий /interrupt.

Теперь весь код на experimental-слое, как сверено с sst/opencode (ветка dev):
- CreateSession: POST /session → голая Session, id в .id.
- Send: блокирующий POST /session/{id}/message, тело {parts:[{type:text,text}]},
  вердикт из частей parts[].type=="text" ответа. Это и есть результат — метод
  Verdict и отдельный GET удалены.
- textCount (прогресс): GET /session/{id}/message → голый массив [{info, parts}].
- Abort: POST /session/{id}/abort.

Runner: блокирующий Send запускается в горутине (канал вердикта/ошибки),
параллельно поллим textCount (рост text-частей сбрасывает idle-таймер). При
idle/hard-таймауте или отмене контекста — Abort + cancel() Send-горутины → rc=-1.

Send ходит через отдельный http.Client без жёсткого Timeout (управляется ctx),
чтобы длинная генерация не обрывалась на 30s. Тесты/fakeAPIServer переведены на
экспериментальный формат. Версия → 0.2.2.
2026-08-18 20:49:34 +05:00

220 lines
7.5 KiB
Go
Raw Permalink 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"
"log"
"net/http"
"time"
)
// Client — HTTP-взаимодействие с одним opencode serve (режим 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 прервать выполняющийся ответ
//
// Вердикт собирается из parts[] ответа на POST /message: текст тех частей,
// где type == "text".
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)
}
// ClientErr — классы ошибок клиента.
type ClientErr struct {
Op string // "connect" | "create" | "prompt" | "messages" | "abort"
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}
}
if c.httpSend == nil {
c.httpSend = &http.Client{}
}
}
// do выполняет запрос через c.http (с таймаутом 30s) и возвращает тело при 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 {
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")
}
if c.Debug {
log.Printf("opencode api %s -> %s %s%s", op, method, c.BaseURL, path)
if len(body) > 0 {
log.Printf("opencode api %s request body: %s", op, truncateStr(string(body), 5000))
}
}
resp, err := hc.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 {
if c.Debug {
log.Printf("opencode api %s response: status %d: %s", op, resp.StatusCode, truncateStr(string(b), 1000))
}
return nil, &ClientErr{Op: op, Err: fmt.Errorf("status %d: %s", resp.StatusCode, truncateStr(string(b), 300))}
}
if c.Debug {
log.Printf("opencode api %s response (%d bytes): %s", op, len(b), truncateStr(string(b), 5000))
}
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, "/session", "create", b)
if err != nil {
return "", err
}
// experimental: ответ — голая Session (без обёртки {data}).
var out struct {
ID string `json:"id"`
}
if err := json.Unmarshal(raw, &out); err != nil {
return "", &ClientErr{Op: "create", Err: fmt.Errorf("невалидный ответ: %v", err)}
}
if out.ID == "" {
return "", &ClientErr{Op: "create", Err: fmt.Errorf("пустой id сессии")}
}
return out.ID, nil
}
// Send отправляет промпт в сессию, БЛОКИРУЯСЬ до завершения ответа, и
// возвращает вердикт (текст text-частей из parts). Отмена — только через ctx
// (используется отдельный клиент без жёсткого таймаута; idle/hard в Runner'е
// отменяют контекст, что прерывает этот запрос).
func (c *Client) Send(ctx context.Context, sessionID, prompt string) (string, error) {
payload := map[string]any{
"parts": []map[string]string{{"type": "text", "text": prompt}},
}
b, _ := json.Marshal(payload)
c.defaults()
raw, err := c.doHTTP(ctx, http.MethodPost, "/session/"+sessionID+"/message", "prompt", b, c.httpSend)
if err != nil {
return "", err
}
var out struct {
Parts []part `json:"parts"`
}
if err := json.Unmarshal(raw, &out); err != nil {
return "", &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 buf.Len() == 0 {
return "", &ClientErr{Op: "prompt", Err: fmt.Errorf("нет text-части в ответе")}
}
return stripFence(buf.String()), 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
}
// part — минимальная часть сообщения (из parts[]).
type part 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"`
}
// messages возвращает сырые сообщения сессии (для поллинга прогресса).
func (c *Client) messages(ctx context.Context, sessionID string) ([]message, error) {
raw, err := c.do(ctx, http.MethodGet, "/session/"+sessionID+"/message", "messages", nil)
if err != nil {
return nil, err
}
var out []message
if err := json.Unmarshal(raw, &out); err != nil {
return nil, err
}
return out, nil
}
// textCount считает число text-частей в assistant-сообщениях (для progress).
func (c *Client) textCount(ctx context.Context, sessionID string) (int, error) {
msgs, err := c.messages(ctx, sessionID)
if err != nil {
return 0, err
}
n := 0
for _, m := range msgs {
if m.Info.Role != "assistant" {
continue
}
for _, p := range m.Parts {
if p.Type == "text" && p.Text != "" {
n++
}
}
}
return n, nil
}
func truncateStr(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "..."
}