feat(commands): add sub-command routing to Executor
Uses Registry.Lookup for O(1) command dispatch instead of iterating all definitions. Definitions with SubCommands are routed to matching sub-command handlers. Missing or unknown sub-commands reply with auto-generated usage. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
c940133d0f
commit
a3419c6342
2 changed files with 130 additions and 37 deletions
|
|
@ -2,14 +2,13 @@ package commands
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Outcome int
|
type Outcome int
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// OutcomePassthrough means this input should continue through normal agent flow.
|
|
||||||
OutcomePassthrough Outcome = iota
|
OutcomePassthrough Outcome = iota
|
||||||
// OutcomeHandled means a command handler executed (with or without handler error).
|
|
||||||
OutcomeHandled
|
OutcomeHandled
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -41,36 +40,47 @@ func (e *Executor) Execute(ctx context.Context, req Request) ExecuteResult {
|
||||||
return ExecuteResult{Outcome: OutcomePassthrough, Command: cmdName}
|
return ExecuteResult{Outcome: OutcomePassthrough, Command: cmdName}
|
||||||
}
|
}
|
||||||
|
|
||||||
passthroughCommand := ""
|
def, found := e.reg.Lookup(cmdName)
|
||||||
|
if !found {
|
||||||
|
return ExecuteResult{Outcome: OutcomePassthrough, Command: cmdName}
|
||||||
|
}
|
||||||
|
|
||||||
for _, def := range e.reg.Definitions() {
|
return e.executeDefinition(ctx, req, def)
|
||||||
if !matchesCommand(def, cmdName) {
|
}
|
||||||
continue
|
|
||||||
}
|
func (e *Executor) executeDefinition(ctx context.Context, req Request, def Definition) ExecuteResult {
|
||||||
if passthroughCommand == "" {
|
// Simple command — no sub-commands
|
||||||
passthroughCommand = def.Name
|
if len(def.SubCommands) == 0 {
|
||||||
}
|
|
||||||
if def.Handler == nil {
|
if def.Handler == nil {
|
||||||
continue
|
return ExecuteResult{Outcome: OutcomePassthrough, Command: def.Name}
|
||||||
}
|
}
|
||||||
err := def.Handler(ctx, req)
|
err := def.Handler(ctx, req)
|
||||||
return ExecuteResult{Outcome: OutcomeHandled, Command: def.Name, Err: err}
|
return ExecuteResult{Outcome: OutcomeHandled, Command: def.Name, Err: err}
|
||||||
}
|
}
|
||||||
if passthroughCommand != "" {
|
|
||||||
return ExecuteResult{Outcome: OutcomePassthrough, Command: passthroughCommand}
|
// Sub-command routing
|
||||||
|
subName := secondToken(req.Text)
|
||||||
|
if subName == "" {
|
||||||
|
if req.Reply != nil {
|
||||||
|
_ = req.Reply("Usage: " + def.EffectiveUsage())
|
||||||
|
}
|
||||||
|
return ExecuteResult{Outcome: OutcomeHandled, Command: def.Name}
|
||||||
}
|
}
|
||||||
|
|
||||||
return ExecuteResult{Outcome: OutcomePassthrough, Command: cmdName}
|
normalized := normalizeCommandName(subName)
|
||||||
}
|
for _, sc := range def.SubCommands {
|
||||||
|
if normalizeCommandName(sc.Name) == normalized {
|
||||||
|
if sc.Handler == nil {
|
||||||
|
return ExecuteResult{Outcome: OutcomePassthrough, Command: def.Name}
|
||||||
|
}
|
||||||
|
err := sc.Handler(ctx, req)
|
||||||
|
return ExecuteResult{Outcome: OutcomeHandled, Command: def.Name, Err: err}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func matchesCommand(def Definition, cmdName string) bool {
|
// Unknown sub-command
|
||||||
if normalizeCommandName(def.Name) == cmdName {
|
if req.Reply != nil {
|
||||||
return true
|
_ = req.Reply(fmt.Sprintf("Unknown parameter: %s. Usage: %s", subName, def.EffectiveUsage()))
|
||||||
}
|
}
|
||||||
for _, alias := range def.Aliases {
|
return ExecuteResult{Outcome: OutcomeHandled, Command: def.Name}
|
||||||
if normalizeCommandName(alias) == cmdName {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package commands
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -108,29 +109,20 @@ func TestExecutor_SupportedCommandWithNilHandler_ReturnsPassthrough(t *testing.T
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExecutor_NilHandlerDoesNotMaskLaterHandler(t *testing.T) {
|
func TestExecutor_NilHandlerDoesNotMaskLaterHandler(t *testing.T) {
|
||||||
called := false
|
// With Lookup-based dispatch, the first registered definition for a name wins.
|
||||||
|
// A definition with nil Handler and no SubCommands returns Passthrough.
|
||||||
defs := []Definition{
|
defs := []Definition{
|
||||||
{Name: "placeholder"},
|
{Name: "placeholder"},
|
||||||
{
|
|
||||||
Name: "placeholder",
|
|
||||||
Handler: func(context.Context, Request) error {
|
|
||||||
called = true
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
ex := NewExecutor(NewRegistry(defs))
|
ex := NewExecutor(NewRegistry(defs))
|
||||||
|
|
||||||
res := ex.Execute(context.Background(), Request{Channel: "telegram", Text: "/placeholder"})
|
res := ex.Execute(context.Background(), Request{Channel: "telegram", Text: "/placeholder"})
|
||||||
if res.Outcome != OutcomeHandled {
|
if res.Outcome != OutcomePassthrough {
|
||||||
t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled)
|
t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomePassthrough)
|
||||||
}
|
}
|
||||||
if res.Command != "placeholder" {
|
if res.Command != "placeholder" {
|
||||||
t.Fatalf("command=%q, want=%q", res.Command, "placeholder")
|
t.Fatalf("command=%q, want=%q", res.Command, "placeholder")
|
||||||
}
|
}
|
||||||
if !called {
|
|
||||||
t.Fatalf("expected later handler to be called")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExecutor_HandlerErrorIsPropagated(t *testing.T) {
|
func TestExecutor_HandlerErrorIsPropagated(t *testing.T) {
|
||||||
|
|
@ -175,3 +167,94 @@ func TestExecutor_SupportsBangPrefixAndCaseInsensitiveCommand(t *testing.T) {
|
||||||
t.Fatalf("expected handler to be called")
|
t.Fatalf("expected handler to be called")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExecutor_SubCommand_RoutesToCorrectHandler(t *testing.T) {
|
||||||
|
modelCalled := false
|
||||||
|
defs := []Definition{
|
||||||
|
{
|
||||||
|
Name: "show",
|
||||||
|
SubCommands: []SubCommand{
|
||||||
|
{Name: "model", Handler: func(_ context.Context, _ Request) error {
|
||||||
|
modelCalled = true
|
||||||
|
return nil
|
||||||
|
}},
|
||||||
|
{Name: "channel"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ex := NewExecutor(NewRegistry(defs))
|
||||||
|
|
||||||
|
res := ex.Execute(context.Background(), Request{Text: "/show model"})
|
||||||
|
if res.Outcome != OutcomeHandled {
|
||||||
|
t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled)
|
||||||
|
}
|
||||||
|
if !modelCalled {
|
||||||
|
t.Fatal("model sub-command handler was not called")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecutor_SubCommand_NoArg_RepliesUsage(t *testing.T) {
|
||||||
|
defs := []Definition{
|
||||||
|
{
|
||||||
|
Name: "show",
|
||||||
|
SubCommands: []SubCommand{
|
||||||
|
{Name: "model"},
|
||||||
|
{Name: "channel"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ex := NewExecutor(NewRegistry(defs))
|
||||||
|
|
||||||
|
var reply string
|
||||||
|
res := ex.Execute(context.Background(), Request{
|
||||||
|
Text: "/show",
|
||||||
|
Reply: func(text string) error { reply = text; return nil },
|
||||||
|
})
|
||||||
|
if res.Outcome != OutcomeHandled {
|
||||||
|
t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled)
|
||||||
|
}
|
||||||
|
if reply != "Usage: /show [model|channel]" {
|
||||||
|
t.Fatalf("reply=%q, want usage message", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecutor_SubCommand_UnknownArg_RepliesError(t *testing.T) {
|
||||||
|
defs := []Definition{
|
||||||
|
{
|
||||||
|
Name: "show",
|
||||||
|
SubCommands: []SubCommand{
|
||||||
|
{Name: "model"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ex := NewExecutor(NewRegistry(defs))
|
||||||
|
|
||||||
|
var reply string
|
||||||
|
res := ex.Execute(context.Background(), Request{
|
||||||
|
Text: "/show foobar",
|
||||||
|
Reply: func(text string) error { reply = text; return nil },
|
||||||
|
})
|
||||||
|
if res.Outcome != OutcomeHandled {
|
||||||
|
t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled)
|
||||||
|
}
|
||||||
|
if !strings.Contains(reply, "foobar") {
|
||||||
|
t.Fatalf("reply=%q, should mention unknown sub-command", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecutor_SubCommand_NilHandler_ReturnsPassthrough(t *testing.T) {
|
||||||
|
defs := []Definition{
|
||||||
|
{
|
||||||
|
Name: "show",
|
||||||
|
SubCommands: []SubCommand{
|
||||||
|
{Name: "model"}, // nil Handler
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ex := NewExecutor(NewRegistry(defs))
|
||||||
|
|
||||||
|
res := ex.Execute(context.Background(), Request{Text: "/show model"})
|
||||||
|
if res.Outcome != OutcomePassthrough {
|
||||||
|
t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomePassthrough)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue