mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-27 07:16:19 +09:00
feat(command): improve slash command UX (#361)
Make slash commands easier to navigate in chat by splitting help into levels, compacting list output, and surfacing current selections for model, search, memory, and browser settings. Also route /status to the active conversation session and add an access inspector so users can understand their current command and ACL context.
This commit is contained in:
@@ -5,6 +5,10 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
dbsqlc "github.com/memohai/memoh/internal/db/sqlc"
|
||||
"github.com/memohai/memoh/internal/mcp"
|
||||
"github.com/memohai/memoh/internal/schedule"
|
||||
"github.com/memohai/memoh/internal/settings"
|
||||
@@ -25,9 +29,58 @@ type fakeScheduleService struct {
|
||||
items []schedule.Schedule
|
||||
}
|
||||
|
||||
type fakeCommandQueries struct {
|
||||
latestSessionID pgtype.UUID
|
||||
latestSessionErr error
|
||||
messageCount int64
|
||||
latestUsage int64
|
||||
latestUsageErr error
|
||||
cacheRow dbsqlc.GetSessionCacheStatsRow
|
||||
cacheErr error
|
||||
skills []string
|
||||
}
|
||||
|
||||
func (f *fakeCommandQueries) GetLatestSessionIDByBot(_ context.Context, _ pgtype.UUID) (pgtype.UUID, error) {
|
||||
return f.latestSessionID, f.latestSessionErr
|
||||
}
|
||||
|
||||
func (f *fakeCommandQueries) CountMessagesBySession(_ context.Context, _ pgtype.UUID) (int64, error) {
|
||||
return f.messageCount, nil
|
||||
}
|
||||
|
||||
func (f *fakeCommandQueries) GetLatestAssistantUsage(_ context.Context, _ pgtype.UUID) (int64, error) {
|
||||
if f.latestUsageErr != nil {
|
||||
return 0, f.latestUsageErr
|
||||
}
|
||||
return f.latestUsage, nil
|
||||
}
|
||||
|
||||
func (f *fakeCommandQueries) GetSessionCacheStats(_ context.Context, _ pgtype.UUID) (dbsqlc.GetSessionCacheStatsRow, error) {
|
||||
if f.cacheErr != nil {
|
||||
return dbsqlc.GetSessionCacheStatsRow{}, f.cacheErr
|
||||
}
|
||||
return f.cacheRow, nil
|
||||
}
|
||||
|
||||
func (f *fakeCommandQueries) GetSessionUsedSkills(_ context.Context, _ pgtype.UUID) ([]string, error) {
|
||||
return f.skills, nil
|
||||
}
|
||||
|
||||
func (*fakeCommandQueries) GetTokenUsageByDayAndType(_ context.Context, _ dbsqlc.GetTokenUsageByDayAndTypeParams) ([]dbsqlc.GetTokenUsageByDayAndTypeRow, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (*fakeCommandQueries) GetTokenUsageByModel(_ context.Context, _ dbsqlc.GetTokenUsageByModelParams) ([]dbsqlc.GetTokenUsageByModelRow, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// 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)
|
||||
return NewHandler(nil, roleResolver, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
}
|
||||
|
||||
func newTestHandlerWithQueries(roleResolver MemberRoleResolver, queries CommandQueries) *Handler {
|
||||
return NewHandler(nil, roleResolver, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, queries, nil, nil, nil)
|
||||
}
|
||||
|
||||
// --- tests ---
|
||||
@@ -73,6 +126,42 @@ func TestExecute_Help(t *testing.T) {
|
||||
if !strings.Contains(result, "Available commands") {
|
||||
t.Errorf("expected help text, got: %s", result)
|
||||
}
|
||||
if strings.Contains(result, "set-heartbeat") {
|
||||
t.Errorf("top-level help should not expand nested actions, got: %s", result)
|
||||
}
|
||||
if !strings.Contains(result, "- /model - Manage bot models") {
|
||||
t.Errorf("expected top-level model entry, got: %s", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecute_HelpGroup(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newTestHandler(&fakeRoleResolver{role: "owner"})
|
||||
result, err := h.Execute(context.Background(), "bot-1", "user-1", "/help model")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(result, "/model - Manage bot models") {
|
||||
t.Errorf("expected group help, got: %s", result)
|
||||
}
|
||||
if !strings.Contains(result, "- set - Set the chat model [owner]") {
|
||||
t.Errorf("expected compact action summary, got: %s", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecute_HelpAction(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newTestHandler(&fakeRoleResolver{role: "owner"})
|
||||
result, err := h.Execute(context.Background(), "bot-1", "user-1", "/help model set")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(result, "Usage: /model set <model_id> | <provider_name> <model_name>") {
|
||||
t.Errorf("expected action usage, got: %s", result)
|
||||
}
|
||||
if !strings.Contains(result, "Access: owner only") {
|
||||
t.Errorf("expected owner hint, got: %s", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecute_UnknownCommand(t *testing.T) {
|
||||
@@ -207,8 +296,8 @@ func TestFormatItems(t *testing.T) {
|
||||
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, "- foo | Type: bar") {
|
||||
t.Errorf("expected compact line entry, got: %s", result)
|
||||
}
|
||||
if !strings.Contains(result, "- longname") {
|
||||
t.Errorf("expected '- longname' bullet, got: %s", result)
|
||||
@@ -255,7 +344,7 @@ func TestGlobalHelp_AllGroups(t *testing.T) {
|
||||
for _, group := range []string{
|
||||
"schedule", "mcp", "settings",
|
||||
"model", "memory", "search", "browser", "usage",
|
||||
"email", "heartbeat", "skill", "fs",
|
||||
"email", "heartbeat", "skill", "fs", "access",
|
||||
} {
|
||||
if !strings.Contains(help, "/"+group) {
|
||||
t.Errorf("missing /%s in global help", group)
|
||||
@@ -263,6 +352,86 @@ func TestGlobalHelp_AllGroups(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteWithInput_Access(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newTestHandler(&fakeRoleResolver{role: "owner"})
|
||||
result, err := h.ExecuteWithInput(context.Background(), ExecuteInput{
|
||||
BotID: "bot-1",
|
||||
ChannelIdentityID: "channel-id-1",
|
||||
UserID: "user-id-1",
|
||||
Text: "/access",
|
||||
ChannelType: "discord",
|
||||
ConversationType: "thread",
|
||||
ConversationID: "conv-1",
|
||||
ThreadID: "thread-1",
|
||||
RouteID: "route-1",
|
||||
SessionID: "session-1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(result, "- Channel Identity: channel-id-1") {
|
||||
t.Errorf("expected channel identity in access output, got: %s", result)
|
||||
}
|
||||
if !strings.Contains(result, "- Write Commands: yes") {
|
||||
t.Errorf("expected write access in access output, got: %s", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecute_StatusLatest(t *testing.T) {
|
||||
t.Parallel()
|
||||
sessionUUID := pgtype.UUID{}
|
||||
copy(sessionUUID.Bytes[:], []byte{1, 2, 3, 4, 5, 6, 7, 8, 9})
|
||||
sessionUUID.Valid = true
|
||||
h := newTestHandlerWithQueries(&fakeRoleResolver{role: "owner"}, &fakeCommandQueries{
|
||||
latestSessionID: sessionUUID,
|
||||
messageCount: 42,
|
||||
latestUsage: 1200,
|
||||
cacheRow: dbsqlc.GetSessionCacheStatsRow{
|
||||
CacheReadTokens: 300,
|
||||
CacheWriteTokens: 150,
|
||||
TotalInputTokens: 1200,
|
||||
},
|
||||
skills: []string{"search", "browser"},
|
||||
})
|
||||
result, err := h.Execute(context.Background(), "11111111-1111-1111-1111-111111111111", "user-1", "/status latest")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(result, "- Scope: latest bot session") {
|
||||
t.Errorf("expected latest scope, got: %s", result)
|
||||
}
|
||||
if !strings.Contains(result, "- Messages: 42") {
|
||||
t.Errorf("expected message count, got: %s", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecute_StatusLatestNoRows(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newTestHandlerWithQueries(&fakeRoleResolver{role: "owner"}, &fakeCommandQueries{
|
||||
latestSessionErr: pgx.ErrNoRows,
|
||||
})
|
||||
result, err := h.Execute(context.Background(), "11111111-1111-1111-1111-111111111111", "user-1", "/status latest")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(result, "No session found for this bot.") {
|
||||
t.Errorf("expected no session message, got: %s", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecute_StatusShowWithoutSession(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newTestHandlerWithQueries(&fakeRoleResolver{role: "owner"}, &fakeCommandQueries{})
|
||||
result, err := h.Execute(context.Background(), "bot-1", "user-1", "/status")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(result, "No active session found for this conversation.") {
|
||||
t.Errorf("expected route-aware no session message, got: %s", result)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify write commands are tagged with [owner] in usage.
|
||||
func TestUsage_OwnerTag(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
Reference in New Issue
Block a user