Files
ratatoskr-go/internal/opencode/runner_test.go
ki.sagidullin be4d749c45
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
fix(opencode): считать реальный прогресс стрима для idle-детекции
Растущий в один text-парт стрим (text-delta) и reasoning больше не
выглядят как зависшая нейронка: idle-таймер сбрасывается по росту
числа партов и суммарной длины text/reasoning.
2026-08-19 19:11:26 +05:00

151 lines
4.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package opencode
import (
"context"
"io"
"net/http/httptest"
"testing"
"time"
)
// fakePool создаёт Pool, в котором уже «живёт» сервер для каталога (без spawn):
// Server{URL: fake.URL}, поэтому Runner ходит по HTTP на фейк-API.
// XDG_CONFIG_HOME уводится во временный каталог, чтобы ReadModelRef не читал
// реальный пользовательский конфиг opencode (детерминизм тестов).
func fakePool(t *testing.T, f *fakeAPIServer, dir string) (*Pool, *Client) {
t.Helper()
ts := httptestURL(t, f)
p := NewPool(dir)
p.mu.Lock()
p.segs[dir] = &Server{URL: ts, PollInterval: time.Millisecond}
p.mu.Unlock()
return p, &Client{BaseURL: ts}
}
// httptestURL запускает фейк-API и возвращает его URL.
func httptestURL(t *testing.T, f *fakeAPIServer) string {
t.Helper()
ts := httptest.NewServer(f.handler())
t.Cleanup(ts.Close)
return ts.URL
}
func TestRun_Success(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
dir := t.TempDir()
f := &fakeAPIServer{verdictText: "done"}
p, _ := fakePool(t, f, dir)
r := &Runner{Pool: p, 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", res.RC)
}
if res.SessionID != "sess-fake" {
t.Errorf("SessionID = %q, want sess-fake", res.SessionID)
}
if !contains(res.Stdout, "done") {
t.Errorf("Stdout = %q, want contain done", res.Stdout)
}
}
func TestRun_IdleTimeout(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
dir := t.TempDir()
// агент «завис»: active=true, прогресс не растёт → idle abort
f := &fakeAPIServer{blockPrompt: true}
p, _ := fakePool(t, f, dir)
r := &Runner{Pool: p, IdleTimeout: 30 * 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 != -1 {
t.Errorf("RC = %d, want -1 (idle timeout)", res.RC)
}
}
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()
f := &fakeAPIServer{blockPrompt: true}
p, _ := fakePool(t, f, dir)
ctx, cancel := context.WithCancel(context.Background())
r := &Runner{Pool: p, IdleTimeout: time.Minute, HardTimeout: time.Minute,
PollInterval: 5 * time.Millisecond, Stdout: io.Discard}
done := make(chan *Result, 1)
errCh := make(chan error, 1)
go func() {
res, err := r.Run(ctx, "task", dir, "dev", "")
done <- res
errCh <- err
}()
time.Sleep(30 * time.Millisecond)
cancel()
res := <-done
if err := <-errCh; err != nil {
t.Fatalf("Run err: %v", err)
}
if res.RC != -1 {
t.Errorf("RC = %d, want -1", res.RC)
}
}
func contains(s, sub string) bool {
return len(s) >= len(sub) && (s == sub || len(s) > 0 && indexOf(s, sub) >= 0)
}
func indexOf(s, sub string) int {
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return i
}
}
return -1
}