fix(opencode): reasoning-fallback в вердикте + API-логи в debug-уровень
- verdict(): если в завершённом assistant-сообщении нет text-парта, но есть reasoning — вердикт собирается из reasoning (fallback), а не падает с 'нет text-части в ответе'. Решает сбой dev/reviewer/postmortem на моделях, отвечающих только thinking (например, через прокси tokentool). - Отладочные логи API-вызовов (запрос/ответ) помечены маркером 'debug', чтобы в панели «Логи» они классифицировались как debug, а не info.
This commit is contained in:
@@ -85,6 +85,8 @@ func TestClassifyLevel(t *testing.T) {
|
|||||||
{"app: db opened /tmp/r.db", LevelInfo},
|
{"app: db opened /tmp/r.db", LevelInfo},
|
||||||
{"app: worker started", LevelInfo},
|
{"app: worker started", LevelInfo},
|
||||||
{"opencode: debug: poll request", LevelDebug},
|
{"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},
|
{"trace: session resumed", LevelDebug},
|
||||||
{"tg: warn: long poll timeout", LevelWarning},
|
{"tg: warn: long poll timeout", LevelWarning},
|
||||||
{"ПРЕДУПРЕЖДЕНИЕ: конфиг не задан", LevelWarning},
|
{"ПРЕДУПРЕЖДЕНИЕ: конфиг не задан", LevelWarning},
|
||||||
|
|||||||
@@ -24,9 +24,9 @@ import (
|
|||||||
// Prompt не блокирует: вердикт собирается поллингом из content[].type=="text"
|
// Prompt не блокирует: вердикт собирается поллингом из content[].type=="text"
|
||||||
// новых assistant-сообщений (см. Runner.awaitVerdict).
|
// новых assistant-сообщений (см. Runner.awaitVerdict).
|
||||||
type Client struct {
|
type Client struct {
|
||||||
BaseURL string // http://host:port (без завершающего слеша)
|
BaseURL string // http://host:port (без завершающего слеша)
|
||||||
Password string // basic auth (username "opencode")
|
Password string // basic auth (username "opencode")
|
||||||
Debug bool // включать отладочные логи API-вызовов (log.level=debug)
|
Debug bool // включать отладочные логи API-вызовов (log.level=debug)
|
||||||
http *http.Client // единый клиент: все операции быстрые (нет блокирующего Send)
|
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")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
}
|
}
|
||||||
if c.Debug {
|
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)
|
resp, err := c.http.Do(req)
|
||||||
if err != nil {
|
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 resp.StatusCode < 200 || resp.StatusCode > 299 {
|
||||||
if c.Debug {
|
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))}
|
return nil, &ClientErr{Op: op, Err: fmt.Errorf("status %d: %s", resp.StatusCode, truncateStr(string(b), 300))}
|
||||||
}
|
}
|
||||||
if c.Debug {
|
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
|
return b, nil
|
||||||
}
|
}
|
||||||
@@ -320,6 +320,26 @@ func assistantText(msgs []v2Message, since int64) []string {
|
|||||||
return texts
|
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 {
|
func truncateStr(s string, n int) string {
|
||||||
if len(s) <= n {
|
if len(s) <= n {
|
||||||
return s
|
return s
|
||||||
|
|||||||
@@ -25,10 +25,11 @@ type fakeAPIServer struct {
|
|||||||
sessionID string
|
sessionID string
|
||||||
created bool
|
created bool
|
||||||
active bool
|
active bool
|
||||||
blockPrompt bool
|
blockPrompt bool
|
||||||
messages []v2Message
|
messages []v2Message
|
||||||
verdictText string
|
verdictText string
|
||||||
failCreate bool
|
verdictReasoning string // завершённый ответ только с reasoning-партом (без text)
|
||||||
|
failCreate bool
|
||||||
failMessages bool
|
failMessages bool
|
||||||
createdModel *ModelRef // модель, полученная на POST /api/session
|
createdModel *ModelRef // модель, полученная на POST /api/session
|
||||||
promptCalls int
|
promptCalls int
|
||||||
@@ -133,6 +134,9 @@ func (f *fakeAPIServer) handler() http.Handler {
|
|||||||
if msgs == nil && f.verdictText != "" && !f.blockPrompt {
|
if msgs == nil && f.verdictText != "" && !f.blockPrompt {
|
||||||
msgs = []v2Message{f.assistantMsg(f.verdictText)}
|
msgs = []v2Message{f.assistantMsg(f.verdictText)}
|
||||||
}
|
}
|
||||||
|
if msgs == nil && f.verdictReasoning != "" && !f.blockPrompt {
|
||||||
|
msgs = []v2Message{f.assistantReasoningMsg(f.verdictReasoning)}
|
||||||
|
}
|
||||||
if msgs == nil {
|
if msgs == nil {
|
||||||
msgs = []v2Message{}
|
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) {
|
func writeJSON(w http.ResponseWriter, v any) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
_ = json.NewEncoder(w).Encode(v)
|
_ = 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) {
|
func Test_parseModelString(t *testing.T) {
|
||||||
m := parseModelString("tokentool/deepseek/deepseek-v4-flash-0731")
|
m := parseModelString("tokentool/deepseek/deepseek-v4-flash-0731")
|
||||||
if m == nil || m.ProviderID != "tokentool" || m.ID != "deepseek/deepseek-v4-flash-0731" {
|
if m == nil || m.ProviderID != "tokentool" || m.ID != "deepseek/deepseek-v4-flash-0731" {
|
||||||
|
|||||||
@@ -216,10 +216,13 @@ func (r *Runner) verdict(model *ModelRef, cur *v2Message, msgs []v2Message, sinc
|
|||||||
if cur.Error != nil && cur.Error.Message != "" {
|
if cur.Error != nil && cur.Error.Message != "" {
|
||||||
return nil, &ClientErr{Op: "prompt", Err: errors.New(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 {
|
if len(texts) == 0 {
|
||||||
return nil, &ClientErr{Op: "prompt", Err: errors.New("нет text-части в ответе")}
|
return nil, &ClientErr{Op: "prompt", Err: errors.New("нет text-части в ответе")}
|
||||||
}
|
}
|
||||||
|
if usedReasoning {
|
||||||
|
r.logf("WARN opencode: в ответе нет text-части — использую reasoning-парты как вердикт")
|
||||||
|
}
|
||||||
vd := stripFence(strings.Join(texts, "\n"))
|
vd := stripFence(strings.Join(texts, "\n"))
|
||||||
r.logf("opencode вердикт готов (%d байт)", len(vd))
|
r.logf("opencode вердикт готов (%d байт)", len(vd))
|
||||||
return &Result{RC: 0, Stdout: vd, SessionID: sid}, nil
|
return &Result{RC: 0, Stdout: vd, SessionID: sid}, nil
|
||||||
|
|||||||
@@ -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) {
|
func TestRun_ContextCancel(t *testing.T) {
|
||||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
|
|||||||
Reference in New Issue
Block a user