diff --git a/internal/analyst/analyst.go b/internal/analyst/analyst.go index b8f62eb..cf28aea 100644 --- a/internal/analyst/analyst.go +++ b/internal/analyst/analyst.go @@ -50,16 +50,40 @@ func (a *Analyst) publish(e events.Event) { // AnalystResponse — структура JSON-ответа аналитика. type AnalystResponse struct { - Phase string `json:"phase"` - Title string `json:"title"` - Goal string `json:"goal"` - Repo string `json:"repo"` // одиночный репо (обратная совместимость) - Repos []string `json:"repos"` // список репо (основной) - Why string `json:"why"` - AC string `json:"ac"` - Questions []string `json:"questions"` - ChatReply string `json:"chat_reply"` - AbortReason string `json:"abort_reason"` + Phase string `json:"phase"` + Title string `json:"title"` + Goal string `json:"goal"` + Repo string `json:"repo"` // одиночный репо (обратная совместимость) + Repos json.RawMessage `json:"repos"` // список репо (основной); устойчив к строке + Why string `json:"why"` + AC string `json:"ac"` + Questions []string `json:"questions"` + ChatReply string `json:"chat_reply"` + AbortReason string `json:"abort_reason"` +} + +// reposList нормализует поле repos: модель может вернуть либо массив +// ["a","b"], либо строку "a,b" (иногда с пробелами). Пустое значение → nil. +func (r *AnalystResponse) reposList() []string { + if r.Repos == nil { + return nil + } + var arr []string + if err := json.Unmarshal(r.Repos, &arr); err == nil { + return arr + } + var s string + if err := json.Unmarshal(r.Repos, &s); err != nil { + return nil + } + var out []string + for _, p := range strings.Split(s, ",") { + p = strings.TrimSpace(p) + if p != "" { + out = append(out, p) + } + } + return out } // Decide реализует core.Decider через открытый код. @@ -141,10 +165,10 @@ func (a *Analyst) Decide(ctx context.Context, history []core.Message, draft stor if ar.Repo != "" { dec.Draft.Repo = ar.Repo } - if len(ar.Repos) > 0 { - dec.Draft.Repos = ar.Repos + if repos := ar.reposList(); len(repos) > 0 { + dec.Draft.Repos = repos // Синхронизируем одиночный repo для старых потребителей. - dec.Draft.Repo = strings.Join(ar.Repos, ",") + dec.Draft.Repo = strings.Join(repos, ",") } if ar.Why != "" { dec.Draft.Why = ar.Why @@ -220,9 +244,9 @@ func formatVerdict(ar *AnalystResponse) string { b.WriteString(", repo=") b.WriteString(ar.Repo) } - if len(ar.Repos) > 0 { + if repos := ar.reposList(); len(repos) > 0 { b.WriteString(", repos=[") - b.WriteString(strings.Join(ar.Repos, ", ")) + b.WriteString(strings.Join(repos, ", ")) b.WriteString("]") } if ar.Why != "" { diff --git a/internal/analyst/analyst_test.go b/internal/analyst/analyst_test.go index 6bfa140..7002670 100644 --- a/internal/analyst/analyst_test.go +++ b/internal/analyst/analyst_test.go @@ -2,6 +2,7 @@ package analyst import ( "context" + "encoding/json" "errors" "strings" "testing" @@ -218,6 +219,27 @@ func TestAskEmptyReplyAndQuestions(t *testing.T) { } } +// TestDecideProposeStringRepos — модель вернула repos строкой (а не массивом): +// парсер должен нормализовать и не падать. +func TestDecideProposeStringRepos(t *testing.T) { + a := &Analyst{Runner: &mockRunner{result: &opencode.Result{ + RC: 0, + Stdout: `{"type":"text","part":{"text":"{\"phase\":\"propose\",\"title\":\"Калькулятор\",\"repos\":\"tools/calc, tools/ui\"}"}}`, + }}, 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 != "propose" { + t.Errorf("Phase = %q, want propose", dec.Phase) + } + if dec.Draft.Repos[0] != "tools/calc" || dec.Draft.Repos[1] != "tools/ui" { + t.Errorf("Repos = %#v, want [tools/calc tools/ui]", dec.Draft.Repos) + } +} + // TestFormatVerdict — человекочитаемое описание вердикта аналитика. func TestFormatVerdict(t *testing.T) { tests := []struct { @@ -231,7 +253,7 @@ func TestFormatVerdict(t *testing.T) { Phase: "propose", Title: "Калькулятор", Goal: "Сделать веб-калькулятор", - Repos: []string{"tools/calc", "tools/ui"}, + Repos: json.RawMessage(`["tools/calc","tools/ui"]`), Why: "Нужен для учёта", AC: "Работает + - * /", ChatReply: "Готово!",