refactor(commands): replace Deps with per-request Runtime

Separate stateless Registry (cached on AgentLoop) from per-request
Runtime (passed to handlers at execution time). This enables future
session management features to inject per-request context without
modifying the command registry.

- Rename Deps → Runtime, move to runtime.go
- Change Handler signature: func(ctx, req) error → func(ctx, req, rt *Runtime) error
- NewExecutor now takes (registry, runtime) — executor is created per-request
- BuiltinDefinitions() no longer takes parameters (stateless)
- AgentLoop caches cmdRegistry, builds Runtime via buildRuntime()
- Update all cmd_*.go handlers and tests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
mingmxren 2026-03-04 00:37:38 +08:00
parent c2fc9f3e2a
commit 58f7b5bf2f
16 changed files with 146 additions and 149 deletions

View file

@ -47,7 +47,7 @@ type AgentLoop struct {
channelManager *channels.Manager channelManager *channels.Manager
mediaStore media.MediaStore mediaStore media.MediaStore
transcriber voice.Transcriber transcriber voice.Transcriber
cmdExecutor *commands.Executor cmdRegistry *commands.Registry
} }
// processOptions configures how a message is processed // processOptions configures how a message is processed
@ -103,48 +103,7 @@ func NewAgentLoop(
fallback: fallbackChain, fallback: fallbackChain,
} }
deps := &commands.Deps{ al.cmdRegistry = commands.NewRegistry(commands.BuiltinDefinitions())
Config: cfg,
GetModelInfo: func() (string, string) {
agent := al.registry.GetDefaultAgent()
if agent == nil {
return cfg.Agents.Defaults.GetModelName(), cfg.Agents.Defaults.Provider
}
return agent.Model, cfg.Agents.Defaults.Provider
},
ListAgentIDs: al.registry.ListAgentIDs,
GetEnabledChannels: func() []string {
if al.channelManager == nil {
return nil
}
return al.channelManager.GetEnabledChannels()
},
SwitchModel: func(value string) (string, error) {
defaultAgent := al.registry.GetDefaultAgent()
if defaultAgent == nil {
return "", fmt.Errorf("no default agent configured")
}
oldModel := defaultAgent.Model
defaultAgent.Model = value
if al.cfg != nil {
al.cfg.Agents.Defaults.ModelName = value
al.cfg.Agents.Defaults.Model = value
}
return oldModel, nil
},
SwitchChannel: func(value string) error {
if al.channelManager == nil {
return fmt.Errorf("channel manager not initialized")
}
if _, exists := al.channelManager.GetChannel(value); !exists && value != "cli" {
return fmt.Errorf("channel '%s' not found or not enabled", value)
}
return nil
},
}
al.cmdExecutor = commands.NewExecutor(
commands.NewRegistry(commands.BuiltinDefinitions(deps)),
)
return al return al
} }
@ -1508,11 +1467,13 @@ func (al *AgentLoop) handleCommand(
return "", false return "", false
} }
executor := al.cmdExecutor if al.cmdRegistry == nil {
if executor == nil {
return "", false return "", false
} }
rt := al.buildRuntime()
executor := commands.NewExecutor(al.cmdRegistry, rt)
var commandReply string var commandReply string
result := executor.Execute(ctx, commands.Request{ result := executor.Execute(ctx, commands.Request{
Channel: msg.Channel, Channel: msg.Channel,
@ -1539,6 +1500,48 @@ func (al *AgentLoop) handleCommand(
} }
} }
func (al *AgentLoop) buildRuntime() *commands.Runtime {
return &commands.Runtime{
Config: al.cfg,
GetModelInfo: func() (string, string) {
agent := al.registry.GetDefaultAgent()
if agent == nil {
return al.cfg.Agents.Defaults.GetModelName(), al.cfg.Agents.Defaults.Provider
}
return agent.Model, al.cfg.Agents.Defaults.Provider
},
ListAgentIDs: al.registry.ListAgentIDs,
GetEnabledChannels: func() []string {
if al.channelManager == nil {
return nil
}
return al.channelManager.GetEnabledChannels()
},
SwitchModel: func(value string) (string, error) {
defaultAgent := al.registry.GetDefaultAgent()
if defaultAgent == nil {
return "", fmt.Errorf("no default agent configured")
}
oldModel := defaultAgent.Model
defaultAgent.Model = value
if al.cfg != nil {
al.cfg.Agents.Defaults.ModelName = value
al.cfg.Agents.Defaults.Model = value
}
return oldModel, nil
},
SwitchChannel: func(value string) error {
if al.channelManager == nil {
return fmt.Errorf("channel manager not initialized")
}
if _, exists := al.channelManager.GetChannel(value); !exists && value != "cli" {
return fmt.Errorf("channel '%s' not found or not enabled", value)
}
return nil
},
}
}
func mapCommandError(result commands.ExecuteResult) string { func mapCommandError(result commands.ExecuteResult) string {
if result.Command == "" { if result.Command == "" {
return fmt.Sprintf("Failed to execute command: %v", result.Err) return fmt.Sprintf("Failed to execute command: %v", result.Err)

View file

@ -130,7 +130,7 @@ func (c *TelegramChannel) Start(ctx context.Context) error {
"username": c.bot.Username(), "username": c.bot.Username(),
}) })
c.startCommandRegistration(c.ctx, commands.BuiltinDefinitions(&commands.Deps{Config: c.config})) c.startCommandRegistration(c.ctx, commands.BuiltinDefinitions())
go func() { go func() {
if err = bh.Start(); err != nil { if err = bh.Start(); err != nil {

View file

@ -2,12 +2,14 @@ package commands
// BuiltinDefinitions returns all built-in command definitions. // BuiltinDefinitions returns all built-in command definitions.
// Each command group is defined in its own cmd_*.go file. // Each command group is defined in its own cmd_*.go file.
func BuiltinDefinitions(deps *Deps) []Definition { // Definitions are stateless — runtime dependencies are provided
// via the Runtime parameter passed to handlers at execution time.
func BuiltinDefinitions() []Definition {
return []Definition{ return []Definition{
startCommand(), startCommand(),
helpCommand(deps), helpCommand(),
showCommand(deps), showCommand(),
listCommand(deps), listCommand(),
switchCommand(deps), switchCommand(),
} }
} }

View file

@ -18,8 +18,7 @@ func findDefinitionByName(t *testing.T, defs []Definition, name string) Definiti
} }
func TestBuiltinHelpHandler_ReturnsFormattedMessage(t *testing.T) { func TestBuiltinHelpHandler_ReturnsFormattedMessage(t *testing.T) {
deps := &Deps{} defs := BuiltinDefinitions()
defs := BuiltinDefinitions(deps)
helpDef := findDefinitionByName(t, defs, "help") helpDef := findDefinitionByName(t, defs, "help")
if helpDef.Handler == nil { if helpDef.Handler == nil {
t.Fatalf("/help handler should not be nil") t.Fatalf("/help handler should not be nil")
@ -32,7 +31,7 @@ func TestBuiltinHelpHandler_ReturnsFormattedMessage(t *testing.T) {
reply = text reply = text
return nil return nil
}, },
}) }, nil)
if err != nil { if err != nil {
t.Fatalf("/help handler error: %v", err) t.Fatalf("/help handler error: %v", err)
} }
@ -46,11 +45,8 @@ func TestBuiltinHelpHandler_ReturnsFormattedMessage(t *testing.T) {
} }
func TestBuiltinShowChannel_PreservesUserVisibleBehavior(t *testing.T) { func TestBuiltinShowChannel_PreservesUserVisibleBehavior(t *testing.T) {
deps := &Deps{} defs := BuiltinDefinitions()
defs := BuiltinDefinitions(deps) ex := NewExecutor(NewRegistry(defs), nil)
// show now uses sub-commands, so we need the executor to route
ex := NewExecutor(NewRegistry(defs))
cases := []string{"telegram", "whatsapp"} cases := []string{"telegram", "whatsapp"}
for _, channel := range cases { for _, channel := range cases {
@ -74,13 +70,13 @@ func TestBuiltinShowChannel_PreservesUserVisibleBehavior(t *testing.T) {
} }
func TestBuiltinListChannels_UsesGetEnabledChannels(t *testing.T) { func TestBuiltinListChannels_UsesGetEnabledChannels(t *testing.T) {
deps := &Deps{ rt := &Runtime{
GetEnabledChannels: func() []string { GetEnabledChannels: func() []string {
return []string{"telegram", "slack"} return []string{"telegram", "slack"}
}, },
} }
defs := BuiltinDefinitions(deps) defs := BuiltinDefinitions()
ex := NewExecutor(NewRegistry(defs)) ex := NewExecutor(NewRegistry(defs), rt)
var reply string var reply string
res := ex.Execute(context.Background(), Request{ res := ex.Execute(context.Background(), Request{
@ -99,13 +95,13 @@ func TestBuiltinListChannels_UsesGetEnabledChannels(t *testing.T) {
} }
func TestBuiltinShowAgents_RestoresOldBehavior(t *testing.T) { func TestBuiltinShowAgents_RestoresOldBehavior(t *testing.T) {
deps := &Deps{ rt := &Runtime{
ListAgentIDs: func() []string { ListAgentIDs: func() []string {
return []string{"default", "coder"} return []string{"default", "coder"}
}, },
} }
defs := BuiltinDefinitions(deps) defs := BuiltinDefinitions()
ex := NewExecutor(NewRegistry(defs)) ex := NewExecutor(NewRegistry(defs), rt)
var reply string var reply string
res := ex.Execute(context.Background(), Request{ res := ex.Execute(context.Background(), Request{
@ -124,13 +120,13 @@ func TestBuiltinShowAgents_RestoresOldBehavior(t *testing.T) {
} }
func TestBuiltinListAgents_RestoresOldBehavior(t *testing.T) { func TestBuiltinListAgents_RestoresOldBehavior(t *testing.T) {
deps := &Deps{ rt := &Runtime{
ListAgentIDs: func() []string { ListAgentIDs: func() []string {
return []string{"default", "coder"} return []string{"default", "coder"}
}, },
} }
defs := BuiltinDefinitions(deps) defs := BuiltinDefinitions()
ex := NewExecutor(NewRegistry(defs)) ex := NewExecutor(NewRegistry(defs), rt)
var reply string var reply string
res := ex.Execute(context.Background(), Request{ res := ex.Execute(context.Background(), Request{

View file

@ -7,12 +7,12 @@ import (
) )
// agentsHandler returns a shared handler for both /show agents and /list agents. // agentsHandler returns a shared handler for both /show agents and /list agents.
func agentsHandler(deps *Deps) Handler { func agentsHandler() Handler {
return func(_ context.Context, req Request) error { return func(_ context.Context, req Request, rt *Runtime) error {
if deps.ListAgentIDs == nil { if rt == nil || rt.ListAgentIDs == nil {
return req.Reply(unavailableMsg) return req.Reply(unavailableMsg)
} }
ids := deps.ListAgentIDs() ids := rt.ListAgentIDs()
if len(ids) == 0 { if len(ids) == 0 {
return req.Reply("No agents registered") return req.Reply("No agents registered")
} }

View file

@ -6,13 +6,13 @@ import (
"strings" "strings"
) )
func helpCommand(deps *Deps) Definition { func helpCommand() Definition {
return Definition{ return Definition{
Name: "help", Name: "help",
Description: "Show this help message", Description: "Show this help message",
Usage: "/help", Usage: "/help",
Handler: func(_ context.Context, req Request) error { Handler: func(_ context.Context, req Request, _ *Runtime) error {
defs := BuiltinDefinitions(deps) defs := BuiltinDefinitions()
return req.Reply(formatHelpMessage(defs)) return req.Reply(formatHelpMessage(defs))
}, },
} }

View file

@ -6,7 +6,7 @@ import (
"strings" "strings"
) )
func listCommand(deps *Deps) Definition { func listCommand() Definition {
return Definition{ return Definition{
Name: "list", Name: "list",
Description: "List available options", Description: "List available options",
@ -14,11 +14,11 @@ func listCommand(deps *Deps) Definition {
{ {
Name: "models", Name: "models",
Description: "Configured models", Description: "Configured models",
Handler: func(_ context.Context, req Request) error { Handler: func(_ context.Context, req Request, rt *Runtime) error {
if deps.GetModelInfo == nil { if rt == nil || rt.GetModelInfo == nil {
return req.Reply(unavailableMsg) return req.Reply(unavailableMsg)
} }
name, provider := deps.GetModelInfo() name, provider := rt.GetModelInfo()
if provider == "" { if provider == "" {
provider = "configured default" provider = "configured default"
} }
@ -31,11 +31,11 @@ func listCommand(deps *Deps) Definition {
{ {
Name: "channels", Name: "channels",
Description: "Enabled channels", Description: "Enabled channels",
Handler: func(_ context.Context, req Request) error { Handler: func(_ context.Context, req Request, rt *Runtime) error {
if deps.GetEnabledChannels == nil { if rt == nil || rt.GetEnabledChannels == nil {
return req.Reply(unavailableMsg) return req.Reply(unavailableMsg)
} }
enabled := deps.GetEnabledChannels() enabled := rt.GetEnabledChannels()
if len(enabled) == 0 { if len(enabled) == 0 {
return req.Reply("No channels enabled") return req.Reply("No channels enabled")
} }
@ -45,7 +45,7 @@ func listCommand(deps *Deps) Definition {
{ {
Name: "agents", Name: "agents",
Description: "Registered agents", Description: "Registered agents",
Handler: agentsHandler(deps), Handler: agentsHandler(),
}, },
}, },
} }

View file

@ -5,7 +5,7 @@ import (
"fmt" "fmt"
) )
func showCommand(deps *Deps) Definition { func showCommand() Definition {
return Definition{ return Definition{
Name: "show", Name: "show",
Description: "Show current configuration", Description: "Show current configuration",
@ -13,25 +13,25 @@ func showCommand(deps *Deps) Definition {
{ {
Name: "model", Name: "model",
Description: "Current model and provider", Description: "Current model and provider",
Handler: func(_ context.Context, req Request) error { Handler: func(_ context.Context, req Request, rt *Runtime) error {
if deps.GetModelInfo == nil { if rt == nil || rt.GetModelInfo == nil {
return req.Reply(unavailableMsg) return req.Reply(unavailableMsg)
} }
name, provider := deps.GetModelInfo() name, provider := rt.GetModelInfo()
return req.Reply(fmt.Sprintf("Current Model: %s (Provider: %s)", name, provider)) return req.Reply(fmt.Sprintf("Current Model: %s (Provider: %s)", name, provider))
}, },
}, },
{ {
Name: "channel", Name: "channel",
Description: "Current channel", Description: "Current channel",
Handler: func(_ context.Context, req Request) error { Handler: func(_ context.Context, req Request, _ *Runtime) error {
return req.Reply(fmt.Sprintf("Current Channel: %s", req.Channel)) return req.Reply(fmt.Sprintf("Current Channel: %s", req.Channel))
}, },
}, },
{ {
Name: "agents", Name: "agents",
Description: "Registered agents", Description: "Registered agents",
Handler: agentsHandler(deps), Handler: agentsHandler(),
}, },
}, },
} }

View file

@ -7,7 +7,7 @@ func startCommand() Definition {
Name: "start", Name: "start",
Description: "Start the bot", Description: "Start the bot",
Usage: "/start", Usage: "/start",
Handler: func(_ context.Context, req Request) error { Handler: func(_ context.Context, req Request, _ *Runtime) error {
return req.Reply("Hello! I am PicoClaw 🦞") return req.Reply("Hello! I am PicoClaw 🦞")
}, },
} }

View file

@ -5,7 +5,7 @@ import (
"fmt" "fmt"
) )
func switchCommand(deps *Deps) Definition { func switchCommand() Definition {
return Definition{ return Definition{
Name: "switch", Name: "switch",
Description: "Switch model or channel", Description: "Switch model or channel",
@ -14,8 +14,8 @@ func switchCommand(deps *Deps) Definition {
Name: "model", Name: "model",
Description: "Switch to a different model", Description: "Switch to a different model",
ArgsUsage: "to <name>", ArgsUsage: "to <name>",
Handler: func(_ context.Context, req Request) error { Handler: func(_ context.Context, req Request, rt *Runtime) error {
if deps.SwitchModel == nil { if rt == nil || rt.SwitchModel == nil {
return req.Reply(unavailableMsg) return req.Reply(unavailableMsg)
} }
// Parse: /switch model to <value> // Parse: /switch model to <value>
@ -23,7 +23,7 @@ func switchCommand(deps *Deps) Definition {
if nthToken(req.Text, 2) != "to" || value == "" { if nthToken(req.Text, 2) != "to" || value == "" {
return req.Reply("Usage: /switch model to <name>") return req.Reply("Usage: /switch model to <name>")
} }
oldModel, err := deps.SwitchModel(value) oldModel, err := rt.SwitchModel(value)
if err != nil { if err != nil {
return req.Reply(err.Error()) return req.Reply(err.Error())
} }
@ -34,15 +34,15 @@ func switchCommand(deps *Deps) Definition {
Name: "channel", Name: "channel",
Description: "Switch to a different channel", Description: "Switch to a different channel",
ArgsUsage: "to <name>", ArgsUsage: "to <name>",
Handler: func(_ context.Context, req Request) error { Handler: func(_ context.Context, req Request, rt *Runtime) error {
if deps.SwitchChannel == nil { if rt == nil || rt.SwitchChannel == nil {
return req.Reply(unavailableMsg) return req.Reply(unavailableMsg)
} }
value := nthToken(req.Text, 3) value := nthToken(req.Text, 3)
if nthToken(req.Text, 2) != "to" || value == "" { if nthToken(req.Text, 2) != "to" || value == "" {
return req.Reply("Usage: /switch channel to <name>") return req.Reply("Usage: /switch channel to <name>")
} }
if err := deps.SwitchChannel(value); err != nil { if err := rt.SwitchChannel(value); err != nil {
return req.Reply(err.Error()) return req.Reply(err.Error())
} }
return req.Reply(fmt.Sprintf("Switched target channel to %s", value)) return req.Reply(fmt.Sprintf("Switched target channel to %s", value))

View file

@ -7,12 +7,12 @@ import (
) )
func TestSwitchModel_Success(t *testing.T) { func TestSwitchModel_Success(t *testing.T) {
deps := &Deps{ rt := &Runtime{
SwitchModel: func(value string) (string, error) { SwitchModel: func(value string) (string, error) {
return "old-model", nil return "old-model", nil
}, },
} }
ex := NewExecutor(NewRegistry(BuiltinDefinitions(deps))) ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt)
var reply string var reply string
res := ex.Execute(context.Background(), Request{ res := ex.Execute(context.Background(), Request{
@ -32,12 +32,12 @@ func TestSwitchModel_Success(t *testing.T) {
} }
func TestSwitchModel_MissingToKeyword(t *testing.T) { func TestSwitchModel_MissingToKeyword(t *testing.T) {
deps := &Deps{ rt := &Runtime{
SwitchModel: func(value string) (string, error) { SwitchModel: func(value string) (string, error) {
return "old", nil return "old", nil
}, },
} }
ex := NewExecutor(NewRegistry(BuiltinDefinitions(deps))) ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt)
var reply string var reply string
res := ex.Execute(context.Background(), Request{ res := ex.Execute(context.Background(), Request{
@ -56,12 +56,12 @@ func TestSwitchModel_MissingToKeyword(t *testing.T) {
} }
func TestSwitchModel_MissingValue(t *testing.T) { func TestSwitchModel_MissingValue(t *testing.T) {
deps := &Deps{ rt := &Runtime{
SwitchModel: func(value string) (string, error) { SwitchModel: func(value string) (string, error) {
return "old", nil return "old", nil
}, },
} }
ex := NewExecutor(NewRegistry(BuiltinDefinitions(deps))) ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt)
var reply string var reply string
res := ex.Execute(context.Background(), Request{ res := ex.Execute(context.Background(), Request{
@ -80,12 +80,12 @@ func TestSwitchModel_MissingValue(t *testing.T) {
} }
func TestSwitchModel_Error(t *testing.T) { func TestSwitchModel_Error(t *testing.T) {
deps := &Deps{ rt := &Runtime{
SwitchModel: func(value string) (string, error) { SwitchModel: func(value string) (string, error) {
return "", fmt.Errorf("model not found") return "", fmt.Errorf("model not found")
}, },
} }
ex := NewExecutor(NewRegistry(BuiltinDefinitions(deps))) ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt)
var reply string var reply string
res := ex.Execute(context.Background(), Request{ res := ex.Execute(context.Background(), Request{
@ -104,8 +104,7 @@ func TestSwitchModel_Error(t *testing.T) {
} }
func TestSwitchModel_NilDep(t *testing.T) { func TestSwitchModel_NilDep(t *testing.T) {
deps := &Deps{} ex := NewExecutor(NewRegistry(BuiltinDefinitions()), &Runtime{})
ex := NewExecutor(NewRegistry(BuiltinDefinitions(deps)))
var reply string var reply string
res := ex.Execute(context.Background(), Request{ res := ex.Execute(context.Background(), Request{
@ -124,12 +123,12 @@ func TestSwitchModel_NilDep(t *testing.T) {
} }
func TestSwitchChannel_Success(t *testing.T) { func TestSwitchChannel_Success(t *testing.T) {
deps := &Deps{ rt := &Runtime{
SwitchChannel: func(value string) error { SwitchChannel: func(value string) error {
return nil return nil
}, },
} }
ex := NewExecutor(NewRegistry(BuiltinDefinitions(deps))) ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt)
var reply string var reply string
res := ex.Execute(context.Background(), Request{ res := ex.Execute(context.Background(), Request{
@ -149,12 +148,12 @@ func TestSwitchChannel_Success(t *testing.T) {
} }
func TestSwitchChannel_Error(t *testing.T) { func TestSwitchChannel_Error(t *testing.T) {
deps := &Deps{ rt := &Runtime{
SwitchChannel: func(value string) error { SwitchChannel: func(value string) error {
return fmt.Errorf("channel '%s' not found", value) return fmt.Errorf("channel '%s' not found", value)
}, },
} }
ex := NewExecutor(NewRegistry(BuiltinDefinitions(deps))) ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt)
var reply string var reply string
res := ex.Execute(context.Background(), Request{ res := ex.Execute(context.Background(), Request{
@ -173,8 +172,7 @@ func TestSwitchChannel_Error(t *testing.T) {
} }
func TestSwitchChannel_NilDep(t *testing.T) { func TestSwitchChannel_NilDep(t *testing.T) {
deps := &Deps{} ex := NewExecutor(NewRegistry(BuiltinDefinitions()), &Runtime{})
ex := NewExecutor(NewRegistry(BuiltinDefinitions(deps)))
var reply string var reply string
res := ex.Execute(context.Background(), Request{ res := ex.Execute(context.Background(), Request{
@ -193,12 +191,12 @@ func TestSwitchChannel_NilDep(t *testing.T) {
} }
func TestSwitch_BangPrefix(t *testing.T) { func TestSwitch_BangPrefix(t *testing.T) {
deps := &Deps{ rt := &Runtime{
SwitchModel: func(value string) (string, error) { SwitchModel: func(value string) (string, error) {
return "old", nil return "old", nil
}, },
} }
ex := NewExecutor(NewRegistry(BuiltinDefinitions(deps))) ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt)
var reply string var reply string
res := ex.Execute(context.Background(), Request{ res := ex.Execute(context.Background(), Request{
@ -217,8 +215,7 @@ func TestSwitch_BangPrefix(t *testing.T) {
} }
func TestSwitch_NoSubCommand(t *testing.T) { func TestSwitch_NoSubCommand(t *testing.T) {
deps := &Deps{} ex := NewExecutor(NewRegistry(BuiltinDefinitions()), &Runtime{})
ex := NewExecutor(NewRegistry(BuiltinDefinitions(deps)))
var reply string var reply string
res := ex.Execute(context.Background(), Request{ res := ex.Execute(context.Background(), Request{

View file

@ -5,7 +5,7 @@ import (
"strings" "strings"
) )
type Handler func(ctx context.Context, req Request) error type Handler func(ctx context.Context, req Request, rt *Runtime) error
type Request struct { type Request struct {
Channel string Channel string

View file

@ -22,10 +22,11 @@ type ExecuteResult struct {
type Executor struct { type Executor struct {
reg *Registry reg *Registry
rt *Runtime
} }
func NewExecutor(reg *Registry) *Executor { func NewExecutor(reg *Registry, rt *Runtime) *Executor {
return &Executor{reg: reg} return &Executor{reg: reg, rt: rt}
} }
// Execute implements a two-state command decision: // Execute implements a two-state command decision:
@ -60,7 +61,7 @@ func (e *Executor) executeDefinition(ctx context.Context, req Request, def Defin
if def.Handler == nil { if def.Handler == nil {
return ExecuteResult{Outcome: OutcomePassthrough, Command: def.Name} return ExecuteResult{Outcome: OutcomePassthrough, Command: def.Name}
} }
err := def.Handler(ctx, req) err := def.Handler(ctx, req, e.rt)
return ExecuteResult{Outcome: OutcomeHandled, Command: def.Name, Err: err} return ExecuteResult{Outcome: OutcomeHandled, Command: def.Name, Err: err}
} }
@ -77,7 +78,7 @@ func (e *Executor) executeDefinition(ctx context.Context, req Request, def Defin
if sc.Handler == nil { if sc.Handler == nil {
return ExecuteResult{Outcome: OutcomePassthrough, Command: def.Name} return ExecuteResult{Outcome: OutcomePassthrough, Command: def.Name}
} }
err := sc.Handler(ctx, req) err := sc.Handler(ctx, req, e.rt)
return ExecuteResult{Outcome: OutcomeHandled, Command: def.Name, Err: err} return ExecuteResult{Outcome: OutcomeHandled, Command: def.Name, Err: err}
} }
} }

View file

@ -9,7 +9,7 @@ import (
func TestExecutor_RegisteredWithoutHandler_ReturnsPassthrough(t *testing.T) { func TestExecutor_RegisteredWithoutHandler_ReturnsPassthrough(t *testing.T) {
defs := []Definition{{Name: "show"}} defs := []Definition{{Name: "show"}}
ex := NewExecutor(NewRegistry(defs)) ex := NewExecutor(NewRegistry(defs), nil)
res := ex.Execute(context.Background(), Request{Channel: "whatsapp", Text: "/show"}) res := ex.Execute(context.Background(), Request{Channel: "whatsapp", Text: "/show"})
if res.Outcome != OutcomePassthrough { if res.Outcome != OutcomePassthrough {
@ -19,7 +19,7 @@ func TestExecutor_RegisteredWithoutHandler_ReturnsPassthrough(t *testing.T) {
func TestExecutor_UnknownSlashCommand_ReturnsPassthrough(t *testing.T) { func TestExecutor_UnknownSlashCommand_ReturnsPassthrough(t *testing.T) {
defs := []Definition{{Name: "show"}} defs := []Definition{{Name: "show"}}
ex := NewExecutor(NewRegistry(defs)) ex := NewExecutor(NewRegistry(defs), nil)
res := ex.Execute(context.Background(), Request{Channel: "telegram", Text: "/unknown"}) res := ex.Execute(context.Background(), Request{Channel: "telegram", Text: "/unknown"})
if res.Outcome != OutcomePassthrough { if res.Outcome != OutcomePassthrough {
@ -32,13 +32,13 @@ func TestExecutor_SupportedCommandWithHandler_ReturnsHandled(t *testing.T) {
defs := []Definition{ defs := []Definition{
{ {
Name: "help", Name: "help",
Handler: func(context.Context, Request) error { Handler: func(context.Context, Request, *Runtime) error {
called = true called = true
return nil return nil
}, },
}, },
} }
ex := NewExecutor(NewRegistry(defs)) ex := NewExecutor(NewRegistry(defs), nil)
res := ex.Execute(context.Background(), Request{Channel: "telegram", Text: "/help@my_bot"}) res := ex.Execute(context.Background(), Request{Channel: "telegram", Text: "/help@my_bot"})
if res.Outcome != OutcomeHandled { if res.Outcome != OutcomeHandled {
@ -56,7 +56,7 @@ func TestExecutor_AliasWithoutHandler_ReturnsPassthrough(t *testing.T) {
Aliases: []string{"display"}, Aliases: []string{"display"},
}, },
} }
ex := NewExecutor(NewRegistry(defs)) ex := NewExecutor(NewRegistry(defs), nil)
res := ex.Execute(context.Background(), Request{Channel: "whatsapp", Text: "/display"}) res := ex.Execute(context.Background(), Request{Channel: "whatsapp", Text: "/display"})
if res.Outcome != OutcomePassthrough { if res.Outcome != OutcomePassthrough {
@ -73,13 +73,13 @@ func TestExecutor_AliasWithHandler_ReturnsHandled(t *testing.T) {
{ {
Name: "clear", Name: "clear",
Aliases: []string{"reset"}, Aliases: []string{"reset"},
Handler: func(context.Context, Request) error { Handler: func(context.Context, Request, *Runtime) error {
called = true called = true
return nil return nil
}, },
}, },
} }
ex := NewExecutor(NewRegistry(defs)) ex := NewExecutor(NewRegistry(defs), nil)
res := ex.Execute(context.Background(), Request{Channel: "telegram", Text: "/reset"}) res := ex.Execute(context.Background(), Request{Channel: "telegram", Text: "/reset"})
if res.Outcome != OutcomeHandled { if res.Outcome != OutcomeHandled {
@ -97,7 +97,7 @@ func TestExecutor_SupportedCommandWithNilHandler_ReturnsPassthrough(t *testing.T
defs := []Definition{ defs := []Definition{
{Name: "placeholder"}, {Name: "placeholder"},
} }
ex := NewExecutor(NewRegistry(defs)) ex := NewExecutor(NewRegistry(defs), nil)
res := ex.Execute(context.Background(), Request{Channel: "telegram", Text: "/placeholder list"}) res := ex.Execute(context.Background(), Request{Channel: "telegram", Text: "/placeholder list"})
if res.Outcome != OutcomePassthrough { if res.Outcome != OutcomePassthrough {
@ -114,7 +114,7 @@ func TestExecutor_NilHandlerDoesNotMaskLaterHandler(t *testing.T) {
defs := []Definition{ defs := []Definition{
{Name: "placeholder"}, {Name: "placeholder"},
} }
ex := NewExecutor(NewRegistry(defs)) ex := NewExecutor(NewRegistry(defs), nil)
res := ex.Execute(context.Background(), Request{Channel: "telegram", Text: "/placeholder"}) res := ex.Execute(context.Background(), Request{Channel: "telegram", Text: "/placeholder"})
if res.Outcome != OutcomePassthrough { if res.Outcome != OutcomePassthrough {
@ -130,12 +130,12 @@ func TestExecutor_HandlerErrorIsPropagated(t *testing.T) {
defs := []Definition{ defs := []Definition{
{ {
Name: "help", Name: "help",
Handler: func(context.Context, Request) error { Handler: func(context.Context, Request, *Runtime) error {
return wantErr return wantErr
}, },
}, },
} }
ex := NewExecutor(NewRegistry(defs)) ex := NewExecutor(NewRegistry(defs), nil)
res := ex.Execute(context.Background(), Request{Channel: "telegram", Text: "/help"}) res := ex.Execute(context.Background(), Request{Channel: "telegram", Text: "/help"})
if res.Outcome != OutcomeHandled { if res.Outcome != OutcomeHandled {
@ -151,13 +151,13 @@ func TestExecutor_SupportsBangPrefixAndCaseInsensitiveCommand(t *testing.T) {
defs := []Definition{ defs := []Definition{
{ {
Name: "help", Name: "help",
Handler: func(context.Context, Request) error { Handler: func(context.Context, Request, *Runtime) error {
called = true called = true
return nil return nil
}, },
}, },
} }
ex := NewExecutor(NewRegistry(defs)) ex := NewExecutor(NewRegistry(defs), nil)
res := ex.Execute(context.Background(), Request{Channel: "telegram", Text: "!HELP"}) res := ex.Execute(context.Background(), Request{Channel: "telegram", Text: "!HELP"})
if res.Outcome != OutcomeHandled { if res.Outcome != OutcomeHandled {
@ -174,7 +174,7 @@ func TestExecutor_SubCommand_RoutesToCorrectHandler(t *testing.T) {
{ {
Name: "show", Name: "show",
SubCommands: []SubCommand{ SubCommands: []SubCommand{
{Name: "model", Handler: func(_ context.Context, _ Request) error { {Name: "model", Handler: func(_ context.Context, _ Request, _ *Runtime) error {
modelCalled = true modelCalled = true
return nil return nil
}}, }},
@ -182,7 +182,7 @@ func TestExecutor_SubCommand_RoutesToCorrectHandler(t *testing.T) {
}, },
}, },
} }
ex := NewExecutor(NewRegistry(defs)) ex := NewExecutor(NewRegistry(defs), nil)
res := ex.Execute(context.Background(), Request{Text: "/show model"}) res := ex.Execute(context.Background(), Request{Text: "/show model"})
if res.Outcome != OutcomeHandled { if res.Outcome != OutcomeHandled {
@ -203,7 +203,7 @@ func TestExecutor_SubCommand_NoArg_RepliesUsage(t *testing.T) {
}, },
}, },
} }
ex := NewExecutor(NewRegistry(defs)) ex := NewExecutor(NewRegistry(defs), nil)
var reply string var reply string
res := ex.Execute(context.Background(), Request{ res := ex.Execute(context.Background(), Request{
@ -227,7 +227,7 @@ func TestExecutor_SubCommand_UnknownArg_RepliesError(t *testing.T) {
}, },
}, },
} }
ex := NewExecutor(NewRegistry(defs)) ex := NewExecutor(NewRegistry(defs), nil)
var reply string var reply string
res := ex.Execute(context.Background(), Request{ res := ex.Execute(context.Background(), Request{
@ -251,7 +251,7 @@ func TestExecutor_SubCommand_NilHandler_ReturnsPassthrough(t *testing.T) {
}, },
}, },
} }
ex := NewExecutor(NewRegistry(defs)) ex := NewExecutor(NewRegistry(defs), nil)
res := ex.Execute(context.Background(), Request{Text: "/show model"}) res := ex.Execute(context.Background(), Request{Text: "/show model"})
if res.Outcome != OutcomePassthrough { if res.Outcome != OutcomePassthrough {

View file

@ -2,11 +2,10 @@ package commands
import "github.com/sipeed/picoclaw/pkg/config" import "github.com/sipeed/picoclaw/pkg/config"
// Deps provides runtime data to command handlers without importing // Runtime provides runtime dependencies to command handlers. It is constructed
// agent or channel packages. Function fields are called at handler // per-request by the agent loop so that per-request state (like session scope)
// invocation time, not at construction time, so late-bound values // can coexist with long-lived callbacks (like GetModelInfo).
// (e.g. channelManager set after NewAgentLoop) are visible. type Runtime struct {
type Deps struct {
Config *config.Config Config *config.Config
GetModelInfo func() (name, provider string) GetModelInfo func() (name, provider string)
ListAgentIDs func() []string ListAgentIDs func() []string

View file

@ -7,8 +7,7 @@ import (
) )
func TestShowListHandlers_ChannelPolicy(t *testing.T) { func TestShowListHandlers_ChannelPolicy(t *testing.T) {
deps := &Deps{} ex := NewExecutor(NewRegistry(BuiltinDefinitions()), nil)
ex := NewExecutor(NewRegistry(BuiltinDefinitions(deps)))
var telegramReply string var telegramReply string
handled := ex.Execute(context.Background(), Request{ handled := ex.Execute(context.Background(), Request{
@ -58,12 +57,12 @@ func TestShowListHandlers_ChannelPolicy(t *testing.T) {
} }
func TestShowListHandlers_ListHandledOnAllChannels(t *testing.T) { func TestShowListHandlers_ListHandledOnAllChannels(t *testing.T) {
deps := &Deps{ rt := &Runtime{
GetEnabledChannels: func() []string { GetEnabledChannels: func() []string {
return []string{"telegram"} return []string{"telegram"}
}, },
} }
ex := NewExecutor(NewRegistry(BuiltinDefinitions(deps))) ex := NewExecutor(NewRegistry(BuiltinDefinitions()), rt)
var reply string var reply string
res := ex.Execute(context.Background(), Request{ res := ex.Execute(context.Background(), Request{