feat/b9d901435d3248cd #14

Merged
Teraonious merged 2 commits from feat/b9d901435d3248cd into main 2026-08-23 14:49:18 +05:00
5 changed files with 118 additions and 12 deletions
Showing only changes of commit 8cf4fc9f7c - Show all commits

View File

@@ -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},

View File

@@ -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

View File

@@ -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" {

View File

@@ -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

View File

@@ -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()