fix(agentic): register plan named actions

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-03-31 11:22:36 +00:00
parent 759bb9bcb7
commit f37980bd4a
4 changed files with 247 additions and 0 deletions

View file

@ -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 {

View file

@ -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)

View file

@ -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"

View file

@ -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().