fix(opencode): выбор модели — только глобальный конфиг opencode; агент передаётся в сессию
- CreateSession(ctx, agent) шлёт {agent} в POST /api/session, модель не выбираем
- удалён internal/opencode/config.go (ReadModelRef/JSONC-стрип) и его тесты
- runner.go больше не читает конфиг opencode и не хардпинит модель
- тесты и README обновлены
This commit is contained in:
@@ -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.
|
||||
|
||||
## Контракты (не ломать)
|
||||
|
||||
19
README.md
19
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`); завершение ответа — сессия ушла из активных дренажей
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user