From f07ea569baa563f3815cc6426ed76a625dd22241 Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 00:42:55 +0000 Subject: [PATCH] feat(agentic): add state command surface Co-Authored-By: Virgil --- pkg/agentic/commands.go | 1 + pkg/agentic/commands_state.go | 174 +++++++++++++++++++++++++++++ pkg/agentic/commands_state_test.go | 151 +++++++++++++++++++++++++ pkg/agentic/prep_test.go | 5 + 4 files changed, 331 insertions(+) create mode 100644 pkg/agentic/commands_state.go create mode 100644 pkg/agentic/commands_state_test.go diff --git a/pkg/agentic/commands.go b/pkg/agentic/commands.go index d83fdce..65d4ed5 100644 --- a/pkg/agentic/commands.go +++ b/pkg/agentic/commands.go @@ -72,6 +72,7 @@ func (s *PrepSubsystem) registerCommands(ctx context.Context) { s.registerPlanCommands() s.registerSessionCommands() s.registerTaskCommands() + s.registerStateCommands() s.registerLanguageCommands() s.registerSetupCommands() } diff --git a/pkg/agentic/commands_state.go b/pkg/agentic/commands_state.go new file mode 100644 index 0000000..94de35f --- /dev/null +++ b/pkg/agentic/commands_state.go @@ -0,0 +1,174 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package agentic + +import ( + core "dappco.re/go/core" +) + +func (s *PrepSubsystem) registerStateCommands() { + c := s.Core() + c.Command("state", core.Command{Description: "Manage shared plan state", Action: s.cmdState}) + c.Command("agentic:state", core.Command{Description: "Manage shared plan state", Action: s.cmdState}) + c.Command("state/set", core.Command{Description: "Store shared plan state", Action: s.cmdStateSet}) + c.Command("agentic:state/set", core.Command{Description: "Store shared plan state", Action: s.cmdStateSet}) + c.Command("state/get", core.Command{Description: "Read shared plan state by key", Action: s.cmdStateGet}) + c.Command("agentic:state/get", core.Command{Description: "Read shared plan state by key", Action: s.cmdStateGet}) + c.Command("state/list", core.Command{Description: "List shared plan state for a plan", Action: s.cmdStateList}) + c.Command("agentic:state/list", core.Command{Description: "List shared plan state for a plan", Action: s.cmdStateList}) + c.Command("state/delete", core.Command{Description: "Delete shared plan state by key", Action: s.cmdStateDelete}) + c.Command("agentic:state/delete", core.Command{Description: "Delete shared plan state by key", Action: s.cmdStateDelete}) +} + +func (s *PrepSubsystem) cmdState(options core.Options) core.Result { + switch action := optionStringValue(options, "action"); action { + case "set": + return s.cmdStateSet(options) + case "get": + return s.cmdStateGet(options) + case "list": + return s.cmdStateList(options) + case "delete": + return s.cmdStateDelete(options) + case "": + core.Print(nil, "usage: core-agent state set --key=pattern --value=observer [--type=general] [--description=\"Shared across sessions\"]") + core.Print(nil, " core-agent state get --key=pattern") + core.Print(nil, " core-agent state list [--type=general] [--category=general]") + core.Print(nil, " core-agent state delete --key=pattern") + return core.Result{OK: true} + default: + core.Print(nil, "usage: core-agent state set --key=pattern --value=observer [--type=general] [--description=\"Shared across sessions\"]") + core.Print(nil, " core-agent state get --key=pattern") + core.Print(nil, " core-agent state list [--type=general] [--category=general]") + core.Print(nil, " core-agent state delete --key=pattern") + return core.Result{Value: core.E("agentic.cmdState", core.Concat("unknown state command: ", action), nil), OK: false} + } +} + +func (s *PrepSubsystem) cmdStateSet(options core.Options) core.Result { + result := s.handleStateSet(s.commandContext(), core.NewOptions( + core.Option{Key: "plan_slug", Value: optionStringValue(options, "plan_slug", "plan", "slug", "_arg")}, + core.Option{Key: "key", Value: optionStringValue(options, "key")}, + core.Option{Key: "value", Value: optionAnyValue(options, "value")}, + core.Option{Key: "type", Value: optionStringValue(options, "type")}, + core.Option{Key: "description", Value: optionStringValue(options, "description")}, + core.Option{Key: "category", Value: optionStringValue(options, "category")}, + )) + if !result.OK { + err := commandResultError("agentic.cmdStateSet", result) + core.Print(nil, "error: %v", err) + return core.Result{Value: err, OK: false} + } + + output, ok := result.Value.(StateOutput) + if !ok { + err := core.E("agentic.cmdStateSet", "invalid state set output", nil) + core.Print(nil, "error: %v", err) + return core.Result{Value: err, OK: false} + } + + core.Print(nil, "key: %s", output.State.Key) + core.Print(nil, "type: %s", output.State.Type) + if output.State.Description != "" { + core.Print(nil, "desc: %s", output.State.Description) + } + core.Print(nil, "value: %s", stateValueString(output.State.Value)) + return core.Result{Value: output, OK: true} +} + +func (s *PrepSubsystem) cmdStateGet(options core.Options) core.Result { + result := s.handleStateGet(s.commandContext(), core.NewOptions( + core.Option{Key: "plan_slug", Value: optionStringValue(options, "plan_slug", "plan", "slug", "_arg")}, + core.Option{Key: "key", Value: optionStringValue(options, "key")}, + )) + if !result.OK { + err := commandResultError("agentic.cmdStateGet", result) + core.Print(nil, "error: %v", err) + return core.Result{Value: err, OK: false} + } + + output, ok := result.Value.(StateOutput) + if !ok { + err := core.E("agentic.cmdStateGet", "invalid state get output", nil) + core.Print(nil, "error: %v", err) + return core.Result{Value: err, OK: false} + } + + core.Print(nil, "key: %s", output.State.Key) + core.Print(nil, "type: %s", output.State.Type) + if output.State.Description != "" { + core.Print(nil, "desc: %s", output.State.Description) + } + if output.State.UpdatedAt != "" { + core.Print(nil, "updated: %s", output.State.UpdatedAt) + } + core.Print(nil, "value: %s", stateValueString(output.State.Value)) + return core.Result{Value: output, OK: true} +} + +func (s *PrepSubsystem) cmdStateList(options core.Options) core.Result { + result := s.handleStateList(s.commandContext(), core.NewOptions( + core.Option{Key: "plan_slug", Value: optionStringValue(options, "plan_slug", "plan", "slug", "_arg")}, + core.Option{Key: "type", Value: optionStringValue(options, "type")}, + core.Option{Key: "category", Value: optionStringValue(options, "category")}, + )) + if !result.OK { + err := commandResultError("agentic.cmdStateList", result) + core.Print(nil, "error: %v", err) + return core.Result{Value: err, OK: false} + } + + output, ok := result.Value.(StateListOutput) + if !ok { + err := core.E("agentic.cmdStateList", "invalid state list output", nil) + core.Print(nil, "error: %v", err) + return core.Result{Value: err, OK: false} + } + + if output.Total == 0 { + core.Print(nil, "no states") + return core.Result{Value: output, OK: true} + } + + for _, state := range output.States { + core.Print(nil, " %-20s %-12s %s", state.Key, state.Type, stateValueString(state.Value)) + } + core.Print(nil, "%d state(s)", output.Total) + return core.Result{Value: output, OK: true} +} + +func (s *PrepSubsystem) cmdStateDelete(options core.Options) core.Result { + result := s.handleStateDelete(s.commandContext(), core.NewOptions( + core.Option{Key: "plan_slug", Value: optionStringValue(options, "plan_slug", "plan", "slug", "_arg")}, + core.Option{Key: "key", Value: optionStringValue(options, "key")}, + )) + if !result.OK { + err := commandResultError("agentic.cmdStateDelete", result) + core.Print(nil, "error: %v", err) + return core.Result{Value: err, OK: false} + } + + output, ok := result.Value.(StateDeleteOutput) + if !ok { + err := core.E("agentic.cmdStateDelete", "invalid state delete output", nil) + core.Print(nil, "error: %v", err) + return core.Result{Value: err, OK: false} + } + + core.Print(nil, "deleted: %s", output.Deleted.Key) + core.Print(nil, "type: %s", output.Deleted.Type) + return core.Result{Value: output, OK: true} +} + +func stateValueString(value any) string { + if text, ok := value.(string); ok { + return text + } + + jsonValue := core.JSONMarshalString(value) + if jsonValue != "" { + return jsonValue + } + + return core.Sprint(value) +} diff --git a/pkg/agentic/commands_state_test.go b/pkg/agentic/commands_state_test.go new file mode 100644 index 0000000..c381646 --- /dev/null +++ b/pkg/agentic/commands_state_test.go @@ -0,0 +1,151 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package agentic + +import ( + "testing" + + core "dappco.re/go/core" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCommandsState_RegisterStateCommands_Good(t *testing.T) { + s, c := testPrepWithCore(t, nil) + + s.registerStateCommands() + + assert.Contains(t, c.Commands(), "state") + assert.Contains(t, c.Commands(), "agentic:state") + assert.Contains(t, c.Commands(), "state/set") + assert.Contains(t, c.Commands(), "state/get") + assert.Contains(t, c.Commands(), "state/list") + assert.Contains(t, c.Commands(), "state/delete") +} + +func TestCommandsState_CmdStateSet_Good(t *testing.T) { + s, _ := testPrepWithCore(t, nil) + + result := s.cmdStateSet(core.NewOptions( + core.Option{Key: "_arg", Value: "ax-follow-up"}, + core.Option{Key: "key", Value: "pattern"}, + core.Option{Key: "value", Value: "observer"}, + core.Option{Key: "type", Value: "general"}, + core.Option{Key: "description", Value: "Shared across sessions"}, + )) + + require.True(t, result.OK) + + output, ok := result.Value.(StateOutput) + require.True(t, ok) + assert.Equal(t, "pattern", output.State.Key) + assert.Equal(t, "general", output.State.Type) + assert.Equal(t, "observer", output.State.Value) + assert.Equal(t, "Shared across sessions", output.State.Description) +} + +func TestCommandsState_CmdStateSet_Bad_MissingValue(t *testing.T) { + s, _ := testPrepWithCore(t, nil) + + result := s.cmdStateSet(core.NewOptions( + core.Option{Key: "_arg", Value: "ax-follow-up"}, + core.Option{Key: "key", Value: "pattern"}, + )) + + assert.False(t, result.OK) +} + +func TestCommandsState_CmdStateGet_Good(t *testing.T) { + s, _ := testPrepWithCore(t, nil) + + _, output, err := s.stateSet(s.commandContext(), nil, StateSetInput{ + PlanSlug: "ax-follow-up", + Key: "pattern", + Value: "observer", + Type: "general", + }) + require.NoError(t, err) + require.Equal(t, "pattern", output.State.Key) + + result := s.cmdStateGet(core.NewOptions( + core.Option{Key: "_arg", Value: "ax-follow-up"}, + core.Option{Key: "key", Value: "pattern"}, + )) + + require.True(t, result.OK) + stateOutput, ok := result.Value.(StateOutput) + require.True(t, ok) + assert.Equal(t, "pattern", stateOutput.State.Key) + assert.Equal(t, "observer", stateOutput.State.Value) +} + +func TestCommandsState_CmdStateGet_Bad_MissingKey(t *testing.T) { + s, _ := testPrepWithCore(t, nil) + + result := s.cmdStateGet(core.NewOptions(core.Option{Key: "_arg", Value: "ax-follow-up"})) + + assert.False(t, result.OK) +} + +func TestCommandsState_CmdStateList_Good(t *testing.T) { + s, _ := testPrepWithCore(t, nil) + + _, _, err := s.stateSet(s.commandContext(), nil, StateSetInput{ + PlanSlug: "ax-follow-up", + Key: "pattern", + Value: "observer", + Type: "general", + }) + require.NoError(t, err) + + result := s.cmdStateList(core.NewOptions(core.Option{Key: "_arg", Value: "ax-follow-up"})) + + require.True(t, result.OK) + listOutput, ok := result.Value.(StateListOutput) + require.True(t, ok) + assert.Equal(t, 1, listOutput.Total) + assert.Len(t, listOutput.States, 1) +} + +func TestCommandsState_CmdStateList_Ugly_EmptyPlan(t *testing.T) { + s, _ := testPrepWithCore(t, nil) + + result := s.cmdStateList(core.NewOptions(core.Option{Key: "_arg", Value: "ax-follow-up"})) + + require.True(t, result.OK) + listOutput, ok := result.Value.(StateListOutput) + require.True(t, ok) + assert.Zero(t, listOutput.Total) + assert.Empty(t, listOutput.States) +} + +func TestCommandsState_CmdStateDelete_Good(t *testing.T) { + s, _ := testPrepWithCore(t, nil) + + _, _, err := s.stateSet(s.commandContext(), nil, StateSetInput{ + PlanSlug: "ax-follow-up", + Key: "pattern", + Value: "observer", + Type: "general", + }) + require.NoError(t, err) + + result := s.cmdStateDelete(core.NewOptions( + core.Option{Key: "_arg", Value: "ax-follow-up"}, + core.Option{Key: "key", Value: "pattern"}, + )) + + require.True(t, result.OK) + deleteOutput, ok := result.Value.(StateDeleteOutput) + require.True(t, ok) + assert.Equal(t, "pattern", deleteOutput.Deleted.Key) + assert.False(t, fs.Exists(statePath("ax-follow-up"))) +} + +func TestCommandsState_CmdStateDelete_Bad_MissingKey(t *testing.T) { + s, _ := testPrepWithCore(t, nil) + + result := s.cmdStateDelete(core.NewOptions(core.Option{Key: "_arg", Value: "ax-follow-up"})) + + assert.False(t, result.OK) +} diff --git a/pkg/agentic/prep_test.go b/pkg/agentic/prep_test.go index 5e26c3c..dda6fbb 100644 --- a/pkg/agentic/prep_test.go +++ b/pkg/agentic/prep_test.go @@ -711,6 +711,11 @@ func TestPrep_OnStartup_Good_RegistersGenerateCommand(t *testing.T) { assert.Contains(t, c.Commands(), "task/create") assert.Contains(t, c.Commands(), "task/update") assert.Contains(t, c.Commands(), "task/toggle") + assert.Contains(t, c.Commands(), "state") + assert.Contains(t, c.Commands(), "state/set") + assert.Contains(t, c.Commands(), "state/get") + assert.Contains(t, c.Commands(), "state/list") + assert.Contains(t, c.Commands(), "state/delete") } func TestPrep_OnStartup_Bad(t *testing.T) {