Files
Memoh/internal/command/handler_test.go
T
Acbox Liu b3a39ad93d refactor: replace persistent subagents with ephemeral spawn tool (#280)
* refactor: replace persistent subagents with ephemeral spawn tool (#subagent)

- Drop subagents table, remove all persistent subagent infrastructure
- Add 'subagent' session type with parent_session_id on bot_sessions
- Rewrite subagent tool as single 'spawn' tool with parallel execution
- Create system_subagent.md prompt, add _subagent.md include for chat
- Limit subagent tools to file, exec, web_search, web_fetch only
- Merge subagent token usage into parent chat session in reporting
- Remove frontend subagent management page, update chat UI for spawn
- Fix UTF-8 truncation in session title, fix query not passed to agent

* refactor: remove history message page
2026-03-22 19:03:28 +08:00

311 lines
8.3 KiB
Go

package command
import (
"context"
"strings"
"testing"
"github.com/memohai/memoh/internal/mcp"
"github.com/memohai/memoh/internal/schedule"
"github.com/memohai/memoh/internal/settings"
)
// --- fake services ---
type fakeRoleResolver struct {
role string
err error
}
func (f *fakeRoleResolver) GetMemberRole(_ context.Context, _, _ string) (string, error) {
return f.role, f.err
}
type fakeScheduleService struct {
items []schedule.Schedule
}
// newTestHandler creates a Handler with nil services for use in tests.
func newTestHandler(roleResolver MemberRoleResolver) *Handler {
return NewHandler(nil, roleResolver, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
}
// --- tests ---
func TestIsCommand(t *testing.T) {
t.Parallel()
h := newTestHandler(nil)
tests := []struct {
input string
want bool
}{
{"/help", true},
{" /schedule list", true},
{"@BotName /help", true},
{"@_user_1 /schedule list", true},
{"<@123456> /mcp list", true},
{"/help@MemohBot", true},
{"hello", false},
{"", false},
{"/", false},
{"/ ", false},
{"/unknown_cmd", false},
{"check https://example.com/help", false},
{"@bot hello", false},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
t.Parallel()
if got := h.IsCommand(tt.input); got != tt.want {
t.Errorf("IsCommand(%q) = %v, want %v", tt.input, got, tt.want)
}
})
}
}
func TestExecute_Help(t *testing.T) {
t.Parallel()
h := newTestHandler(&fakeRoleResolver{role: "owner"})
result, err := h.Execute(context.Background(), "bot-1", "user-1", "/help")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(result, "Available commands") {
t.Errorf("expected help text, got: %s", result)
}
}
func TestExecute_UnknownCommand(t *testing.T) {
t.Parallel()
h := newTestHandler(&fakeRoleResolver{role: "owner"})
result, err := h.Execute(context.Background(), "bot-1", "user-1", "/foobar")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(result, "Unknown command") {
t.Errorf("expected unknown command message, got: %s", result)
}
}
func TestExecute_WithMentionPrefix(t *testing.T) {
t.Parallel()
h := newTestHandler(&fakeRoleResolver{role: "owner"})
result, err := h.Execute(context.Background(), "bot-1", "user-1", "@BotName /help")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(result, "Available commands") {
t.Errorf("expected help text from mention-prefixed command, got: %s", result)
}
}
func TestExecute_TelegramBotSuffix(t *testing.T) {
t.Parallel()
h := newTestHandler(&fakeRoleResolver{role: "owner"})
result, err := h.Execute(context.Background(), "bot-1", "user-1", "/help@MemohBot")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(result, "Available commands") {
t.Errorf("expected help text from telegram-style command, got: %s", result)
}
}
func TestExecute_UnknownAction(t *testing.T) {
t.Parallel()
h := newTestHandler(&fakeRoleResolver{role: "owner"})
result, err := h.Execute(context.Background(), "bot-1", "user-1", "/schedule foobar")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(result, "Unknown action") {
t.Errorf("expected unknown action message, got: %s", result)
}
if !strings.Contains(result, "/schedule") {
t.Errorf("expected schedule usage in message, got: %s", result)
}
}
func TestExecute_WritePermissionDenied(t *testing.T) {
t.Parallel()
h := newTestHandler(&fakeRoleResolver{role: ""})
result, err := h.Execute(context.Background(), "bot-1", "user-1", "/schedule create test desc")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(result, "Permission denied") {
t.Errorf("expected permission denied, got: %s", result)
}
}
func TestExecute_WritePermissionAllowedForOwner(t *testing.T) {
t.Parallel()
h := newTestHandler(&fakeRoleResolver{role: "owner"})
result, err := h.Execute(context.Background(), "bot-1", "user-1", "/schedule create")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if strings.Contains(result, "Permission denied") {
t.Errorf("owner should not get permission denied, got: %s", result)
}
if !strings.Contains(result, "Usage:") {
t.Errorf("expected usage hint for missing args, got: %s", result)
}
}
func TestExecute_SettingsDefaultAction(t *testing.T) {
t.Parallel()
h := newTestHandler(&fakeRoleResolver{role: ""})
result, err := h.Execute(context.Background(), "bot-1", "user-1", "/settings")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if strings.Contains(result, "Unknown action") {
t.Errorf("expected settings get attempt, not unknown action, got: %s", result)
}
}
func TestExecute_MissingArgs(t *testing.T) {
t.Parallel()
h := newTestHandler(&fakeRoleResolver{role: "owner"})
tests := []struct {
cmd string
contains string
}{
{"/schedule get", "Usage:"},
{"/schedule create", "Usage:"},
{"/schedule delete", "Usage:"},
{"/mcp get", "Usage:"},
{"/mcp delete", "Usage:"},
{"/fs read", "not available"},
{"/model set", "Usage:"},
{"/model set-heartbeat", "Usage:"},
{"/memory set", "Usage:"},
{"/search set", "Usage:"},
{"/browser set", "Usage:"},
}
for _, tt := range tests {
t.Run(tt.cmd, func(t *testing.T) {
t.Parallel()
result, err := h.Execute(context.Background(), "bot-1", "user-1", tt.cmd)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(result, tt.contains) {
t.Errorf("expected %q in result, got: %s", tt.contains, result)
}
})
}
}
func TestFormatItems(t *testing.T) {
t.Parallel()
result := formatItems([][]kv{
{{"Name", "foo"}, {"Type", "bar"}},
{{"Name", "longname"}, {"Type", "x"}},
})
if !strings.Contains(result, "- foo") {
t.Errorf("expected '- foo' bullet, got: %s", result)
}
if !strings.Contains(result, " Type: bar") {
t.Errorf("expected indented 'Type: bar', got: %s", result)
}
if !strings.Contains(result, "- longname") {
t.Errorf("expected '- longname' bullet, got: %s", result)
}
}
func TestFormatItems_Empty(t *testing.T) {
t.Parallel()
result := formatItems(nil)
if result != "" {
t.Errorf("expected empty string for nil items, got: %q", result)
}
}
func TestFormatKV(t *testing.T) {
t.Parallel()
result := formatKV([]kv{
{"Name", "test"},
{"ID", "123"},
})
if !strings.Contains(result, "- Name: test") {
t.Errorf("expected '- Name: test', got: %s", result)
}
if !strings.Contains(result, "- ID: 123") {
t.Errorf("expected '- ID: 123', got: %s", result)
}
}
func TestTruncate(t *testing.T) {
t.Parallel()
if got := truncate("hello world", 5); got != "he..." {
t.Errorf("truncate: got %q", got)
}
if got := truncate("hi", 5); got != "hi" {
t.Errorf("truncate short: got %q", got)
}
}
// Verify that the global help includes all resource groups.
func TestGlobalHelp_AllGroups(t *testing.T) {
t.Parallel()
h := newTestHandler(nil)
help := h.registry.GlobalHelp()
for _, group := range []string{
"schedule", "mcp", "settings",
"model", "memory", "search", "browser", "usage",
"email", "heartbeat", "skill", "fs",
} {
if !strings.Contains(help, "/"+group) {
t.Errorf("missing /%s in global help", group)
}
}
}
// Verify write commands are tagged with [owner] in usage.
func TestUsage_OwnerTag(t *testing.T) {
t.Parallel()
h := newTestHandler(nil)
for _, name := range h.registry.order {
group := h.registry.groups[name]
usage := group.Usage()
for _, subName := range group.order {
sub := group.commands[subName]
if sub.IsWrite && !strings.Contains(usage, "[owner]") {
t.Errorf("/%s %s is a write command but usage missing [owner] tag", name, subName)
}
}
}
}
// Verify new commands with nil services return graceful errors, not panics.
func TestNewCommands_NilServices(t *testing.T) {
t.Parallel()
h := newTestHandler(&fakeRoleResolver{role: "owner"})
cmds := []string{
"/skill list",
"/fs list",
"/fs read /test.txt",
}
for _, cmd := range cmds {
t.Run(cmd, func(t *testing.T) {
t.Parallel()
result, err := h.Execute(context.Background(), "bot-1", "user-1", cmd)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result == "" {
t.Error("expected non-empty result")
}
})
}
}
// suppress unused warnings.
var (
_ = fakeScheduleService{items: []schedule.Schedule{{ID: "1", Name: "test"}}}
_ = mcp.Connection{}
_ = settings.Settings{}
)