fix(analyst): steps устойчив к строке — не роняет decide при string-steps (аналог repos)
This commit is contained in:
@@ -57,7 +57,7 @@ type AnalystResponse struct {
|
|||||||
Repos json.RawMessage `json:"repos"` // список репо (основной); устойчив к строке
|
Repos json.RawMessage `json:"repos"` // список репо (основной); устойчив к строке
|
||||||
Why string `json:"why"`
|
Why string `json:"why"`
|
||||||
AC string `json:"ac"`
|
AC string `json:"ac"`
|
||||||
Steps []storage.Step `json:"steps"`
|
Steps json.RawMessage `json:"steps"` // список этапов; устойчив к строке
|
||||||
Questions []string `json:"questions"`
|
Questions []string `json:"questions"`
|
||||||
ChatReply string `json:"chat_reply"`
|
ChatReply string `json:"chat_reply"`
|
||||||
AbortReason string `json:"abort_reason"`
|
AbortReason string `json:"abort_reason"`
|
||||||
@@ -87,14 +87,10 @@ func (r *AnalystResponse) reposList() []string {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// stepsList возвращает этапы: модель может вернуть массив объектов либо
|
// validSteps отбрасывает записи без title.
|
||||||
// пустой/отсутствующий — тогда nil. Записи без title отбрасываются.
|
func validSteps(arr []storage.Step) []storage.Step {
|
||||||
func (r *AnalystResponse) stepsList() []storage.Step {
|
out := make([]storage.Step, 0, len(arr))
|
||||||
if r.Steps == nil {
|
for _, st := range arr {
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var out []storage.Step
|
|
||||||
for _, st := range r.Steps {
|
|
||||||
if st.Title == "" {
|
if st.Title == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -103,6 +99,30 @@ func (r *AnalystResponse) stepsList() []storage.Step {
|
|||||||
return out
|
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 через открытый код.
|
// Decide реализует core.Decider через открытый код.
|
||||||
func (a *Analyst) Decide(ctx context.Context, history []core.Message, draft storage.Task, force bool) (core.Decision, error) {
|
func (a *Analyst) Decide(ctx context.Context, history []core.Message, draft storage.Task, force bool) (core.Decision, error) {
|
||||||
agent := a.Agent
|
agent := a.Agent
|
||||||
|
|||||||
@@ -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 → валидно.
|
// TestProposeOnlyStepsValid — propose меняет только steps → валидно.
|
||||||
func TestProposeOnlyStepsValid(t *testing.T) {
|
func TestProposeOnlyStepsValid(t *testing.T) {
|
||||||
a := &Analyst{Runner: &mockRunner{result: &opencode.Result{
|
a := &Analyst{Runner: &mockRunner{result: &opencode.Result{
|
||||||
|
|||||||
Reference in New Issue
Block a user