diff --git a/internal/events/log_test.go b/internal/events/log_test.go index a2a85d4..b0d9cfe 100644 --- a/internal/events/log_test.go +++ b/internal/events/log_test.go @@ -85,6 +85,8 @@ func TestClassifyLevel(t *testing.T) { {"app: db opened /tmp/r.db", LevelInfo}, {"app: worker started", LevelInfo}, {"opencode: debug: poll request", LevelDebug}, + {"opencode api debug: prompt -> POST http://127.0.0.1:4096/api/session", LevelDebug}, + {"opencode api debug: messages response (512 bytes)", LevelDebug}, {"trace: session resumed", LevelDebug}, {"tg: warn: long poll timeout", LevelWarning}, {"ПРЕДУПРЕЖДЕНИЕ: конфиг не задан", LevelWarning}, @@ -119,4 +121,4 @@ func TestLogWriterClassifiesLevels(t *testing.T) { if second.Level != LevelError || second.Text != "ERROR: boom" { t.Fatalf("unexpected second: %+v", second) } -} \ No newline at end of file +} diff --git a/internal/opencode/client.go b/internal/opencode/client.go index f90d9cc..f821625 100644 --- a/internal/opencode/client.go +++ b/internal/opencode/client.go @@ -24,9 +24,9 @@ import ( // Prompt не блокирует: вердикт собирается поллингом из content[].type=="text" // новых assistant-сообщений (см. Runner.awaitVerdict). type Client struct { - BaseURL string // http://host:port (без завершающего слеша) - Password string // basic auth (username "opencode") - Debug bool // включать отладочные логи API-вызовов (log.level=debug) + BaseURL string // http://host:port (без завершающего слеша) + Password string // basic auth (username "opencode") + Debug bool // включать отладочные логи API-вызовов (log.level=debug) http *http.Client // единый клиент: все операции быстрые (нет блокирующего Send) } @@ -63,7 +63,7 @@ func (c *Client) do(ctx context.Context, method, path, op string, body []byte) ( req.Header.Set("Content-Type", "application/json") } if c.Debug { - log.Printf("opencode api %s -> %s %s%s", op, method, c.BaseURL, path) + log.Printf("opencode api debug: %s -> %s %s%s", op, method, c.BaseURL, path) } resp, err := c.http.Do(req) if err != nil { @@ -76,12 +76,12 @@ func (c *Client) do(ctx context.Context, method, path, op string, body []byte) ( } if resp.StatusCode < 200 || resp.StatusCode > 299 { if c.Debug { - log.Printf("opencode api %s response: status %d", op, resp.StatusCode) + log.Printf("opencode api debug: %s response: status %d", op, resp.StatusCode) } 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)", op, len(b)) + log.Printf("opencode api debug: %s response (%d bytes)", op, len(b)) } return b, nil } @@ -320,6 +320,26 @@ func assistantText(msgs []v2Message, since int64) []string { return texts } +// assistantVerdict собирает финальный текст ответа: сначала text-парты, а если +// их нет — только reasoning-парты (fallback для моделей, которые на некоторые +// запросы отвечают лишь reasoning без text). usedReasoning=true означает, что +// text-партов не было вовсе и вердикт собран из reasoning. +func assistantVerdict(msgs []v2Message, since int64) (texts []string, usedReasoning bool) { + if texts := assistantText(msgs, since); len(texts) > 0 { + return texts, false + } + ass := assistantSince(msgs, since) + reasoning := make([]string, 0, len(ass)) + for i := len(ass) - 1; i >= 0; i-- { + for _, p := range ass[i].Content { + if p.Type == "reasoning" && p.Text != "" { + reasoning = append(reasoning, p.Text) + } + } + } + return reasoning, len(reasoning) > 0 +} + func truncateStr(s string, n int) string { if len(s) <= n { return s diff --git a/internal/opencode/client_test.go b/internal/opencode/client_test.go index c8d03d5..9c7eee3 100644 --- a/internal/opencode/client_test.go +++ b/internal/opencode/client_test.go @@ -25,10 +25,11 @@ type fakeAPIServer struct { sessionID string created bool active bool - blockPrompt bool - messages []v2Message - verdictText string - failCreate bool + blockPrompt bool + messages []v2Message + verdictText string + verdictReasoning string // завершённый ответ только с reasoning-партом (без text) + failCreate bool failMessages bool createdModel *ModelRef // модель, полученная на POST /api/session promptCalls int @@ -133,6 +134,9 @@ func (f *fakeAPIServer) handler() http.Handler { if msgs == nil && f.verdictText != "" && !f.blockPrompt { msgs = []v2Message{f.assistantMsg(f.verdictText)} } + if msgs == nil && f.verdictReasoning != "" && !f.blockPrompt { + msgs = []v2Message{f.assistantReasoningMsg(f.verdictReasoning)} + } if msgs == nil { msgs = []v2Message{} } @@ -153,6 +157,19 @@ func (f *fakeAPIServer) assistantMsg(text string) v2Message { } } +// assistantReasoningMsg строит завершённое assistant-сообщение только с +// reasoning-партом (без text) — для проверки fallback-сценария. +func (f *fakeAPIServer) assistantReasoningMsg(text string) v2Message { + now := time.Now().UnixMilli() + return v2Message{ + ID: "msg_r", + Type: "assistant", + Content: []v2Part{{Type: "reasoning", Text: text}}, + Finish: "end_turn", + Time: v2Time{Created: &now, Completed: &now}, + } +} + func writeJSON(w http.ResponseWriter, v any) { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(v) @@ -279,6 +296,48 @@ func Test_newestAssistant(t *testing.T) { } } +func Test_assistantVerdict(t *testing.T) { + older := time.Now().Add(-time.Minute).UnixMilli() + newer := time.Now().UnixMilli() + reasoningOf := func(text string, at *int64) v2Message { + return v2Message{ID: "r", Type: "assistant", Content: []v2Part{{Type: "reasoning", Text: text}}, Time: v2Time{Created: at}} + } + + // reasoning-only: text-партов нет → fallback на reasoning, usedReasoning=true. + // Сообщения приходят новейшими первыми (как из API) → размышление 2 новее. + reasoningOnlyMsgs := []v2Message{ + reasoningOf("размышление 2", &newer), + reasoningOf("размышление 1", &older), + } + texts, used := assistantVerdict(reasoningOnlyMsgs, older) + if !used { + t.Error("usedReasoning = false, want true для reasoning-only") + } + if len(texts) != 2 || texts[0] != "размышление 1" || texts[1] != "размышление 2" { + t.Errorf("verdict = %v, want [размышление 1 размышление 2] (хронологически)", texts) + } + + // text + reasoning → берётся text, reasoning игнорируется. + mixed := []v2Message{ + {ID: "a", Type: "assistant", + Content: []v2Part{{Type: "reasoning", Text: "thinking"}, {Type: "text", Text: "ответ"}}, + Time: v2Time{Created: &newer}}, + } + texts, used = assistantVerdict(mixed, older) + if used { + t.Error("usedReasoning = true, want false (есть text)") + } + if len(texts) != 1 || texts[0] != "ответ" { + t.Errorf("verdict = %v, want [ответ]", texts) + } + + // пусто → пусто и usedReasoning=false. + empty := []v2Message{{ID: "u", Type: "user", Time: v2Time{Created: &newer}}} + if texts, used := assistantVerdict(empty, older); used || len(texts) != 0 { + t.Errorf("пусто: texts=%v usedReasoning=%v, want пусто/false", texts, used) + } +} + 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" { diff --git a/internal/opencode/runner.go b/internal/opencode/runner.go index a5a946c..da4a729 100644 --- a/internal/opencode/runner.go +++ b/internal/opencode/runner.go @@ -216,10 +216,13 @@ func (r *Runner) verdict(model *ModelRef, cur *v2Message, msgs []v2Message, sinc if cur.Error != nil && cur.Error.Message != "" { return nil, &ClientErr{Op: "prompt", Err: errors.New(cur.Error.Message)} } - texts := assistantText(msgs, since) + texts, usedReasoning := assistantVerdict(msgs, since) if len(texts) == 0 { return nil, &ClientErr{Op: "prompt", Err: errors.New("нет text-части в ответе")} } + if usedReasoning { + r.logf("WARN opencode: в ответе нет text-части — использую reasoning-парты как вердикт") + } vd := stripFence(strings.Join(texts, "\n")) r.logf("opencode вердикт готов (%d байт)", len(vd)) return &Result{RC: 0, Stdout: vd, SessionID: sid}, nil diff --git a/internal/opencode/runner_test.go b/internal/opencode/runner_test.go index efdf24c..02efe41 100644 --- a/internal/opencode/runner_test.go +++ b/internal/opencode/runner_test.go @@ -110,6 +110,28 @@ func TestRun_ReasoningGrowth(t *testing.T) { } } +func TestRun_ReasoningOnlyVerdict(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + dir := t.TempDir() + // завершённый ответ без text-парта, только reasoning — вердикт собирается + // из reasoning (fallback) вместо ошибки «нет text-части в ответе». + f := &fakeAPIServer{verdictReasoning: "размышления без текста"} + p, _ := fakePool(t, f, dir) + + r := &Runner{Pool: p, IdleTimeout: time.Minute, HardTimeout: time.Minute, + PollInterval: 5 * time.Millisecond, Stdout: io.Discard} + res, err := r.Run(context.Background(), "task", dir, "dev", "") + if err != nil { + t.Fatalf("Run err: %v", err) + } + if res.RC != 0 { + t.Errorf("RC = %d, want 0 (reasoning-only вердикт)", res.RC) + } + if !contains(res.Stdout, "размышления без текста") { + t.Errorf("Stdout = %q, want reasoning fallback", res.Stdout) + } +} + func TestRun_ContextCancel(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", t.TempDir()) dir := t.TempDir()