fix(opencode): считать реальный прогресс стрима для idle-детекции
Растущий в один text-парт стрим (text-delta) и reasoning больше не выглядят как зависшая нейронка: idle-таймер сбрасывается по росту числа партов и суммарной длины text/reasoning.
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user