Files
ratatoskr-go/internal/analyst/analyst_test.go
Hermes 46065f84fd
Some checks failed
CI / test (push) Failing after 52s
CI / build-and-package (amd64, linux) (push) Failing after 27s
CI / build-and-package (amd64, windows) (push) Failing after 28s
feat: фаза ready — черновик готов как есть, без требований к изменённым полям
Аналитик теперь может вернуть phase=ready, когда черновик уже полный и
менять нечего. Раньше модель не могла это выразить и возвращала пустой
propose, который валидатор резал A3 (нет изменённых полей).

- analyst: case ready в validateResponse (без требований к полям)
- core: propose и ready обрабатываются одинаково (applyDraft + E1 + ready)
- prompt/analyst.md: контракт фаз обновлён (ask|propose|ready|abort)
- тест TestDecideReady + README: раздел фаз аналитика
2026-08-17 14:07:05 +05:00

218 lines
7.2 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 analyst
import (
"context"
"errors"
"testing"
"github.com/kamelion/ratatoskr-go/internal/core"
"github.com/kamelion/ratatoskr-go/internal/opencode"
"github.com/kamelion/ratatoskr-go/internal/storage"
)
// mockRunner — тестовый OpenCodeRunner с задаваемым поведением.
type mockRunner struct {
result *opencode.Result
err error
}
func (m *mockRunner) Run(_ context.Context, _, _, _, _ string) (*opencode.Result, error) {
return m.result, m.err
}
func TestDecideAsk(t *testing.T) {
a := &Analyst{Runner: &mockRunner{result: &opencode.Result{
RC: 0,
Stdout: `{"type":"text","part":{"text":"{\"phase\":\"ask\",\"chat_reply\":\"Уточню про репозиторий\",\"questions\":[\"Где лежит код?\",\"Какая цель?\"]}"}}`,
}}, Worktree: "/tmp"}
history := []core.Message{{Role: "user", Content: "Сделай калькулятор"}}
dec, err := a.Decide(context.Background(), history, storage.Task{}, false)
if err != nil {
t.Fatalf("Decide err: %v", err)
}
if dec.Phase != "ask" {
t.Errorf("Phase = %q, want ask", dec.Phase)
}
if len(dec.Questions) != 2 {
t.Errorf("len(Questions) = %d, want 2", len(dec.Questions))
}
if dec.ChatReply == "" {
t.Error("ChatReply пустой")
}
}
func TestDecidePropose(t *testing.T) {
a := &Analyst{Runner: &mockRunner{result: &opencode.Result{
RC: 0,
Stdout: `{"type":"text","part":{"text":"{\"phase\":\"propose\",\"title\":\"Калькулятор\",\"goal\":\"Сделать веб-калькулятор\",\"repo\":\"tools/calc\",\"why\":\"Нужен для учёта\",\"ac\":\"Работает + - * /\",\"chat_reply\":\"Готово!\"}"}}`,
}}, Worktree: "/tmp"}
history := []core.Message{
{Role: "user", Content: "Сделай калькулятор"},
{Role: "assistant", Content: "Где репозиторий?"},
{Role: "user", Content: "tools/calc"},
}
dec, err := a.Decide(context.Background(), history, storage.Task{}, false)
if err != nil {
t.Fatalf("Decide err: %v", err)
}
if dec.Phase != "propose" {
t.Errorf("Phase = %q, want propose", dec.Phase)
}
if dec.Draft.Title != "Калькулятор" {
t.Errorf("Draft.Title = %q, want Калькулятор", dec.Draft.Title)
}
if dec.Draft.Repo != "tools/calc" {
t.Errorf("Draft.Repo = %q", dec.Draft.Repo)
}
}
func TestDecideAbort(t *testing.T) {
a := &Analyst{Runner: &mockRunner{result: &opencode.Result{
RC: 0,
Stdout: `{"type":"text","part":{"text":"{\"phase\":\"abort\",\"chat_reply\":\"Это не про код.\",\"abort_reason\":\"Тема не подходит opencode\"}"}}`,
}}, Worktree: "/tmp"}
history := []core.Message{{Role: "user", Content: "Почини принтер"}}
dec, err := a.Decide(context.Background(), history, storage.Task{}, false)
if err != nil {
t.Fatalf("Decide err: %v", err)
}
if dec.Phase != "abort" {
t.Errorf("Phase = %q, want abort", dec.Phase)
}
if dec.ChatReply != "Это не про код." {
t.Errorf("ChatReply = %q", dec.ChatReply)
}
}
func TestDecideReady(t *testing.T) {
// ready с пустыми изменёнными полями — ВАЛИДНО (черновик готов как есть)
a := &Analyst{Runner: &mockRunner{result: &opencode.Result{
RC: 0,
Stdout: `{"type":"text","part":{"text":"{\"phase\":\"ready\",\"chat_reply\":\"Черновик готов, запускаю.\"}"}}`,
}}, Worktree: "/tmp"}
history := []core.Message{{Role: "user", Content: "Сделай калькулятор"}}
dec, err := a.Decide(context.Background(), history, storage.Task{}, false)
if err != nil {
t.Fatalf("Decide err: %v", err)
}
if dec.Phase != "ready" {
t.Errorf("Phase = %q, want ready", dec.Phase)
}
}
func TestDecodeFail(t *testing.T) {
a := &Analyst{Runner: &mockRunner{result: &opencode.Result{
RC: 0,
Stdout: `{"type":"text","part":{"text":"не JSON, а просто текст"}}`,
}}, Worktree: "/tmp"}
history := []core.Message{{Role: "user", Content: "тест"}}
_, err := a.Decide(context.Background(), history, storage.Task{}, false)
if err == nil {
t.Fatal("expected A1 error")
}
if !errors.Is(err, ErrDecodeFail) {
t.Errorf("err = %v, want A1", err)
}
}
func TestRunError(t *testing.T) {
a := &Analyst{Runner: &mockRunner{err: errors.New("opencode not found")}, Worktree: "/tmp"}
history := []core.Message{{Role: "user", Content: "тест"}}
_, err := a.Decide(context.Background(), history, storage.Task{}, false)
if err == nil {
t.Fatal("expected A2 error")
}
if !errors.Is(err, ErrRunError) {
t.Errorf("err = %v, want A2", err)
}
}
func TestEmptyHistory(t *testing.T) {
a := &Analyst{Runner: &mockRunner{result: &opencode.Result{RC: 0}}}
_, err := a.Decide(context.Background(), nil, storage.Task{}, false)
if err == nil {
t.Fatal("expected A4 error")
}
if !errors.Is(err, ErrNotReady) {
t.Errorf("err = %v, want A4", err)
}
}
func TestForceEmptyHistory(t *testing.T) {
a := &Analyst{Runner: &mockRunner{result: &opencode.Result{
RC: 0,
Stdout: `{"type":"text","part":{"text":"{\"phase\":\"ask\",\"chat_reply\":\"Опишите задачу.\"}"}}`,
}}, Worktree: "/tmp"}
_, err := a.Decide(context.Background(), nil, storage.Task{}, true)
if err != nil {
t.Fatalf("Decide(force=true) err: %v", err)
}
}
func TestInvalidPhase(t *testing.T) {
a := &Analyst{Runner: &mockRunner{result: &opencode.Result{
RC: 0,
Stdout: `{"type":"text","part":{"text":"{\"phase\":\"unknown\"}"}}`,
}}, Worktree: "/tmp"}
history := []core.Message{{Role: "user", Content: "test"}}
_, err := a.Decide(context.Background(), history, storage.Task{}, false)
if err == nil {
t.Fatal("expected A3 error")
}
if !errors.Is(err, ErrValidation) {
t.Errorf("err = %v, want A3", err)
}
}
func TestNonZeroExit(t *testing.T) {
a := &Analyst{Runner: &mockRunner{result: &opencode.Result{RC: 1, Stdout: "fail"}}, Worktree: "/tmp"}
history := []core.Message{{Role: "user", Content: "test"}}
_, err := a.Decide(context.Background(), history, storage.Task{}, false)
if err == nil {
t.Fatal("expected A2 error for rc=1")
}
if !errors.Is(err, ErrRunError) {
t.Errorf("err = %v, want A2", err)
}
}
func TestProposeEmptyFields(t *testing.T) {
// propose без изменённых полей — A3
a := &Analyst{Runner: &mockRunner{result: &opencode.Result{
RC: 0,
Stdout: `{"type":"text","part":{"text":"{\"phase\":\"propose\",\"chat_reply\":\"ok\"}"}}`,
}}, Worktree: "/tmp"}
history := []core.Message{{Role: "user", Content: "test"}}
_, err := a.Decide(context.Background(), history, storage.Task{}, false)
if err == nil {
t.Fatal("expected A3 error for propose with no fields")
}
if !errors.Is(err, ErrValidation) {
t.Errorf("err = %v, want A3", err)
}
}
func TestAskEmptyReplyAndQuestions(t *testing.T) {
// ask без chat_reply и questions — A3
a := &Analyst{Runner: &mockRunner{result: &opencode.Result{
RC: 0,
Stdout: `{"type":"text","part":{"text":"{\"phase\":\"ask\"}"}}`,
}}, Worktree: "/tmp"}
history := []core.Message{{Role: "user", Content: "test"}}
_, err := a.Decide(context.Background(), history, storage.Task{}, false)
if err == nil {
t.Fatal("expected A3 error for empty ask")
}
if !errors.Is(err, ErrValidation) {
t.Errorf("err = %v, want A3", err)
}
}