Аналитик теперь может вернуть phase=ready, когда черновик уже полный и менять нечего. Раньше модель не могла это выразить и возвращала пустой propose, который валидатор резал A3 (нет изменённых полей). - analyst: case ready в validateResponse (без требований к полям) - core: propose и ready обрабатываются одинаково (applyDraft + E1 + ready) - prompt/analyst.md: контракт фаз обновлён (ask|propose|ready|abort) - тест TestDecideReady + README: раздел фаз аналитика
218 lines
7.2 KiB
Go
218 lines
7.2 KiB
Go
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)
|
||
}
|
||
} |