diff --git a/internal/analyst/analyst.go b/internal/analyst/analyst.go index ad453c5..aac40a1 100644 --- a/internal/analyst/analyst.go +++ b/internal/analyst/analyst.go @@ -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 diff --git a/internal/analyst/analyst_test.go b/internal/analyst/analyst_test.go index 02f0d0e..8b993b6 100644 --- a/internal/analyst/analyst_test.go +++ b/internal/analyst/analyst_test.go @@ -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{