fix(opencode): убрать смешение слоёв API — перейти целиком на experimental (/session)
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

Корень проблемы «не получаем результаты»: клиент смешивал два слоя 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.
This commit is contained in:
Hermes
2026-08-18 20:49:34 +05:00
parent a64e3d6cc3
commit 7eb3a0292c
6 changed files with 251 additions and 231 deletions

View File

@@ -13,26 +13,27 @@ import (
// Client — HTTP-взаимодействие с одним opencode serve (режим API).
//
// Ходит по HTTP API opencode serve (v1.18+, префикс /api):
// - POST /api/session создать сессию → {data:{id}}
// - POST /session/:id/message отправить промпт {parts:[{type:"text"}]}
// - POST /api/session/:id/wait дождаться завершения ответа (блок)
// - GET /api/session/:id/message?order=desc → {data:[{...}]} история ответов
// - POST /api/session/:id/interrupt прервать выполняющийся ответ
// Ходит по 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 прервать выполняющийся ответ
//
// Вердикт собирается из последнего assistant-сообщения: его content[] → текст
// тех частей, где type == "text". (В API-режиме текст в part.text — плоско,
// в отличие от NDJSON run, где он был вложен в part.part.text.)
// Вердикт собирается из 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
http *http.Client // для быстрых операций (create/messages/abort)
httpSend *http.Client // для блокирующего Send — без жёсткого таймаута,
// отменяется только через контекст (idle/hard)
}
// ClientErr — классы ошибок клиента.
type ClientErr struct {
Op string // "connect" | "create" | "prompt" | "wait" | "messages" | "verdict"
Op string // "connect" | "create" | "prompt" | "messages" | "abort"
Err error
}
@@ -43,11 +44,19 @@ 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 выполняет запрос и возвращает тело ответа при 2xx. Иначе — ClientErr.
// op — метка операции (create/prompt/wait/messages/abort) для класса ошибки.
// 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 {
@@ -69,7 +78,7 @@ func (c *Client) do(ctx context.Context, method, path, op string, body []byte) (
log.Printf("opencode api %s request body: %s", op, truncateStr(string(body), 5000))
}
}
resp, err := c.http.Do(req)
resp, err := hc.Do(req)
if err != nil {
return nil, &ClientErr{Op: "connect", Err: err}
}
@@ -97,96 +106,109 @@ func (c *Client) CreateSession(ctx context.Context, title string) (string, error
body["title"] = title
}
b, _ := json.Marshal(body)
raw, err := c.do(ctx, http.MethodPost, "/api/session", "create", b)
raw, err := c.do(ctx, http.MethodPost, "/session", "create", b)
if err != nil {
return "", err
}
// experimental: ответ — голая Session (без обёртки {data}).
var out struct {
Data struct {
ID string `json:"id"`
} `json:"data"`
ID string `json:"id"`
}
if err := json.Unmarshal(raw, &out); err != nil {
return "", &ClientErr{Op: "create", Err: fmt.Errorf("невалидный ответ: %v", err)}
}
if out.Data.ID == "" {
if out.ID == "" {
return "", &ClientErr{Op: "create", Err: fmt.Errorf("пустой id сессии")}
}
return out.Data.ID, nil
return out.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'ом для асинхронного ожидания через поллинг сообщений.
// Формат тела — v1.18: parts:[{type:"text"}], а не {prompt:{text}}.
func (c *Client) Send(ctx context.Context, sessionID, prompt string) error {
// 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)
_, err := c.do(ctx, http.MethodPost, "/session/"+sessionID+"/message", "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)
c.defaults()
raw, err := c.doHTTP(ctx, http.MethodPost, "/session/"+sessionID+"/message", "prompt", b, c.httpSend)
if err != nil {
return "", err
}
var out struct {
Data []sessionMessage `json:"data"`
Parts []part `json:"parts"`
}
if err := json.Unmarshal(raw, &out); err != nil {
return "", &ClientErr{Op: "verdict", Err: fmt.Errorf("невалидный ответ: %v", err)}
return "", &ClientErr{Op: "prompt", Err: fmt.Errorf("невалидный ответ: %v", err)}
}
var b bytes.Buffer
for _, m := range out.Data {
if m.Type != "assistant" {
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
}
b.Reset()
for _, p := range m.Content {
for _, p := range m.Parts {
if p.Type == "text" && p.Text != "" {
if b.Len() > 0 {
b.WriteString("\n")
}
b.WriteString(p.Text)
n++
}
}
if b.Len() > 0 {
return stripFence(b.String()), nil
}
}
return "", &ClientErr{Op: "verdict", Err: fmt.Errorf("нет assistant-сообщения с text-частью")}
return n, nil
}
func truncateStr(s string, n int) string {
@@ -194,4 +216,4 @@ func truncateStr(s string, n int) string {
return s
}
return s[:n] + "..."
}
}