mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-25 07:00:48 +09:00
b3a39ad93d
* 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
311 lines
8.3 KiB
Go
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{}
|
|
)
|