fix(opencode): считать реальный прогресс стрима для idle-детекции
All checks were successful
CI / test (pull_request) Successful in 1m9s
CI / build-and-package (amd64, linux) (pull_request) Successful in 53s
CI / build-and-package (amd64, windows) (pull_request) Successful in 52s

Растущий в один text-парт стрим (text-delta) и reasoning больше не
выглядят как зависшая нейронка: idle-таймер сбрасывается по росту
числа партов и суммарной длины text/reasoning.
This commit is contained in:
ki.sagidullin
2026-08-19 19:11:26 +05:00
parent b5fb583c90
commit be4d749c45
5 changed files with 181 additions and 54 deletions

View File

@@ -292,6 +292,22 @@ func newestAssistant(msgs []v2Message, since int64) (*v2Message, int) {
return newest, count
}
// progressOf — «живой» прогресс новых assistant-сообщений: число контент-партов
// (text/reasoning/tool) + суммарная длина их текста. Растёт во время стриминга,
// когда один и тот же парт увеличивается (и при reasoning), — это и есть
// сигнал, что LLM работает, а не висит.
func progressOf(msgs []v2Message, since int64) (parts, textLen int) {
for _, m := range assistantSince(msgs, since) {
for _, p := range m.Content {
parts++
if p.Type == "text" || p.Type == "reasoning" {
textLen += len(p.Text)
}
}
}
return
}
// assistantText объединяет text-парты новых assistant-сообщений в хронологическом
// порядке (сообщения приходят новейшими первыми → идём с конца).
func assistantText(msgs []v2Message, since int64) []string {

View File

@@ -6,6 +6,7 @@ import (
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
@@ -16,7 +17,10 @@ import (
// - нормальный: Prompt ставит active=false и в messages кладётся финальное
// assistant-сообщение (verdictText) → Runner собирает вердикт;
// - blockPrompt: «агент завис» — active=true всегда, сообщений нет → idle abort;
// - failCreate / failMessages — имитация ошибок.
// - failCreate / failMessages — имитация ошибок;
// - growStream: стрим одного растущего парта — текст/reasoning растёт с
// каждым опросом GET /message (streamPolls раз), active=true, затем
// active=false + финальное завершённое сообщение.
type fakeAPIServer struct {
sessionID string
created bool
@@ -28,6 +32,14 @@ type fakeAPIServer struct {
failMessages bool
createdModel *ModelRef // модель, полученная на POST /api/session
promptCalls int
// streamGrow: стрим одного растущего парта — текст/reasoning растёт с
// каждым опросом GET /message, active=true, пока messageCalls не дойдёт до
// streamPolls; затем active=false + финальное завершённое сообщение.
streamGrow bool
streamReasoning bool // растущий парт — reasoning вместо text
streamPolls int // сколько опросов длится «стрим» до завершения
messageCalls int
}
func (f *fakeAPIServer) handler() http.Handler {
@@ -96,6 +108,27 @@ func (f *fakeAPIServer) handler() http.Handler {
http.Error(w, "db error", http.StatusInternalServerError)
return
}
if f.streamGrow {
f.messageCalls++
now := time.Now().UnixMilli()
done := f.messageCalls >= f.streamPolls
msg := v2Message{ID: "msg_stream", Type: "assistant", Time: v2Time{Created: &now}}
switch {
case done:
msg.Content = []v2Part{{Type: "text", Text: "done-stream"}}
msg.Finish = "end_turn"
msg.Time.Completed = &now
f.active = false
case f.streamReasoning:
msg.Content = []v2Part{{Type: "reasoning", Text: strings.Repeat("r", f.messageCalls)}}
f.active = true
default:
msg.Content = []v2Part{{Type: "text", Text: strings.Repeat("x", f.messageCalls)}}
f.active = true
}
writeJSON(w, map[string]any{"data": []v2Message{msg}})
return
}
msgs := f.messages
if msgs == nil && f.verdictText != "" && !f.blockPrompt {
msgs = []v2Message{f.assistantMsg(f.verdictText)}

View File

@@ -120,9 +120,10 @@ func (r *Runner) awaitVerdict(ctx context.Context, c *Client, model *ModelRef, s
admittedAt = adm.TimeCreated
}
// Прогресс = число text-партов в новых assistant-сообщениях. Рост сбрасывает
// idle-таймер (LLM стримит = жив).
lastCount := -1
// Прогресс = число контент-партов + суммарная длина их текста в новых
// assistant-сообщениях (progressOf). Рост сбрасывает idle-таймер: LLM
// стримит (даже в один растущий text-парт) или думает (reasoning) = жив.
lastParts, lastTextLen := -1, -1
lastProgress := time.Now()
launch := time.Now()
@@ -164,10 +165,11 @@ func (r *Runner) awaitVerdict(ctx context.Context, c *Client, model *ModelRef, s
return nil, err
}
cur, count := newestAssistant(msgs, admittedAt)
if count != lastCount {
cur, _ := newestAssistant(msgs, admittedAt)
parts, textLen := progressOf(msgs, admittedAt)
if parts != lastParts || textLen != lastTextLen {
lastProgress = time.Now()
lastCount = count
lastParts, lastTextLen = parts, textLen
}
now := time.Now()
if now.Sub(lastProgress) > r.IdleTimeout {

View File

@@ -70,6 +70,46 @@ func TestRun_IdleTimeout(t *testing.T) {
}
}
func TestRun_StreamingGrowth(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
dir := t.TempDir()
// стрим: один text-парт растёт с каждым опросом (дольше, чем idle timeout),
// но модель жива → idle НЕ должен сработать.
f := &fakeAPIServer{streamGrow: true, streamPolls: 30}
p, _ := fakePool(t, f, dir)
r := &Runner{Pool: p, IdleTimeout: 60 * time.Millisecond,
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 (растущий стрим не должен считаться hung)", res.RC)
}
if !contains(res.Stdout, "done-stream") {
t.Errorf("Stdout = %q, want contain done-stream", res.Stdout)
}
}
func TestRun_ReasoningGrowth(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
dir := t.TempDir()
// стрим: растёт только reasoning-парт (текста нет) — тоже живая активность.
f := &fakeAPIServer{streamGrow: true, streamReasoning: true, streamPolls: 30}
p, _ := fakePool(t, f, dir)
r := &Runner{Pool: p, IdleTimeout: 60 * time.Millisecond,
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 не должен считаться hung)", res.RC)
}
}
func TestRun_ContextCancel(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
dir := t.TempDir()