feat(command): improve slash command UX

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:
Acbox
2026-04-12 17:25:10 +08:00
parent 3307b27a80
commit 0549f5cafc
22 changed files with 1080 additions and 138 deletions
+173 -4
View File
@@ -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()