diff --git a/pkg/agentic/prep.go b/pkg/agentic/prep.go index 33a64cf..16e15c9 100644 --- a/pkg/agentic/prep.go +++ b/pkg/agentic/prep.go @@ -206,6 +206,7 @@ func (s *PrepSubsystem) OnStartup(ctx context.Context) core.Result { c.Action("state.set", s.handleStateSet).Description = "Store shared plan state for later sessions" c.Action("state.get", s.handleStateGet).Description = "Read shared plan state by key" c.Action("state.list", s.handleStateList).Description = "List shared plan state for a plan" + c.Action("state.delete", s.handleStateDelete).Description = "Delete shared plan state by key" c.Action("template.list", s.handleTemplateList).Description = "List available YAML plan templates" c.Action("template.preview", s.handleTemplatePreview).Description = "Preview a YAML plan template with variable substitution" c.Action("template.create_plan", s.handleTemplateCreatePlan).Description = "Create a stored plan from a YAML template" diff --git a/pkg/agentic/prep_test.go b/pkg/agentic/prep_test.go index 08d4d46..1390a35 100644 --- a/pkg/agentic/prep_test.go +++ b/pkg/agentic/prep_test.go @@ -478,6 +478,7 @@ func TestPrep_OnStartup_Good_RegistersSessionActions(t *testing.T) { assert.True(t, c.Action("state.set").Exists()) assert.True(t, c.Action("state.get").Exists()) assert.True(t, c.Action("state.list").Exists()) + assert.True(t, c.Action("state.delete").Exists()) assert.True(t, c.Action("issue.create").Exists()) assert.True(t, c.Action("issue.get").Exists()) assert.True(t, c.Action("issue.list").Exists()) diff --git a/pkg/agentic/state.go b/pkg/agentic/state.go index f264991..b473044 100644 --- a/pkg/agentic/state.go +++ b/pkg/agentic/state.go @@ -46,6 +46,12 @@ type StateListInput struct { Category string `json:"category,omitempty"` } +// input := agentic.StateDeleteInput{PlanSlug: "ax-follow-up", Key: "pattern"} +type StateDeleteInput struct { + PlanSlug string `json:"plan_slug"` + Key string `json:"key"` +} + // out := agentic.StateOutput{Success: true, State: agentic.WorkspaceState{Key: "pattern"}} type StateOutput struct { Success bool `json:"success"` @@ -59,6 +65,12 @@ type StateListOutput struct { States []WorkspaceState `json:"states"` } +// out := agentic.StateDeleteOutput{Success: true, Deleted: agentic.WorkspaceState{Key: "pattern"}} +type StateDeleteOutput struct { + Success bool `json:"success"` + Deleted WorkspaceState `json:"deleted"` +} + // result := c.Action("state.set").Run(ctx, core.NewOptions( // // core.Option{Key: "plan_slug", Value: "ax-follow-up"}, @@ -111,6 +123,23 @@ func (s *PrepSubsystem) handleStateList(ctx context.Context, options core.Option return core.Result{Value: output, OK: true} } +// result := c.Action("state.delete").Run(ctx, core.NewOptions( +// +// core.Option{Key: "plan_slug", Value: "ax-follow-up"}, +// core.Option{Key: "key", Value: "pattern"}, +// +// )) +func (s *PrepSubsystem) handleStateDelete(ctx context.Context, options core.Options) core.Result { + _, output, err := s.stateDelete(ctx, nil, StateDeleteInput{ + PlanSlug: optionStringValue(options, "plan_slug", "plan"), + Key: optionStringValue(options, "key"), + }) + if err != nil { + return core.Result{Value: err, OK: false} + } + return core.Result{Value: output, OK: true} +} + func (s *PrepSubsystem) registerStateTools(server *mcp.Server) { mcp.AddTool(server, &mcp.Tool{ Name: "state_set", @@ -126,6 +155,11 @@ func (s *PrepSubsystem) registerStateTools(server *mcp.Server) { Name: "state_list", Description: "List all stored workspace state values for a plan, with optional type or category filtering.", }, s.stateList) + + mcp.AddTool(server, &mcp.Tool{ + Name: "state_delete", + Description: "Delete a stored workspace state value for a plan by key.", + }, s.stateDelete) } func (s *PrepSubsystem) stateSet(_ context.Context, _ *mcp.CallToolRequest, input StateSetInput) (*mcp.CallToolResult, StateOutput, error) { @@ -238,6 +272,51 @@ func (s *PrepSubsystem) stateList(_ context.Context, _ *mcp.CallToolRequest, inp }, nil } +func (s *PrepSubsystem) stateDelete(_ context.Context, _ *mcp.CallToolRequest, input StateDeleteInput) (*mcp.CallToolResult, StateDeleteOutput, error) { + if input.PlanSlug == "" { + return nil, StateDeleteOutput{}, core.E("stateDelete", "plan_slug is required", nil) + } + if input.Key == "" { + return nil, StateDeleteOutput{}, core.E("stateDelete", "key is required", nil) + } + + states, err := readPlanStates(input.PlanSlug) + if err != nil { + return nil, StateDeleteOutput{}, err + } + + filtered := make([]WorkspaceState, 0, len(states)) + deleted := WorkspaceState{} + found := false + for _, state := range states { + if state.Key == input.Key { + deleted = normaliseWorkspaceState(state) + found = true + continue + } + filtered = append(filtered, state) + } + + if !found { + return nil, StateDeleteOutput{}, core.E("stateDelete", core.Concat("state not found: ", input.Key), nil) + } + + path := statePath(input.PlanSlug) + if len(filtered) == 0 { + if deleteResult := fs.Delete(path); !deleteResult.OK { + err, _ := deleteResult.Value.(error) + return nil, StateDeleteOutput{}, core.E("stateDelete", "failed to delete empty state file", err) + } + } else if err := writePlanStates(input.PlanSlug, filtered); err != nil { + return nil, StateDeleteOutput{}, err + } + + return nil, StateDeleteOutput{ + Success: true, + Deleted: deleted, + }, nil +} + func stateRoot() string { return core.JoinPath(CoreRoot(), "state") } diff --git a/pkg/agentic/state_test.go b/pkg/agentic/state_test.go index 837b2df..568e82d 100644 --- a/pkg/agentic/state_test.go +++ b/pkg/agentic/state_test.go @@ -145,3 +145,54 @@ func TestState_HandleStateList_Ugly_CorruptStateFile(t *testing.T) { )) assert.False(t, result.OK) } + +func TestState_HandleStateDelete_Good(t *testing.T) { + subsystem := testPrepWithPlatformServer(t, nil, "") + require.NoError(t, writePlanStates("ax-follow-up", []WorkspaceState{ + {Key: "pattern", Value: "observer", Type: "general", Description: "Shared across sessions"}, + {Key: "risk", Value: "auth", Type: "security"}, + })) + + result := subsystem.handleStateDelete(context.Background(), core.NewOptions( + core.Option{Key: "plan_slug", Value: "ax-follow-up"}, + core.Option{Key: "key", Value: "pattern"}, + )) + require.True(t, result.OK) + + output, ok := result.Value.(StateDeleteOutput) + require.True(t, ok) + assert.Equal(t, "pattern", output.Deleted.Key) + assert.Equal(t, "general", output.Deleted.Type) + assert.Equal(t, "Shared across sessions", output.Deleted.Description) + + states, err := readPlanStates("ax-follow-up") + require.NoError(t, err) + require.Len(t, states, 1) + assert.Equal(t, "risk", states[0].Key) + + require.True(t, subsystem.handleStateDelete(context.Background(), core.NewOptions( + core.Option{Key: "plan_slug", Value: "ax-follow-up"}, + core.Option{Key: "key", Value: "risk"}, + )).OK) + assert.False(t, fs.Exists(statePath("ax-follow-up"))) +} + +func TestState_HandleStateDelete_Bad(t *testing.T) { + subsystem := testPrepWithPlatformServer(t, nil, "") + result := subsystem.handleStateDelete(context.Background(), core.NewOptions( + core.Option{Key: "plan_slug", Value: "ax-follow-up"}, + )) + assert.False(t, result.OK) +} + +func TestState_HandleStateDelete_Ugly_CorruptStateFile(t *testing.T) { + subsystem := testPrepWithPlatformServer(t, nil, "") + require.True(t, fs.EnsureDir(stateRoot()).OK) + require.True(t, fs.Write(statePath("ax-follow-up"), `{broken`).OK) + + result := subsystem.handleStateDelete(context.Background(), core.NewOptions( + core.Option{Key: "plan_slug", Value: "ax-follow-up"}, + core.Option{Key: "key", Value: "pattern"}, + )) + assert.False(t, result.OK) +}