From f37980bd4a1c742736fae8c0c0529bb88610c088 Mon Sep 17 00:00:00 2001 From: Virgil Date: Tue, 31 Mar 2026 11:22:36 +0000 Subject: [PATCH] fix(agentic): register plan named actions Co-Authored-By: Virgil --- pkg/agentic/plan.go | 165 ++++++++++++++++++++++++++++++++++ pkg/agentic/plan_crud_test.go | 61 +++++++++++++ pkg/agentic/prep.go | 5 ++ pkg/agentic/prep_test.go | 16 ++++ 4 files changed, 247 insertions(+) diff --git a/pkg/agentic/plan.go b/pkg/agentic/plan.go index c5e85de..cde26f4 100644 --- a/pkg/agentic/plan.go +++ b/pkg/agentic/plan.go @@ -95,6 +95,83 @@ type PlanListOutput struct { Plans []Plan `json:"plans"` } +// result := c.Action("plan.create").Run(ctx, core.NewOptions( +// +// core.Option{Key: "title", Value: "AX RFC follow-up"}, +// core.Option{Key: "objective", Value: "Register plan actions"}, +// +// )) +func (s *PrepSubsystem) handlePlanCreate(ctx context.Context, options core.Options) core.Result { + _, output, err := s.planCreate(ctx, nil, PlanCreateInput{ + Title: optionStringValue(options, "title"), + Objective: optionStringValue(options, "objective"), + Repo: optionStringValue(options, "repo"), + Org: optionStringValue(options, "org"), + Phases: planPhasesValue(options, "phases"), + Notes: optionStringValue(options, "notes"), + }) + if err != nil { + return core.Result{Value: err, OK: false} + } + return core.Result{Value: output, OK: true} +} + +// result := c.Action("plan.read").Run(ctx, core.NewOptions(core.Option{Key: "id", Value: "id-42-a3f2b1"})) +func (s *PrepSubsystem) handlePlanRead(ctx context.Context, options core.Options) core.Result { + _, output, err := s.planRead(ctx, nil, PlanReadInput{ + ID: optionStringValue(options, "id"), + }) + if err != nil { + return core.Result{Value: err, OK: false} + } + return core.Result{Value: output, OK: true} +} + +// result := c.Action("plan.update").Run(ctx, core.NewOptions( +// +// core.Option{Key: "id", Value: "id-42-a3f2b1"}, +// core.Option{Key: "status", Value: "ready"}, +// +// )) +func (s *PrepSubsystem) handlePlanUpdate(ctx context.Context, options core.Options) core.Result { + _, output, err := s.planUpdate(ctx, nil, PlanUpdateInput{ + ID: optionStringValue(options, "id"), + Status: optionStringValue(options, "status"), + Title: optionStringValue(options, "title"), + Objective: optionStringValue(options, "objective"), + Phases: planPhasesValue(options, "phases"), + Notes: optionStringValue(options, "notes"), + Agent: optionStringValue(options, "agent"), + }) + if err != nil { + return core.Result{Value: err, OK: false} + } + return core.Result{Value: output, OK: true} +} + +// result := c.Action("plan.delete").Run(ctx, core.NewOptions(core.Option{Key: "id", Value: "id-42-a3f2b1"})) +func (s *PrepSubsystem) handlePlanDelete(ctx context.Context, options core.Options) core.Result { + _, output, err := s.planDelete(ctx, nil, PlanDeleteInput{ + ID: optionStringValue(options, "id"), + }) + if err != nil { + return core.Result{Value: err, OK: false} + } + return core.Result{Value: output, OK: true} +} + +// result := c.Action("plan.list").Run(ctx, core.NewOptions(core.Option{Key: "repo", Value: "go-io"})) +func (s *PrepSubsystem) handlePlanList(ctx context.Context, options core.Options) core.Result { + _, output, err := s.planList(ctx, nil, PlanListInput{ + Status: optionStringValue(options, "status"), + Repo: optionStringValue(options, "repo"), + }) + if err != nil { + return core.Result{Value: err, OK: false} + } + return core.Result{Value: output, OK: true} +} + func (s *PrepSubsystem) registerPlanTools(server *mcp.Server) { mcp.AddTool(server, &mcp.Tool{ Name: "agentic_plan_create", @@ -318,6 +395,94 @@ func planPath(dir, id string) string { return core.JoinPath(dir, core.Concat(safe, ".json")) } +func planPhasesValue(options core.Options, keys ...string) []Phase { + for _, key := range keys { + result := options.Get(key) + if !result.OK { + continue + } + phases := phaseSliceValue(result.Value) + if len(phases) > 0 { + return phases + } + } + return nil +} + +func phaseSliceValue(value any) []Phase { + switch typed := value.(type) { + case []Phase: + return typed + case []any: + phases := make([]Phase, 0, len(typed)) + for _, item := range typed { + phase, ok := phaseValue(item) + if ok { + phases = append(phases, phase) + } + } + return phases + case string: + trimmed := core.Trim(typed) + if trimmed == "" { + return nil + } + if core.HasPrefix(trimmed, "[") { + var phases []Phase + if result := core.JSONUnmarshalString(trimmed, &phases); result.OK { + return phases + } + if values := anyMapSliceValue(trimmed); len(values) > 0 { + return phaseSliceValue(values) + } + var generic []any + if result := core.JSONUnmarshalString(trimmed, &generic); result.OK { + return phaseSliceValue(generic) + } + } + case []map[string]any: + phases := make([]Phase, 0, len(typed)) + for _, item := range typed { + phase, ok := phaseValue(item) + if ok { + phases = append(phases, phase) + } + } + return phases + } + if phase, ok := phaseValue(value); ok { + return []Phase{phase} + } + return nil +} + +func phaseValue(value any) (Phase, bool) { + switch typed := value.(type) { + case Phase: + return typed, true + case map[string]any: + return Phase{ + Number: intValue(typed["number"]), + Name: stringValue(typed["name"]), + Status: stringValue(typed["status"]), + Criteria: stringSliceValue(typed["criteria"]), + Tests: intValue(typed["tests"]), + Notes: stringValue(typed["notes"]), + }, true + case map[string]string: + return phaseValue(anyMapValue(typed)) + case string: + trimmed := core.Trim(typed) + if trimmed == "" || !core.HasPrefix(trimmed, "{") { + return Phase{}, false + } + if values := anyMapValue(trimmed); len(values) > 0 { + return phaseValue(values) + } + } + return Phase{}, false +} + // result := readPlanResult(PlansRoot(), "plan-id") // if result.OK { plan := result.Value.(*Plan) } func readPlanResult(dir, id string) core.Result { diff --git a/pkg/agentic/plan_crud_test.go b/pkg/agentic/plan_crud_test.go index fa75b4d..a5eabda 100644 --- a/pkg/agentic/plan_crud_test.go +++ b/pkg/agentic/plan_crud_test.go @@ -329,6 +329,67 @@ func TestPlan_PlanList_Good_FilterByRepo(t *testing.T) { assert.Equal(t, 2, out.Count) } +func TestPlan_HandlePlanCreate_Good(t *testing.T) { + dir := t.TempDir() + t.Setenv("CORE_WORKSPACE", dir) + + s := newTestPrep(t) + result := s.handlePlanCreate(context.Background(), core.NewOptions( + core.Option{Key: "title", Value: "Named plan action"}, + core.Option{Key: "objective", Value: "Expose plan CRUD as named actions"}, + core.Option{Key: "repo", Value: "agent"}, + core.Option{Key: "phases", Value: []any{ + map[string]any{ + "name": "Register actions", + "criteria": []any{"plan.create exists", "tests cover handlers"}, + "tests": 2, + }, + }}, + )) + + require.True(t, result.OK) + output, ok := result.Value.(PlanCreateOutput) + require.True(t, ok) + assert.True(t, output.Success) + assertCoreIDFormat(t, output.ID) + + read, err := readPlan(PlansRoot(), output.ID) + require.NoError(t, err) + require.Len(t, read.Phases, 1) + assert.Equal(t, "Register actions", read.Phases[0].Name) + assert.Equal(t, []string{"plan.create exists", "tests cover handlers"}, read.Phases[0].Criteria) + assert.Equal(t, 2, read.Phases[0].Tests) +} + +func TestPlan_HandlePlanUpdate_Good_JSONPhases(t *testing.T) { + dir := t.TempDir() + t.Setenv("CORE_WORKSPACE", dir) + + s := newTestPrep(t) + _, created, err := s.planCreate(context.Background(), nil, PlanCreateInput{ + Title: "Update via action", + Objective: "Parse phase JSON from action options", + }) + require.NoError(t, err) + + result := s.handlePlanUpdate(context.Background(), core.NewOptions( + core.Option{Key: "id", Value: created.ID}, + core.Option{Key: "status", Value: "ready"}, + core.Option{Key: "agent", Value: "codex"}, + core.Option{Key: "phases", Value: `[{"number":1,"name":"Review drift","status":"pending","criteria":["actions registered"]}]`}, + )) + + require.True(t, result.OK) + output, ok := result.Value.(PlanUpdateOutput) + require.True(t, ok) + assert.True(t, output.Success) + assert.Equal(t, "ready", output.Plan.Status) + assert.Equal(t, "codex", output.Plan.Agent) + require.Len(t, output.Plan.Phases, 1) + assert.Equal(t, "Review drift", output.Plan.Phases[0].Name) + assert.Equal(t, []string{"actions registered"}, output.Plan.Phases[0].Criteria) +} + func TestPlan_PlanList_Good_FilterByStatus(t *testing.T) { dir := t.TempDir() t.Setenv("CORE_WORKSPACE", dir) diff --git a/pkg/agentic/prep.go b/pkg/agentic/prep.go index eb321be..662c408 100644 --- a/pkg/agentic/prep.go +++ b/pkg/agentic/prep.go @@ -153,6 +153,11 @@ func (s *PrepSubsystem) OnStartup(ctx context.Context) core.Result { c.Action("agentic.review-queue", s.handleReviewQueue).Description = "Run CodeRabbit review on completed workspaces" c.Action("agentic.epic", s.handleEpic).Description = "Create sub-issues from an epic plan" + c.Action("plan.create", s.handlePlanCreate).Description = "Create a structured implementation plan" + c.Action("plan.read", s.handlePlanRead).Description = "Read an implementation plan by ID" + c.Action("plan.update", s.handlePlanUpdate).Description = "Update plan status, phases, notes, or agent assignment" + c.Action("plan.delete", s.handlePlanDelete).Description = "Delete an implementation plan by ID" + c.Action("plan.list", s.handlePlanList).Description = "List implementation plans with optional filters" c.Action("agentic.prompt", s.handlePrompt).Description = "Read a system prompt by slug" c.Action("agentic.task", s.handleTask).Description = "Read a task plan by slug" diff --git a/pkg/agentic/prep_test.go b/pkg/agentic/prep_test.go index 08fc5d4..148da2f 100644 --- a/pkg/agentic/prep_test.go +++ b/pkg/agentic/prep_test.go @@ -431,6 +431,22 @@ func TestPrep_OnStartup_Good_NoError(t *testing.T) { assert.True(t, s.OnStartup(context.Background()).OK) } +func TestPrep_OnStartup_Good_RegistersPlanActions(t *testing.T) { + t.Setenv("CORE_WORKSPACE", t.TempDir()) + t.Setenv("CORE_AGENT_DISPATCH", "") + + c := core.New(core.WithOption("name", "test")) + s := NewPrep() + s.ServiceRuntime = core.NewServiceRuntime(c, AgentOptions{}) + + require.True(t, s.OnStartup(context.Background()).OK) + assert.True(t, c.Action("plan.create").Exists()) + assert.True(t, c.Action("plan.read").Exists()) + assert.True(t, c.Action("plan.update").Exists()) + assert.True(t, c.Action("plan.delete").Exists()) + assert.True(t, c.Action("plan.list").Exists()) +} + func TestPrep_OnStartup_Bad(t *testing.T) { // OnStartup with nil ServiceRuntime — panics because // registerCommands calls s.Core().Command().