fix(analyst): steps устойчив к строке — не роняет decide при string-steps (аналог repos)
Some checks failed
CI / test (push) Failing after 1m56s
CI / build-and-package (amd64, linux) (push) Failing after 1m7s
CI / build-and-package (amd64, windows) (push) Successful in 30s

This commit is contained in:
ki.sagidullin
2026-08-24 22:42:42 +05:00
parent 2005421739
commit 75132bc5f5
2 changed files with 70 additions and 9 deletions

View File

@@ -57,7 +57,7 @@ type AnalystResponse struct {
Repos json.RawMessage `json:"repos"` // список репо (основной); устойчив к строке
Why string `json:"why"`
AC string `json:"ac"`
Steps []storage.Step `json:"steps"`
Steps json.RawMessage `json:"steps"` // список этапов; устойчив к строке
Questions []string `json:"questions"`
ChatReply string `json:"chat_reply"`
AbortReason string `json:"abort_reason"`
@@ -87,14 +87,10 @@ func (r *AnalystResponse) reposList() []string {
return out
}
// stepsList возвращает этапы: модель может вернуть массив объектов либо
// пустой/отсутствующий — тогда nil. Записи без title отбрасываются.
func (r *AnalystResponse) stepsList() []storage.Step {
if r.Steps == nil {
return nil
}
var out []storage.Step
for _, st := range r.Steps {
// validSteps отбрасывает записи без title.
func validSteps(arr []storage.Step) []storage.Step {
out := make([]storage.Step, 0, len(arr))
for _, st := range arr {
if st.Title == "" {
continue
}
@@ -103,6 +99,30 @@ func (r *AnalystResponse) stepsList() []storage.Step {
return out
}
// stepsList возвращает этапы, устойчиво к формату модели: массив объектов
// {"title","ac"} либо строка (пустая → nil; содержащая JSON-массив → парсится;
// иначе игнорируется). Записи без title отбрасываются.
func (r *AnalystResponse) stepsList() []storage.Step {
if r.Steps == nil {
return nil
}
var arr []storage.Step
if err := json.Unmarshal(r.Steps, &arr); err == nil {
return validSteps(arr)
}
var s string
if err := json.Unmarshal(r.Steps, &s); err != nil {
return nil
}
if strings.TrimSpace(s) == "" {
return nil
}
if err := json.Unmarshal([]byte(s), &arr); err == nil {
return validSteps(arr)
}
return nil
}
// Decide реализует core.Decider через открытый код.
func (a *Analyst) Decide(ctx context.Context, history []core.Message, draft storage.Task, force bool) (core.Decision, error) {
agent := a.Agent

View File

@@ -266,6 +266,47 @@ func TestDecideProposeSteps(t *testing.T) {
}
}
// TestDecideProposeStringSteps — модель вернула steps строкой (а не массивом):
// парсер должен нормализовать и не падать (аналог string-repos).
func TestDecideProposeStringSteps(t *testing.T) {
a := &Analyst{Runner: &mockRunner{result: &opencode.Result{
RC: 0,
Stdout: `{"type":"text","part":{"text":"{\"phase\":\"propose\",\"title\":\"Калькулятор\",\"steps\":\"\"}"}}`,
}}, 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 len(dec.Draft.Steps) != 0 {
t.Errorf("len(Steps) = %d, want 0 (пустая строка игнорируется)", len(dec.Draft.Steps))
}
}
// TestDecideProposeJSONStepsString — steps пришли строкой с вложенным JSON-массивом.
func TestDecideProposeJSONStepsString(t *testing.T) {
a := &Analyst{Runner: &mockRunner{result: &opencode.Result{
RC: 0,
Stdout: `{"type":"text","part":{"text":"{\"phase\":\"propose\",\"steps\":\"[{\\\"title\\\":\\\"Модель\\\",\\\"ac\\\":\\\"ок\\\"}]\"}"}}`,
}}, Worktree: "/tmp"}
history := []core.Message{{Role: "user", Content: "test"}}
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 len(dec.Draft.Steps) != 1 || dec.Draft.Steps[0].Title != "Модель" {
t.Errorf("Steps = %#v, want [Модель]", dec.Draft.Steps)
}
}
// TestProposeOnlyStepsValid — propose меняет только steps → валидно.
func TestProposeOnlyStepsValid(t *testing.T) {
a := &Analyst{Runner: &mockRunner{result: &opencode.Result{