diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 4c4e00a89..7555fc882 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -66,8 +66,6 @@ type processOptions struct { const ( defaultResponse = "I've completed processing but have no response to give. Increase `max_tool_iterations` in config.json." sessionKeyAgentPrefix = "agent:" - switchCommandToken = "/switch" - commandMentionSeparator = "@" metadataKeyAccountID = "account_id" metadataKeyGuildID = "guild_id" metadataKeyTeamID = "team_id" @@ -121,6 +119,28 @@ func NewAgentLoop( } 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)), @@ -1484,10 +1504,7 @@ func (al *AgentLoop) handleCommand( ctx context.Context, msg bus.InboundMessage, ) (string, bool) { - // Generic command routing is delegated to the centralized commands executor. - // Session lifecycle commands are intentionally out of scope for this PR layer. - content := strings.TrimSpace(msg.Content) - if !strings.HasPrefix(content, "/") { + if !commands.HasCommandPrefix(msg.Content) { return "", false } @@ -1520,53 +1537,9 @@ func (al *AgentLoop) handleCommand( return result.Reply, true } return "", true - case commands.OutcomePassthrough: - parts := strings.Fields(content) - if len(parts) == 0 { - return "", false - } - cmd := parts[0] - if at := strings.Index(cmd, commandMentionSeparator); at > 0 { - cmd = cmd[:at] - } - if cmd != switchCommandToken { - return "", false - } - - args := parts[1:] - if len(args) < 3 || args[1] != "to" { - return "Usage: /switch [model|channel] to ", true - } - target := args[0] - value := args[2] - - switch target { - case "model": - defaultAgent := al.registry.GetDefaultAgent() - if defaultAgent == nil { - return "No default agent configured", true - } - oldModel := defaultAgent.Model - defaultAgent.Model = value - if al.cfg != nil { - al.cfg.Agents.Defaults.ModelName = value - al.cfg.Agents.Defaults.Model = value - } - return fmt.Sprintf("Switched model from %s to %s", oldModel, value), true - case "channel": - if al.channelManager == nil { - return "Channel manager not initialized", true - } - if _, exists := al.channelManager.GetChannel(value); !exists && value != "cli" { - return fmt.Sprintf("Channel '%s' not found or not enabled", value), true - } - return fmt.Sprintf("Switched target channel to %s", value), true - default: - return fmt.Sprintf("Unknown switch target: %s", target), true - } + default: + return "", false } - - return "", false } func mapCommandError(result commands.ExecuteResult) string { diff --git a/pkg/commands/builtin.go b/pkg/commands/builtin.go index e746aaf9d..f35874d23 100644 --- a/pkg/commands/builtin.go +++ b/pkg/commands/builtin.go @@ -8,5 +8,6 @@ func BuiltinDefinitions(deps *Deps) []Definition { helpCommand(deps), showCommand(deps), listCommand(deps), + switchCommand(deps), } } diff --git a/pkg/commands/cmd_switch.go b/pkg/commands/cmd_switch.go new file mode 100644 index 000000000..619197fec --- /dev/null +++ b/pkg/commands/cmd_switch.go @@ -0,0 +1,59 @@ +package commands + +import ( + "context" + "fmt" +) + +func switchCommand(deps *Deps) Definition { + return Definition{ + Name: "switch", + Description: "Switch model or channel", + SubCommands: []SubCommand{ + { + Name: "model", + Description: "Switch to a different model", + ArgsUsage: "to ", + Handler: func(_ context.Context, req Request) error { + if req.Reply == nil { + return nil + } + if deps.SwitchModel == nil { + return req.Reply("Command unavailable in current context.") + } + // Parse: /switch model to + value := nthToken(req.Text, 3) // tokens: [/switch, model, to, ] + if nthToken(req.Text, 2) != "to" || value == "" { + return req.Reply("Usage: /switch model to ") + } + oldModel, err := deps.SwitchModel(value) + if err != nil { + return req.Reply(err.Error()) + } + return req.Reply(fmt.Sprintf("Switched model from %s to %s", oldModel, value)) + }, + }, + { + Name: "channel", + Description: "Switch to a different channel", + ArgsUsage: "to ", + Handler: func(_ context.Context, req Request) error { + if req.Reply == nil { + return nil + } + if deps.SwitchChannel == nil { + return req.Reply("Command unavailable in current context.") + } + value := nthToken(req.Text, 3) + if nthToken(req.Text, 2) != "to" || value == "" { + return req.Reply("Usage: /switch channel to ") + } + if err := deps.SwitchChannel(value); err != nil { + return req.Reply(err.Error()) + } + return req.Reply(fmt.Sprintf("Switched target channel to %s", value)) + }, + }, + }, + } +} diff --git a/pkg/commands/cmd_switch_test.go b/pkg/commands/cmd_switch_test.go new file mode 100644 index 000000000..7b3534dc3 --- /dev/null +++ b/pkg/commands/cmd_switch_test.go @@ -0,0 +1,238 @@ +package commands + +import ( + "context" + "fmt" + "testing" +) + +func TestSwitchModel_Success(t *testing.T) { + deps := &Deps{ + SwitchModel: func(value string) (string, error) { + return "old-model", nil + }, + } + ex := NewExecutor(NewRegistry(BuiltinDefinitions(deps))) + + var reply string + res := ex.Execute(context.Background(), Request{ + Text: "/switch model to gpt-4", + Reply: func(text string) error { + reply = text + return nil + }, + }) + if res.Outcome != OutcomeHandled { + t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled) + } + want := "Switched model from old-model to gpt-4" + if reply != want { + t.Fatalf("reply=%q, want=%q", reply, want) + } +} + +func TestSwitchModel_MissingToKeyword(t *testing.T) { + deps := &Deps{ + SwitchModel: func(value string) (string, error) { + return "old", nil + }, + } + ex := NewExecutor(NewRegistry(BuiltinDefinitions(deps))) + + var reply string + res := ex.Execute(context.Background(), Request{ + Text: "/switch model gpt-4", + 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: /switch model to " { + t.Fatalf("reply=%q, want usage message", reply) + } +} + +func TestSwitchModel_MissingValue(t *testing.T) { + deps := &Deps{ + SwitchModel: func(value string) (string, error) { + return "old", nil + }, + } + ex := NewExecutor(NewRegistry(BuiltinDefinitions(deps))) + + var reply string + res := ex.Execute(context.Background(), Request{ + Text: "/switch model to", + 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: /switch model to " { + t.Fatalf("reply=%q, want usage message", reply) + } +} + +func TestSwitchModel_Error(t *testing.T) { + deps := &Deps{ + SwitchModel: func(value string) (string, error) { + return "", fmt.Errorf("model not found") + }, + } + ex := NewExecutor(NewRegistry(BuiltinDefinitions(deps))) + + var reply string + res := ex.Execute(context.Background(), Request{ + Text: "/switch model to bad-model", + Reply: func(text string) error { + reply = text + return nil + }, + }) + if res.Outcome != OutcomeHandled { + t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled) + } + if reply != "model not found" { + t.Fatalf("reply=%q, want error message", reply) + } +} + +func TestSwitchModel_NilDep(t *testing.T) { + deps := &Deps{} + ex := NewExecutor(NewRegistry(BuiltinDefinitions(deps))) + + var reply string + res := ex.Execute(context.Background(), Request{ + Text: "/switch model to gpt-4", + Reply: func(text string) error { + reply = text + return nil + }, + }) + if res.Outcome != OutcomeHandled { + t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled) + } + if reply != "Command unavailable in current context." { + t.Fatalf("reply=%q, want unavailable message", reply) + } +} + +func TestSwitchChannel_Success(t *testing.T) { + deps := &Deps{ + SwitchChannel: func(value string) error { + return nil + }, + } + ex := NewExecutor(NewRegistry(BuiltinDefinitions(deps))) + + var reply string + res := ex.Execute(context.Background(), Request{ + Text: "/switch channel to telegram", + Reply: func(text string) error { + reply = text + return nil + }, + }) + if res.Outcome != OutcomeHandled { + t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled) + } + want := "Switched target channel to telegram" + if reply != want { + t.Fatalf("reply=%q, want=%q", reply, want) + } +} + +func TestSwitchChannel_Error(t *testing.T) { + deps := &Deps{ + SwitchChannel: func(value string) error { + return fmt.Errorf("channel '%s' not found", value) + }, + } + ex := NewExecutor(NewRegistry(BuiltinDefinitions(deps))) + + var reply string + res := ex.Execute(context.Background(), Request{ + Text: "/switch channel to unknown", + Reply: func(text string) error { + reply = text + return nil + }, + }) + if res.Outcome != OutcomeHandled { + t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled) + } + if reply != "channel 'unknown' not found" { + t.Fatalf("reply=%q, want error message", reply) + } +} + +func TestSwitchChannel_NilDep(t *testing.T) { + deps := &Deps{} + ex := NewExecutor(NewRegistry(BuiltinDefinitions(deps))) + + var reply string + res := ex.Execute(context.Background(), Request{ + Text: "/switch channel to telegram", + Reply: func(text string) error { + reply = text + return nil + }, + }) + if res.Outcome != OutcomeHandled { + t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled) + } + if reply != "Command unavailable in current context." { + t.Fatalf("reply=%q, want unavailable message", reply) + } +} + +func TestSwitch_BangPrefix(t *testing.T) { + deps := &Deps{ + SwitchModel: func(value string) (string, error) { + return "old", nil + }, + } + ex := NewExecutor(NewRegistry(BuiltinDefinitions(deps))) + + var reply string + res := ex.Execute(context.Background(), Request{ + Text: "!switch model to gpt-4", + Reply: func(text string) error { + reply = text + return nil + }, + }) + if res.Outcome != OutcomeHandled { + t.Fatalf("! prefix: outcome=%v, want=%v", res.Outcome, OutcomeHandled) + } + if reply != "Switched model from old to gpt-4" { + t.Fatalf("! prefix: reply=%q, want success message", reply) + } +} + +func TestSwitch_NoSubCommand(t *testing.T) { + deps := &Deps{} + ex := NewExecutor(NewRegistry(BuiltinDefinitions(deps))) + + var reply string + res := ex.Execute(context.Background(), Request{ + Text: "/switch", + Reply: func(text string) error { + reply = text + return nil + }, + }) + if res.Outcome != OutcomeHandled { + t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled) + } + // Should get usage message from executor's sub-command routing + if reply == "" { + t.Fatal("expected usage reply for bare /switch") + } +} diff --git a/pkg/commands/deps.go b/pkg/commands/deps.go index 20fcf0a87..9db6711f9 100644 --- a/pkg/commands/deps.go +++ b/pkg/commands/deps.go @@ -11,4 +11,6 @@ type Deps struct { GetModelInfo func() (name, provider string) ListAgentIDs func() []string GetEnabledChannels func() []string + SwitchModel func(value string) (oldModel string, err error) + SwitchChannel func(value string) error } diff --git a/pkg/commands/dispatcher.go b/pkg/commands/dispatcher.go index 576735763..369d5cc2d 100644 --- a/pkg/commands/dispatcher.go +++ b/pkg/commands/dispatcher.go @@ -65,6 +65,26 @@ func secondToken(input string) string { return parts[1] } +// HasCommandPrefix returns true if the input starts with a recognized +// command prefix (e.g. "/" or "!"). +func HasCommandPrefix(input string) bool { + token := firstToken(input) + if token == "" { + return false + } + _, ok := trimCommandPrefix(token) + return ok +} + +// nthToken returns the 0-indexed token from whitespace-split input. +func nthToken(input string, n int) string { + parts := strings.Fields(strings.TrimSpace(input)) + if n >= len(parts) { + return "" + } + return parts[n] +} + func normalizeCommandName(name string) string { return strings.ToLower(strings.TrimSpace(name)) }