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
mediaStore media.MediaStore
transcriber voice.Transcriber
cmdExecutor *commands.Executor
cmdRegistry *commands.Registry
}
// processOptions configures how a message is processed
@ -103,48 +103,7 @@ func NewAgentLoop(
fallback: fallbackChain,
}
deps := &commands.Deps{
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)),
)
al.cmdRegistry = commands.NewRegistry(commands.BuiltinDefinitions())
return al
}
@ -1508,11 +1467,13 @@ func (al *AgentLoop) handleCommand(
return "", false
}
executor := al.cmdExecutor
if executor == nil {
if al.cmdRegistry == nil {
return "", false
}
rt := al.buildRuntime()
executor := commands.NewExecutor(al.cmdRegistry, rt)
var commandReply string
result := executor.Execute(ctx, commands.Request{
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 {
if result.Command == "" {
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(),
})
c.startCommandRegistration(c.ctx, commands.BuiltinDefinitions(&commands.Deps{Config: c.config}))
c.startCommandRegistration(c.ctx, commands.BuiltinDefinitions())
go func() {
if err = bh.Start(); err != nil {

View file

@ -2,12 +2,14 @@ package commands
// BuiltinDefinitions returns all built-in command definitions.
// 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{
startCommand(),
helpCommand(deps),
showCommand(deps),
listCommand(deps),
switchCommand(deps),
helpCommand(),
showCommand(),
listCommand(),
switchCommand(),
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -22,10 +22,11 @@ type ExecuteResult struct {
type Executor struct {
reg *Registry
rt *Runtime
}
func NewExecutor(reg *Registry) *Executor {
return &Executor{reg: reg}
func NewExecutor(reg *Registry, rt *Runtime) *Executor {
return &Executor{reg: reg, rt: rt}
}
// 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 {
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}
}
@ -77,7 +78,7 @@ func (e *Executor) executeDefinition(ctx context.Context, req Request, def Defin
if sc.Handler == nil {
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}
}
}

View file

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

View file

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

View file

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