fix(agentic): register plan named actions
Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
parent
759bb9bcb7
commit
f37980bd4a
4 changed files with 247 additions and 0 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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().
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue