From 4c0dc80aa87d3ab1ffde1f515de63fb469cafb44 Mon Sep 17 00:00:00 2001 From: "ki.sagidullin" Date: Sun, 23 Aug 2026 17:22:36 +0500 Subject: [PATCH] =?UTF-8?q?fix(opencode):=20=D0=B2=D1=8B=D0=B1=D0=BE=D1=80?= =?UTF-8?q?=20=D0=BC=D0=BE=D0=B4=D0=B5=D0=BB=D0=B8=20=E2=80=94=20=D1=82?= =?UTF-8?q?=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20=D0=B3=D0=BB=D0=BE=D0=B1=D0=B0?= =?UTF-8?q?=D0=BB=D1=8C=D0=BD=D1=8B=D0=B9=20=D0=BA=D0=BE=D0=BD=D1=84=D0=B8?= =?UTF-8?q?=D0=B3=20opencode;=20=D0=B0=D0=B3=D0=B5=D0=BD=D1=82=20=D0=BF?= =?UTF-8?q?=D0=B5=D1=80=D0=B5=D0=B4=D0=B0=D1=91=D1=82=D1=81=D1=8F=20=D0=B2?= =?UTF-8?q?=20=D1=81=D0=B5=D1=81=D1=81=D0=B8=D1=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CreateSession(ctx, agent) шлёт {agent} в POST /api/session, модель не выбираем - удалён internal/opencode/config.go (ReadModelRef/JSONC-стрип) и его тесты - runner.go больше не читает конфиг opencode и не хардпинит модель - тесты и README обновлены --- .serena/memories/core.md | 4 +- README.md | 19 ++-- internal/opencode/client.go | 15 +-- internal/opencode/client_test.go | 47 ++++----- internal/opencode/config.go | 176 ------------------------------- internal/opencode/config_test.go | 85 --------------- internal/opencode/runner.go | 30 ++---- internal/opencode/runner_test.go | 2 - 8 files changed, 46 insertions(+), 332 deletions(-) delete mode 100644 internal/opencode/config.go delete mode 100644 internal/opencode/config_test.go diff --git a/.serena/memories/core.md b/.serena/memories/core.md index d684417..9ba5482 100644 --- a/.serena/memories/core.md +++ b/.serena/memories/core.md @@ -44,8 +44,8 @@ docs/ ui-spec.md — спека Fyne UI (слои, event-bus, fyne.Do ## opencode (v2 HTTP API, >= 1.18.18) - Интеграция с субагентами — через headless `opencode serve`, **v2 API** (`/api/*`). Версия opencode >= 1.18.18. -- **Хардпин модели:** при создании сессии читается top-level `model` из конфига opencode (`internal/opencode/config.go`, JSONC-стрип) и передаётся в `POST /api/session` как `{"model":{providerID,id}}`. -- **О5 WARN (устойчивость к v1-конфигу):** конфиг по старой схеме молча игнорируется v2; провайдер без api → unsupported модели → fallback. Ratatoskr не чинит сам, но логирует warning; фактическая модель ответа сравнивается с ожидаемой. Правильный v2-вид: `api:{type:"aisdk",package,url}`, `request.headers` вместо `options.headers`. +- **Модель — только глобальный конфиг opencode.** Ratatoskr модель не выбирает и про неё не знает: opencode сам берёт модель по умолчанию из своего глобального конфига. Код opencode-конфиг не читает (config.go удалён). +- **Свой агент:** при создании сессии в `POST /api/session` передаётся `agent` (analyst/dev/reviewer/chat/postmortem) из встроенных определений (`internal/agents`, распаковка в `OPENCODE_CONFIG_DIR`). - **Поллинг вердикта:** `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. ## Контракты (не ломать) diff --git a/README.md b/README.md index 50661e9..9a20060 100644 --- a/README.md +++ b/README.md @@ -138,18 +138,13 @@ update: Что делает обёртка (`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`. +- **Модель — только глобальный конфиг opencode.** ratatoskr модель не выбирает + и про неё не знает: opencode сам берёт модель по умолчанию из своего + глобального конфига (`~/.config/opencode/opencode.jsonc`). Наш код конфиг + opencode не читает. +- **Свой агент.** При создании сессии в `POST /api/session` передаётся имя + встроенного агента ratatoskr (analyst/dev/reviewer/chat/postmortem), который + распаковывается в каталог `OPENCODE_CONFIG_DIR` (см. `internal/agents`). - **Поллинг вердикта.** Промпт отправляется неблокирующе (`POST .../prompt` → durable admit), вердикт собирается из новых assistant-сообщений (`GET .../message`); завершение ответа — сессия ушла из активных дренажей diff --git a/internal/opencode/client.go b/internal/opencode/client.go index f821625..703c68b 100644 --- a/internal/opencode/client.go +++ b/internal/opencode/client.go @@ -94,7 +94,7 @@ type ModelRef struct { Variant string `json:"variant,omitempty"` } -// String возвращает каноничное представление "provider/id[/variant]". +// String возвращает каноническое представление "provider/id[/variant]". func (m *ModelRef) String() string { if m == nil { return "" @@ -105,13 +105,14 @@ func (m *ModelRef) String() string { 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) { +// CreateSession создаёт новую сессию и возвращает её id. agent — имя агента +// (analyst/dev/reviewer/chat/postmortem) из встроенных определений ratatoskr. +// Модель мы НЕ выбираем: opencode сам берёт модель по умолчанию из своего +// глобального конфига (ratatoskr про неё ничего не знает). +func (c *Client) CreateSession(ctx context.Context, agent string) (string, error) { payload := map[string]any{} - if model != nil { - payload["model"] = model + if agent != "" { + payload["agent"] = agent } body, _ := json.Marshal(payload) raw, err := c.do(ctx, http.MethodPost, "/api/session", "create", body) diff --git a/internal/opencode/client_test.go b/internal/opencode/client_test.go index 9c7eee3..6dde381 100644 --- a/internal/opencode/client_test.go +++ b/internal/opencode/client_test.go @@ -31,7 +31,7 @@ type fakeAPIServer struct { verdictReasoning string // завершённый ответ только с reasoning-партом (без text) failCreate bool failMessages bool - createdModel *ModelRef // модель, полученная на POST /api/session + createdAgent string // агент, полученный на POST /api/session promptCalls int // streamGrow: стрим одного растущего парта — текст/reasoning растёт с @@ -55,10 +55,10 @@ func (f *fakeAPIServer) handler() http.Handler { return } var in struct { - Model *ModelRef `json:"model"` + Agent string `json:"agent"` } _ = json.NewDecoder(r.Body).Decode(&in) - f.createdModel = in.Model + f.createdAgent = in.Agent f.sessionID = "sess-fake" f.created = true writeJSON(w, map[string]any{"data": map[string]any{"id": "sess-fake"}}) @@ -186,33 +186,43 @@ func fakeClient(t *testing.T, f *fakeAPIServer) *Client { func TestClient_CreateSession(t *testing.T) { f := &fakeAPIServer{} c := fakeClient(t, f) - id, err := c.CreateSession(context.Background(), nil) + id, err := c.CreateSession(context.Background(), "dev") 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) + if f.createdAgent != "dev" { + t.Errorf("createdAgent = %q, want dev", f.createdAgent) } } -func TestClient_CreateSessionHardpinsModel(t *testing.T) { - want := &ModelRef{ProviderID: "tokentool", ID: "deepseek/deepseek-v4-flash-0731"} +func TestClient_CreateSessionPassesAgent(t *testing.T) { f := &fakeAPIServer{} c := fakeClient(t, f) - if _, err := c.CreateSession(context.Background(), want); err != nil { + if _, err := c.CreateSession(context.Background(), "postmortem"); 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) + if f.createdAgent != "postmortem" { + t.Errorf("createdAgent = %q, want postmortem", f.createdAgent) + } +} + +func TestClient_CreateSessionNoAgent(t *testing.T) { + f := &fakeAPIServer{} + c := fakeClient(t, f) + if _, err := c.CreateSession(context.Background(), ""); err != nil { + t.Fatalf("CreateSession err: %v", err) + } + if f.createdAgent != "" { + t.Errorf("createdAgent = %q, want пусто", f.createdAgent) } } func TestClient_CreateSessionFail(t *testing.T) { c := fakeClient(t, &fakeAPIServer{failCreate: true}) - if _, err := c.CreateSession(context.Background(), nil); err == nil { + if _, err := c.CreateSession(context.Background(), "dev"); err == nil { t.Fatal("CreateSession должен упасть при 500, а не nil") } } @@ -338,19 +348,6 @@ func Test_assistantVerdict(t *testing.T) { } } -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 diff --git a/internal/opencode/config.go b/internal/opencode/config.go deleted file mode 100644 index f4bde08..0000000 --- a/internal/opencode/config.go +++ /dev/null @@ -1,176 +0,0 @@ -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 deleted file mode 100644 index b25c39f..0000000 --- a/internal/opencode/config_test.go +++ /dev/null @@ -1,85 +0,0 @@ -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 da4a729..6c175e3 100644 --- a/internal/opencode/runner.go +++ b/internal/opencode/runner.go @@ -75,29 +75,18 @@ 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) или новая. + // Сессия: заданная (resume) или новая. Модель НЕ выбираем — opencode сам + // берёт её по умолчанию из своего глобального конфига. sid := sessionID if sid == "" { - sid, err = c.CreateSession(ctx, model) + sid, err = c.CreateSession(ctx, agent) if err != nil { return nil, fmt.Errorf("opencode: create session: %w", err) } r.logf("opencode(%s) session=%s на %s", agent, sid, srv.Addr()) } - return r.awaitVerdict(ctx, c, model, sid, agent, prompt) + return r.awaitVerdict(ctx, c, sid, agent, prompt) } // settlePolls — сколько подряд опросов должно подтвердить завершение ответа, @@ -109,7 +98,7 @@ const settlePolls = 2 // контролируя idle/hard таймауты. Завершение: сессия ушла из активных дренажей // И есть новое завершённое assistant-сообщение, стабильное в течение settlePolls // опросов. Возвращает вердикт (текст text-партов), либо rc=-1 при таймауте. -func (r *Runner) awaitVerdict(ctx context.Context, c *Client, model *ModelRef, sid, agent, prompt string) (*Result, error) { +func (r *Runner) awaitVerdict(ctx context.Context, c *Client, sid, agent, prompt string) (*Result, error) { // admit промпта; граница «новых» сообщений — время создания user-сообщения. admittedAt := time.Now().UnixMilli() adm, err := c.Prompt(ctx, sid, prompt) @@ -187,7 +176,7 @@ func (r *Runner) awaitVerdict(ctx context.Context, c *Client, model *ModelRef, s doneSeen++ emptySeen = 0 if doneSeen >= settlePolls { - return r.verdict(model, cur, msgs, admittedAt, sid) + return r.verdict(cur, msgs, admittedAt, sid) } case !active && cur == nil: // сессия завершилась, но нового assistant-сообщения так и нет @@ -207,12 +196,7 @@ func (r *Runner) awaitVerdict(ctx context.Context, c *Client, model *ModelRef, s } // 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) - } +func (r *Runner) verdict(cur *v2Message, msgs []v2Message, since int64, sid string) (*Result, error) { if cur.Error != nil && cur.Error.Message != "" { return nil, &ClientErr{Op: "prompt", Err: errors.New(cur.Error.Message)} } diff --git a/internal/opencode/runner_test.go b/internal/opencode/runner_test.go index 02efe41..f70dba4 100644 --- a/internal/opencode/runner_test.go +++ b/internal/opencode/runner_test.go @@ -10,8 +10,6 @@ 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)