Added support for managing multiple workspaces including: - New workspace bind command to link existing directories - Enhanced session management scoped to workspaces - Improved error handling with localized messages - Fixed interactive state lookup for multi-workspace contexts generated by llmgit Co-Authored-By: Claude <noreply@anthropic.com>
1777 lines
54 KiB
Go
1777 lines
54 KiB
Go
package core
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// --- stubs for Engine tests ---
|
|
|
|
type stubAgent struct{}
|
|
|
|
func (a *stubAgent) Name() string { return "stub" }
|
|
func (a *stubAgent) StartSession(_ context.Context, _ string) (AgentSession, error) {
|
|
return &stubAgentSession{}, nil
|
|
}
|
|
func (a *stubAgent) ListSessions(_ context.Context) ([]AgentSessionInfo, error) { return nil, nil }
|
|
func (a *stubAgent) Stop() error { return nil }
|
|
|
|
type stubAgentSession struct{}
|
|
|
|
func (s *stubAgentSession) Send(_ string, _ []ImageAttachment) error { return nil }
|
|
func (s *stubAgentSession) RespondPermission(_ string, _ PermissionResult) error { return nil }
|
|
func (s *stubAgentSession) Events() <-chan Event { return make(chan Event) }
|
|
func (s *stubAgentSession) CurrentSessionID() string { return "stub-session" }
|
|
func (s *stubAgentSession) Alive() bool { return true }
|
|
func (s *stubAgentSession) Close() error { return nil }
|
|
|
|
type recordingAgentSession struct {
|
|
stubAgentSession
|
|
lastID string
|
|
lastResult PermissionResult
|
|
calls int
|
|
}
|
|
|
|
func (s *recordingAgentSession) RespondPermission(id string, res PermissionResult) error {
|
|
s.lastID = id
|
|
s.lastResult = res
|
|
s.calls++
|
|
return nil
|
|
}
|
|
|
|
type stubPlatformEngine struct {
|
|
n string
|
|
sent []string
|
|
}
|
|
|
|
func (p *stubPlatformEngine) Name() string { return p.n }
|
|
func (p *stubPlatformEngine) Start(MessageHandler) error { return nil }
|
|
func (p *stubPlatformEngine) Reply(_ context.Context, _ any, content string) error {
|
|
p.sent = append(p.sent, content)
|
|
return nil
|
|
}
|
|
func (p *stubPlatformEngine) Send(_ context.Context, _ any, content string) error {
|
|
p.sent = append(p.sent, content)
|
|
return nil
|
|
}
|
|
func (p *stubPlatformEngine) Stop() error { return nil }
|
|
|
|
type stubInlineButtonPlatform struct {
|
|
stubPlatformEngine
|
|
buttonContent string
|
|
buttonRows [][]ButtonOption
|
|
}
|
|
|
|
func (p *stubInlineButtonPlatform) SendWithButtons(_ context.Context, _ any, content string, buttons [][]ButtonOption) error {
|
|
p.buttonContent = content
|
|
p.buttonRows = buttons
|
|
return nil
|
|
}
|
|
|
|
type stubCardPlatform struct {
|
|
stubPlatformEngine
|
|
repliedCards []*Card
|
|
sentCards []*Card
|
|
cardErr error
|
|
}
|
|
|
|
func (p *stubCardPlatform) ReplyCard(_ context.Context, _ any, card *Card) error {
|
|
if p.cardErr != nil {
|
|
return p.cardErr
|
|
}
|
|
p.repliedCards = append(p.repliedCards, card)
|
|
return nil
|
|
}
|
|
|
|
func (p *stubCardPlatform) SendCard(_ context.Context, _ any, card *Card) error {
|
|
if p.cardErr != nil {
|
|
return p.cardErr
|
|
}
|
|
p.sentCards = append(p.sentCards, card)
|
|
return nil
|
|
}
|
|
|
|
type stubModelModeAgent struct {
|
|
stubAgent
|
|
model string
|
|
mode string
|
|
reasoningEffort string
|
|
}
|
|
|
|
func (a *stubModelModeAgent) SetModel(model string) {
|
|
a.model = model
|
|
}
|
|
|
|
func (a *stubModelModeAgent) GetModel() string {
|
|
return a.model
|
|
}
|
|
|
|
func (a *stubModelModeAgent) AvailableModels(_ context.Context) []ModelOption {
|
|
return []ModelOption{
|
|
{Name: "gpt-4.1", Desc: "Balanced"},
|
|
{Name: "gpt-4.1-mini", Desc: "Fast"},
|
|
}
|
|
}
|
|
|
|
func (a *stubModelModeAgent) SetMode(mode string) {
|
|
a.mode = mode
|
|
}
|
|
|
|
func (a *stubModelModeAgent) GetMode() string {
|
|
if a.mode == "" {
|
|
return "default"
|
|
}
|
|
return a.mode
|
|
}
|
|
|
|
func (a *stubModelModeAgent) PermissionModes() []PermissionModeInfo {
|
|
return []PermissionModeInfo{
|
|
{Key: "default", Name: "Default", NameZh: "默认", Desc: "Ask before risky actions", DescZh: "危险操作前询问"},
|
|
{Key: "yolo", Name: "YOLO", NameZh: "放手做", Desc: "Skip confirmations", DescZh: "跳过确认"},
|
|
}
|
|
}
|
|
|
|
func (a *stubModelModeAgent) SetReasoningEffort(effort string) {
|
|
a.reasoningEffort = effort
|
|
}
|
|
|
|
func (a *stubModelModeAgent) GetReasoningEffort() string {
|
|
return a.reasoningEffort
|
|
}
|
|
|
|
func (a *stubModelModeAgent) AvailableReasoningEfforts() []string {
|
|
return []string{"low", "medium", "high", "xhigh"}
|
|
}
|
|
|
|
type stubListAgent struct {
|
|
stubAgent
|
|
sessions []AgentSessionInfo
|
|
}
|
|
|
|
func (a *stubListAgent) ListSessions(_ context.Context) ([]AgentSessionInfo, error) {
|
|
return a.sessions, nil
|
|
}
|
|
|
|
type stubDeleteAgent struct {
|
|
stubListAgent
|
|
deleted []string
|
|
errByID map[string]error
|
|
}
|
|
|
|
func (a *stubDeleteAgent) DeleteSession(_ context.Context, sessionID string) error {
|
|
if err := a.errByID[sessionID]; err != nil {
|
|
return err
|
|
}
|
|
a.deleted = append(a.deleted, sessionID)
|
|
return nil
|
|
}
|
|
|
|
type stubProviderAgent struct {
|
|
stubAgent
|
|
providers []ProviderConfig
|
|
active string
|
|
}
|
|
|
|
func (a *stubProviderAgent) ListProviders() []ProviderConfig {
|
|
return a.providers
|
|
}
|
|
|
|
func (a *stubProviderAgent) SetProviders(providers []ProviderConfig) {
|
|
a.providers = providers
|
|
}
|
|
|
|
func (a *stubProviderAgent) GetActiveProvider() *ProviderConfig {
|
|
for i := range a.providers {
|
|
if a.providers[i].Name == a.active {
|
|
return &a.providers[i]
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (a *stubProviderAgent) SetActiveProvider(name string) bool {
|
|
if name == "" {
|
|
a.active = ""
|
|
return true
|
|
}
|
|
for _, prov := range a.providers {
|
|
if prov.Name == name {
|
|
a.active = name
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
type stubUsageAgent struct {
|
|
stubAgent
|
|
report *UsageReport
|
|
err error
|
|
}
|
|
|
|
func (a *stubUsageAgent) GetUsage(_ context.Context) (*UsageReport, error) {
|
|
return a.report, a.err
|
|
}
|
|
|
|
func newTestEngine() *Engine {
|
|
return NewEngine("test", &stubAgent{}, []Platform{&stubPlatformEngine{n: "test"}}, "", LangEnglish)
|
|
}
|
|
|
|
func countCardActionValues(card *Card, prefix string) int {
|
|
count := 0
|
|
for _, elem := range card.Elements {
|
|
switch e := elem.(type) {
|
|
case CardActions:
|
|
for _, btn := range e.Buttons {
|
|
if strings.HasPrefix(btn.Value, prefix) {
|
|
count++
|
|
}
|
|
}
|
|
case CardListItem:
|
|
if strings.HasPrefix(e.BtnValue, prefix) {
|
|
count++
|
|
}
|
|
}
|
|
}
|
|
return count
|
|
}
|
|
|
|
func findCardAction(card *Card, value string) (CardButton, bool) {
|
|
for _, elem := range card.Elements {
|
|
switch e := elem.(type) {
|
|
case CardActions:
|
|
for _, btn := range e.Buttons {
|
|
if btn.Value == value {
|
|
return btn, true
|
|
}
|
|
}
|
|
case CardListItem:
|
|
if e.BtnValue == value {
|
|
return CardButton{Text: e.BtnText, Type: e.BtnType, Value: e.BtnValue}, true
|
|
}
|
|
}
|
|
}
|
|
return CardButton{}, false
|
|
}
|
|
|
|
func collectCardActionRows(card *Card) []CardActions {
|
|
rows := make([]CardActions, 0)
|
|
for _, elem := range card.Elements {
|
|
if row, ok := elem.(CardActions); ok {
|
|
rows = append(rows, row)
|
|
}
|
|
}
|
|
return rows
|
|
}
|
|
|
|
// --- alias tests ---
|
|
|
|
func TestEngine_Alias(t *testing.T) {
|
|
e := newTestEngine()
|
|
e.AddAlias("帮助", "/help")
|
|
e.AddAlias("新建", "/new")
|
|
|
|
got := e.resolveAlias("帮助")
|
|
if got != "/help" {
|
|
t.Errorf("resolveAlias('帮助') = %q, want /help", got)
|
|
}
|
|
|
|
got = e.resolveAlias("新建 my-session")
|
|
if got != "/new my-session" {
|
|
t.Errorf("resolveAlias('新建 my-session') = %q, want '/new my-session'", got)
|
|
}
|
|
|
|
got = e.resolveAlias("random text")
|
|
if got != "random text" {
|
|
t.Errorf("resolveAlias should not modify unmatched content, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestEngine_ClearAliases(t *testing.T) {
|
|
e := newTestEngine()
|
|
e.AddAlias("帮助", "/help")
|
|
e.ClearAliases()
|
|
|
|
got := e.resolveAlias("帮助")
|
|
if got != "帮助" {
|
|
t.Errorf("after ClearAliases, should not resolve, got %q", got)
|
|
}
|
|
}
|
|
|
|
// --- banned words tests ---
|
|
|
|
func TestEngine_BannedWords(t *testing.T) {
|
|
e := newTestEngine()
|
|
e.SetBannedWords([]string{"spam", "BadWord"})
|
|
|
|
if w := e.matchBannedWord("this is spam content"); w != "spam" {
|
|
t.Errorf("expected 'spam', got %q", w)
|
|
}
|
|
if w := e.matchBannedWord("CONTAINS BADWORD HERE"); w != "badword" {
|
|
t.Errorf("expected case-insensitive match 'badword', got %q", w)
|
|
}
|
|
if w := e.matchBannedWord("clean message"); w != "" {
|
|
t.Errorf("expected empty, got %q", w)
|
|
}
|
|
}
|
|
|
|
func TestEngine_BannedWordsEmpty(t *testing.T) {
|
|
e := newTestEngine()
|
|
if w := e.matchBannedWord("anything"); w != "" {
|
|
t.Errorf("no banned words set, should return empty, got %q", w)
|
|
}
|
|
}
|
|
|
|
// --- disabled commands tests ---
|
|
|
|
func TestEngine_DisabledCommands(t *testing.T) {
|
|
e := newTestEngine()
|
|
e.SetDisabledCommands([]string{"upgrade", "restart"})
|
|
|
|
if !e.disabledCmds["upgrade"] {
|
|
t.Error("upgrade should be disabled")
|
|
}
|
|
if !e.disabledCmds["restart"] {
|
|
t.Error("restart should be disabled")
|
|
}
|
|
if e.disabledCmds["help"] {
|
|
t.Error("help should not be disabled")
|
|
}
|
|
}
|
|
|
|
func TestEngine_DisabledCommandsWithSlash(t *testing.T) {
|
|
e := newTestEngine()
|
|
e.SetDisabledCommands([]string{"/upgrade"})
|
|
|
|
if !e.disabledCmds["upgrade"] {
|
|
t.Error("upgrade should be disabled even when prefixed with /")
|
|
}
|
|
}
|
|
|
|
// --- admin_from tests ---
|
|
|
|
func TestEngine_AdminFrom_DenyByDefault(t *testing.T) {
|
|
e := newTestEngine()
|
|
p := &stubPlatformEngine{n: "test"}
|
|
|
|
msg := &Message{SessionKey: "test:u1", UserID: "user1", ReplyCtx: "ctx"}
|
|
e.handleCommand(p, msg, "/shell echo hi")
|
|
|
|
if len(p.sent) != 1 {
|
|
t.Fatalf("expected 1 reply, got %d", len(p.sent))
|
|
}
|
|
if !strings.Contains(p.sent[0], "admin") {
|
|
t.Errorf("expected admin required message, got: %s", p.sent[0])
|
|
}
|
|
}
|
|
|
|
func TestEngine_AdminFrom_ExplicitUser(t *testing.T) {
|
|
e := newTestEngine()
|
|
e.SetAdminFrom("admin1,admin2")
|
|
p := &stubPlatformEngine{n: "test"}
|
|
|
|
if !e.isAdmin("admin1") {
|
|
t.Error("admin1 should be admin")
|
|
}
|
|
if !e.isAdmin("admin2") {
|
|
t.Error("admin2 should be admin")
|
|
}
|
|
if e.isAdmin("user3") {
|
|
t.Error("user3 should not be admin")
|
|
}
|
|
|
|
// non-admin user tries /shell
|
|
msg := &Message{SessionKey: "test:u3", UserID: "user3", ReplyCtx: "ctx"}
|
|
e.handleCommand(p, msg, "/shell echo hi")
|
|
if len(p.sent) != 1 || !strings.Contains(p.sent[0], "admin") {
|
|
t.Errorf("non-admin should be blocked from /shell, got: %v", p.sent)
|
|
}
|
|
}
|
|
|
|
func TestEngine_AdminFrom_Wildcard(t *testing.T) {
|
|
e := newTestEngine()
|
|
e.SetAdminFrom("*")
|
|
|
|
if !e.isAdmin("anyone") {
|
|
t.Error("wildcard admin_from should allow any user")
|
|
}
|
|
if !e.isAdmin("12345") {
|
|
t.Error("wildcard admin_from should allow any user ID")
|
|
}
|
|
}
|
|
|
|
func TestEngine_AdminFrom_GatesRestart(t *testing.T) {
|
|
e := newTestEngine()
|
|
p := &stubPlatformEngine{n: "test"}
|
|
|
|
msg := &Message{SessionKey: "test:u1", UserID: "user1", ReplyCtx: "ctx"}
|
|
e.handleCommand(p, msg, "/restart")
|
|
|
|
if len(p.sent) != 1 || !strings.Contains(p.sent[0], "admin") {
|
|
t.Errorf("non-admin should be blocked from /restart, got: %v", p.sent)
|
|
}
|
|
}
|
|
|
|
func TestEngine_AdminFrom_GatesUpgrade(t *testing.T) {
|
|
e := newTestEngine()
|
|
p := &stubPlatformEngine{n: "test"}
|
|
|
|
msg := &Message{SessionKey: "test:u1", UserID: "user1", ReplyCtx: "ctx"}
|
|
e.handleCommand(p, msg, "/upgrade")
|
|
|
|
if len(p.sent) != 1 || !strings.Contains(p.sent[0], "admin") {
|
|
t.Errorf("non-admin should be blocked from /upgrade, got: %v", p.sent)
|
|
}
|
|
}
|
|
|
|
func TestEngine_AdminFrom_AllowsNonPrivileged(t *testing.T) {
|
|
e := newTestEngine()
|
|
p := &stubPlatformEngine{n: "test"}
|
|
|
|
msg := &Message{SessionKey: "test:u1", UserID: "user1", ReplyCtx: "ctx"}
|
|
e.handleCommand(p, msg, "/help")
|
|
|
|
if len(p.sent) == 0 {
|
|
t.Fatal("expected /help to produce a reply")
|
|
}
|
|
if strings.Contains(p.sent[0], "admin") {
|
|
t.Errorf("/help should not require admin, got: %s", p.sent[0])
|
|
}
|
|
}
|
|
|
|
func TestEngine_AdminFrom_GatesCommandsAddExec(t *testing.T) {
|
|
e := newTestEngine()
|
|
p := &stubPlatformEngine{n: "test"}
|
|
|
|
msg := &Message{SessionKey: "test:u1", UserID: "user1", ReplyCtx: "ctx"}
|
|
e.handleCommand(p, msg, "/commands addexec mysh echo hello")
|
|
|
|
if len(p.sent) != 1 || !strings.Contains(p.sent[0], "admin") {
|
|
t.Errorf("non-admin should be blocked from /commands addexec, got: %v", p.sent)
|
|
}
|
|
}
|
|
|
|
func TestEngine_AdminFrom_GatesCustomExecCommand(t *testing.T) {
|
|
e := newTestEngine()
|
|
e.commands.Add("deploy", "", "", "echo deploying", "", "config")
|
|
p := &stubPlatformEngine{n: "test"}
|
|
|
|
msg := &Message{SessionKey: "test:u1", UserID: "user1", ReplyCtx: "ctx"}
|
|
e.handleCommand(p, msg, "/deploy")
|
|
|
|
if len(p.sent) != 1 || !strings.Contains(p.sent[0], "admin") {
|
|
t.Errorf("non-admin should be blocked from custom exec command, got: %v", p.sent)
|
|
}
|
|
}
|
|
|
|
func TestEngine_AdminFrom_AdminCanRunShell(t *testing.T) {
|
|
e := newTestEngine()
|
|
e.SetAdminFrom("admin1")
|
|
p := &stubPlatformEngine{n: "test"}
|
|
|
|
msg := &Message{SessionKey: "test:a1", UserID: "admin1", ReplyCtx: "ctx"}
|
|
e.handleCommand(p, msg, "/shell echo hello")
|
|
|
|
// Shell runs async in a goroutine, so the command should be accepted (not blocked).
|
|
// No "admin" error should be in replies.
|
|
for _, s := range p.sent {
|
|
if strings.Contains(s, "admin") {
|
|
t.Errorf("admin user should not be blocked, got: %s", s)
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- permission prompt card tests ---
|
|
|
|
func TestSendPermissionPrompt_CardPlatform(t *testing.T) {
|
|
e := newTestEngine()
|
|
p := &stubCardPlatform{stubPlatformEngine: stubPlatformEngine{n: "feishu"}}
|
|
|
|
e.sendPermissionPrompt(p, "ctx", "full prompt text", "write_file", "/tmp/test.txt")
|
|
|
|
if len(p.sentCards) != 1 {
|
|
t.Fatalf("expected 1 sent card, got %d", len(p.sentCards))
|
|
}
|
|
card := p.sentCards[0]
|
|
if card.Header == nil || card.Header.Color != "orange" {
|
|
t.Errorf("expected orange header, got %+v", card.Header)
|
|
}
|
|
if !card.HasButtons() {
|
|
t.Error("expected card to have buttons")
|
|
}
|
|
buttons := card.CollectButtons()
|
|
if len(buttons) < 2 {
|
|
t.Fatalf("expected at least 2 button rows, got %d", len(buttons))
|
|
}
|
|
if buttons[0][0].Data != "perm:allow" {
|
|
t.Errorf("expected first button data=perm:allow, got %s", buttons[0][0].Data)
|
|
}
|
|
if buttons[0][1].Data != "perm:deny" {
|
|
t.Errorf("expected second button data=perm:deny, got %s", buttons[0][1].Data)
|
|
}
|
|
if buttons[1][0].Data != "perm:allow_all" {
|
|
t.Errorf("expected third button data=perm:allow_all, got %s", buttons[1][0].Data)
|
|
}
|
|
if len(p.sent) != 0 {
|
|
t.Errorf("plain text should not be sent when card is used, got %v", p.sent)
|
|
}
|
|
|
|
// Verify Extra fields carry i18n labels and body for card callback updates
|
|
var allowBtn, denyBtn CardButton
|
|
for _, elem := range card.Elements {
|
|
if actions, ok := elem.(CardActions); ok {
|
|
for _, btn := range actions.Buttons {
|
|
switch btn.Value {
|
|
case "perm:allow":
|
|
allowBtn = btn
|
|
case "perm:deny":
|
|
denyBtn = btn
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if allowBtn.Extra == nil {
|
|
t.Fatal("allow button should have Extra map")
|
|
}
|
|
if allowBtn.Extra["perm_color"] != "green" {
|
|
t.Errorf("allow button perm_color should be green, got %s", allowBtn.Extra["perm_color"])
|
|
}
|
|
if allowBtn.Extra["perm_body"] == "" {
|
|
t.Error("allow button perm_body should not be empty")
|
|
}
|
|
if !strings.Contains(allowBtn.Extra["perm_label"], "Allow") {
|
|
t.Errorf("allow button perm_label should contain 'Allow', got %s", allowBtn.Extra["perm_label"])
|
|
}
|
|
if denyBtn.Extra["perm_color"] != "red" {
|
|
t.Errorf("deny button perm_color should be red, got %s", denyBtn.Extra["perm_color"])
|
|
}
|
|
}
|
|
|
|
func TestSendPermissionPrompt_InlineButtonPlatform(t *testing.T) {
|
|
e := newTestEngine()
|
|
p := &stubInlineButtonPlatform{stubPlatformEngine: stubPlatformEngine{n: "telegram"}}
|
|
|
|
e.sendPermissionPrompt(p, "ctx", "full prompt text", "write_file", "/tmp/test.txt")
|
|
|
|
if p.buttonContent != "full prompt text" {
|
|
t.Errorf("expected button content to be prompt, got %s", p.buttonContent)
|
|
}
|
|
if len(p.buttonRows) < 2 {
|
|
t.Fatalf("expected at least 2 button rows, got %d", len(p.buttonRows))
|
|
}
|
|
if p.buttonRows[0][0].Data != "perm:allow" {
|
|
t.Errorf("expected perm:allow, got %s", p.buttonRows[0][0].Data)
|
|
}
|
|
}
|
|
|
|
func TestSendPermissionPrompt_PlainPlatform(t *testing.T) {
|
|
e := newTestEngine()
|
|
p := &stubPlatformEngine{n: "plain"}
|
|
|
|
e.sendPermissionPrompt(p, "ctx", "full prompt text", "write_file", "/tmp/test.txt")
|
|
|
|
if len(p.sent) != 1 || p.sent[0] != "full prompt text" {
|
|
t.Errorf("expected plain text fallback, got %v", p.sent)
|
|
}
|
|
}
|
|
|
|
func TestCmdList_MultiWorkspaceUsesWorkspaceSessions(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "test"}
|
|
globalAgent := &stubListAgent{
|
|
sessions: []AgentSessionInfo{
|
|
{ID: "g1", Summary: "Global One", MessageCount: 1},
|
|
},
|
|
}
|
|
e := NewEngine("test", globalAgent, []Platform{p}, "", LangEnglish)
|
|
|
|
baseDir := t.TempDir()
|
|
bindingPath := filepath.Join(t.TempDir(), "bindings.json")
|
|
e.SetMultiWorkspace(baseDir, bindingPath)
|
|
|
|
wsDir := filepath.Join(baseDir, "ws1")
|
|
if err := os.MkdirAll(wsDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
channelID := "C123"
|
|
e.workspaceBindings.Bind("project:test", channelID, "chan", wsDir)
|
|
|
|
ws := e.workspacePool.GetOrCreate(wsDir)
|
|
ws.agent = &stubListAgent{
|
|
sessions: []AgentSessionInfo{
|
|
{ID: "w1", Summary: "Workspace One", MessageCount: 2},
|
|
},
|
|
}
|
|
ws.sessions = NewSessionManager("")
|
|
|
|
msg := &Message{SessionKey: "slack:" + channelID + ":U1", ReplyCtx: "ctx"}
|
|
e.cmdList(p, msg, nil)
|
|
|
|
if len(p.sent) == 0 {
|
|
t.Fatal("expected /list to send a response")
|
|
}
|
|
if strings.Contains(p.sent[0], "Global One") {
|
|
t.Fatalf("expected workspace sessions, got global list: %q", p.sent[0])
|
|
}
|
|
if !strings.Contains(p.sent[0], "Workspace One") {
|
|
t.Fatalf("expected workspace list to contain session summary, got %q", p.sent[0])
|
|
}
|
|
}
|
|
|
|
func TestHandlePendingPermission_MultiWorkspaceLookup(t *testing.T) {
|
|
e := newTestEngine()
|
|
e.multiWorkspace = true
|
|
|
|
sessionKey := "slack:C123:U1"
|
|
interactiveKey := "/tmp/ws:" + sessionKey
|
|
|
|
pending := &pendingPermission{
|
|
RequestID: "req-1",
|
|
ToolInput: map[string]any{"path": "/tmp/x"},
|
|
Resolved: make(chan struct{}),
|
|
}
|
|
session := &recordingAgentSession{}
|
|
|
|
e.interactiveMu.Lock()
|
|
e.interactiveStates[interactiveKey] = &interactiveState{
|
|
agentSession: session,
|
|
pending: pending,
|
|
}
|
|
e.interactiveMu.Unlock()
|
|
|
|
p := &stubPlatformEngine{n: "test"}
|
|
msg := &Message{SessionKey: sessionKey, ReplyCtx: "ctx"}
|
|
|
|
if !e.handlePendingPermission(p, msg, "allow") {
|
|
t.Fatal("expected pending permission to be handled")
|
|
}
|
|
|
|
e.interactiveMu.Lock()
|
|
state := e.interactiveStates[interactiveKey]
|
|
e.interactiveMu.Unlock()
|
|
if state == nil {
|
|
t.Fatal("expected interactive state to remain")
|
|
}
|
|
state.mu.Lock()
|
|
hasPending := state.pending != nil
|
|
state.mu.Unlock()
|
|
if hasPending {
|
|
t.Fatal("expected pending permission to be cleared")
|
|
}
|
|
|
|
select {
|
|
case <-pending.Resolved:
|
|
default:
|
|
t.Fatal("expected pending permission to be resolved")
|
|
}
|
|
|
|
if session.calls != 1 {
|
|
t.Fatalf("RespondPermission calls = %d, want 1", session.calls)
|
|
}
|
|
if session.lastID != "req-1" {
|
|
t.Fatalf("RespondPermission id = %q, want %q", session.lastID, "req-1")
|
|
}
|
|
if session.lastResult.Behavior != "allow" {
|
|
t.Fatalf("RespondPermission behavior = %q, want %q", session.lastResult.Behavior, "allow")
|
|
}
|
|
}
|
|
|
|
// --- quiet tests ---
|
|
|
|
func TestQuietSessionToggle(t *testing.T) {
|
|
e := newTestEngine()
|
|
p := &stubPlatformEngine{n: "test"}
|
|
msg := &Message{SessionKey: "test:user1", ReplyCtx: "ctx"}
|
|
|
|
// /quiet — per-session toggle on
|
|
e.cmdQuiet(p, msg, nil)
|
|
|
|
e.interactiveMu.Lock()
|
|
state := e.interactiveStates["test:user1"]
|
|
e.interactiveMu.Unlock()
|
|
|
|
if state == nil {
|
|
t.Fatal("expected interactiveState to be created")
|
|
}
|
|
state.mu.Lock()
|
|
q := state.quiet
|
|
state.mu.Unlock()
|
|
if !q {
|
|
t.Fatal("expected session quiet to be true")
|
|
}
|
|
|
|
// /quiet — per-session toggle off
|
|
e.cmdQuiet(p, msg, nil)
|
|
state.mu.Lock()
|
|
q = state.quiet
|
|
state.mu.Unlock()
|
|
if q {
|
|
t.Fatal("expected session quiet to be false after second toggle")
|
|
}
|
|
}
|
|
|
|
func TestQuietSessionResetsOnNewSession(t *testing.T) {
|
|
e := newTestEngine()
|
|
p := &stubPlatformEngine{n: "test"}
|
|
msg := &Message{SessionKey: "test:user1", ReplyCtx: "ctx"}
|
|
|
|
// Enable per-session quiet
|
|
e.cmdQuiet(p, msg, nil)
|
|
|
|
// Simulate /new
|
|
e.cleanupInteractiveState("test:user1")
|
|
|
|
// State should be gone, quiet resets
|
|
e.interactiveMu.Lock()
|
|
state := e.interactiveStates["test:user1"]
|
|
e.interactiveMu.Unlock()
|
|
if state != nil {
|
|
t.Fatal("expected interactiveState to be cleaned up")
|
|
}
|
|
|
|
// Global quiet should still be off
|
|
e.quietMu.RLock()
|
|
gq := e.quiet
|
|
e.quietMu.RUnlock()
|
|
if gq {
|
|
t.Fatal("expected global quiet to be false")
|
|
}
|
|
}
|
|
|
|
func TestQuietGlobalToggle(t *testing.T) {
|
|
e := newTestEngine()
|
|
p := &stubPlatformEngine{n: "test"}
|
|
msg := &Message{SessionKey: "test:user1", ReplyCtx: "ctx"}
|
|
|
|
// Default: global quiet is off
|
|
if e.quiet {
|
|
t.Fatal("expected global quiet to be false by default")
|
|
}
|
|
|
|
// /quiet global — toggle on
|
|
e.cmdQuiet(p, msg, []string{"global"})
|
|
e.quietMu.RLock()
|
|
q := e.quiet
|
|
e.quietMu.RUnlock()
|
|
if !q {
|
|
t.Fatal("expected global quiet to be true")
|
|
}
|
|
|
|
// /quiet global — toggle off
|
|
e.cmdQuiet(p, msg, []string{"global"})
|
|
e.quietMu.RLock()
|
|
q = e.quiet
|
|
e.quietMu.RUnlock()
|
|
if q {
|
|
t.Fatal("expected global quiet to be false after second toggle")
|
|
}
|
|
}
|
|
|
|
func TestQuietGlobalPersistsAcrossSessions(t *testing.T) {
|
|
e := newTestEngine()
|
|
p := &stubPlatformEngine{n: "test"}
|
|
msg := &Message{SessionKey: "test:user1", ReplyCtx: "ctx"}
|
|
|
|
// Enable global quiet
|
|
e.cmdQuiet(p, msg, []string{"global"})
|
|
|
|
// Simulate /new
|
|
e.cleanupInteractiveState("test:user1")
|
|
|
|
// Global quiet should still be on
|
|
e.quietMu.RLock()
|
|
q := e.quiet
|
|
e.quietMu.RUnlock()
|
|
if !q {
|
|
t.Fatal("expected global quiet to remain true after session cleanup")
|
|
}
|
|
}
|
|
|
|
func TestQuietGlobalAndSessionCombined(t *testing.T) {
|
|
e := newTestEngine()
|
|
p := &stubPlatformEngine{n: "test"}
|
|
msg := &Message{SessionKey: "test:user1", ReplyCtx: "ctx"}
|
|
|
|
// Only global quiet on — should suppress
|
|
e.cmdQuiet(p, msg, []string{"global"})
|
|
e.quietMu.RLock()
|
|
gq := e.quiet
|
|
e.quietMu.RUnlock()
|
|
if !gq {
|
|
t.Fatal("expected global quiet on")
|
|
}
|
|
|
|
// Session quiet is off (no state yet) — global alone should be enough
|
|
e.interactiveMu.Lock()
|
|
state := e.interactiveStates["test:user1"]
|
|
e.interactiveMu.Unlock()
|
|
if state != nil {
|
|
t.Fatal("expected no session state yet")
|
|
}
|
|
|
|
// Turn off global, turn on session
|
|
e.cmdQuiet(p, msg, []string{"global"}) // global off
|
|
e.cmdQuiet(p, msg, nil) // session on
|
|
|
|
e.quietMu.RLock()
|
|
gq = e.quiet
|
|
e.quietMu.RUnlock()
|
|
if gq {
|
|
t.Fatal("expected global quiet off")
|
|
}
|
|
|
|
e.interactiveMu.Lock()
|
|
state = e.interactiveStates["test:user1"]
|
|
e.interactiveMu.Unlock()
|
|
state.mu.Lock()
|
|
sq := state.quiet
|
|
state.mu.Unlock()
|
|
if !sq {
|
|
t.Fatal("expected session quiet on")
|
|
}
|
|
}
|
|
|
|
func TestReplyWithCard_FallsBackToTextWhenPlatformHasNoCardSupport(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "plain"}
|
|
e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish)
|
|
card := NewCard().Title("Help", "blue").Markdown("Plain fallback").Build()
|
|
|
|
e.replyWithCard(p, "ctx", card)
|
|
|
|
if len(p.sent) != 1 {
|
|
t.Fatalf("sent messages = %d, want 1", len(p.sent))
|
|
}
|
|
if got, want := p.sent[0], card.RenderText(); got != want {
|
|
t.Fatalf("fallback text = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestReplyWithCard_UsesCardSenderWhenSupported(t *testing.T) {
|
|
p := &stubCardPlatform{stubPlatformEngine: stubPlatformEngine{n: "card"}}
|
|
e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish)
|
|
card := NewCard().Markdown("Interactive").Build()
|
|
|
|
e.replyWithCard(p, "ctx", card)
|
|
|
|
if len(p.repliedCards) != 1 {
|
|
t.Fatalf("replied cards = %d, want 1", len(p.repliedCards))
|
|
}
|
|
if len(p.sent) != 0 {
|
|
t.Fatalf("plain replies = %d, want 0", len(p.sent))
|
|
}
|
|
}
|
|
|
|
func TestCmdHelp_UsesLegacyTextOnPlatformWithoutCardSupport(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "plain"}
|
|
e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangChinese)
|
|
msg := &Message{SessionKey: "test:user1", ReplyCtx: "ctx"}
|
|
|
|
e.cmdHelp(p, msg)
|
|
|
|
if len(p.sent) != 1 {
|
|
t.Fatalf("sent messages = %d, want 1", len(p.sent))
|
|
}
|
|
if got := p.sent[0]; got != e.i18n.T(MsgHelp) {
|
|
t.Fatalf("help text = %q, want legacy help text", got)
|
|
}
|
|
if strings.Contains(p.sent[0], "cc-connect 帮助") {
|
|
t.Fatalf("help text = %q, should not be card title fallback", p.sent[0])
|
|
}
|
|
}
|
|
|
|
func TestCmdList_UsesLegacyTextOnPlatformWithoutCardSupport(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "plain"}
|
|
sessions := []AgentSessionInfo{{ID: "session-a", Summary: "First session", MessageCount: 3, ModifiedAt: time.Date(2026, 3, 11, 2, 0, 0, 0, time.UTC)}}
|
|
e := NewEngine("test", &stubListAgent{sessions: sessions}, []Platform{p}, "", LangEnglish)
|
|
msg := &Message{SessionKey: "test:user1", ReplyCtx: "ctx"}
|
|
|
|
e.cmdList(p, msg, nil)
|
|
|
|
if len(p.sent) != 1 {
|
|
t.Fatalf("sent messages = %d, want 1", len(p.sent))
|
|
}
|
|
if !strings.Contains(p.sent[0], "Sessions") {
|
|
t.Fatalf("list text = %q, want legacy list title", p.sent[0])
|
|
}
|
|
if strings.Contains(p.sent[0], "[← 返回]") {
|
|
t.Fatalf("list text = %q, should not be card fallback text", p.sent[0])
|
|
}
|
|
}
|
|
|
|
func TestCmdCurrent_UsesLegacyTextOnPlatformWithoutCardSupport(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "plain"}
|
|
e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish)
|
|
msg := &Message{SessionKey: "test:user1", ReplyCtx: "ctx"}
|
|
session := e.sessions.GetOrCreateActive(msg.SessionKey)
|
|
session.Name = "Focus"
|
|
session.AgentSessionID = "session-123"
|
|
session.History = append(session.History, HistoryEntry{Role: "user", Content: "hello", Timestamp: time.Now()})
|
|
|
|
e.cmdCurrent(p, msg)
|
|
|
|
if len(p.sent) != 1 {
|
|
t.Fatalf("sent messages = %d, want 1", len(p.sent))
|
|
}
|
|
if !strings.Contains(p.sent[0], "Current session") {
|
|
t.Fatalf("current text = %q, want legacy current session text", p.sent[0])
|
|
}
|
|
if strings.Contains(p.sent[0], "cc-connect") {
|
|
t.Fatalf("current text = %q, should not be card fallback title", p.sent[0])
|
|
}
|
|
}
|
|
|
|
func TestCmdDelete_BatchCommaList(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "plain"}
|
|
agent := &stubDeleteAgent{stubListAgent: stubListAgent{sessions: []AgentSessionInfo{
|
|
{ID: "session-1", Summary: "One"},
|
|
{ID: "session-2", Summary: "Two"},
|
|
{ID: "session-3", Summary: "Three"},
|
|
{ID: "session-4", Summary: "Four"},
|
|
}}}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
msg := &Message{SessionKey: "test:user1", ReplyCtx: "ctx"}
|
|
|
|
e.cmdDelete(p, msg, []string{"1,2,3"})
|
|
|
|
if got, want := strings.Join(agent.deleted, ","), "session-1,session-2,session-3"; got != want {
|
|
t.Fatalf("deleted = %q, want %q", got, want)
|
|
}
|
|
if len(p.sent) != 1 {
|
|
t.Fatalf("sent messages = %d, want 1", len(p.sent))
|
|
}
|
|
if !strings.Contains(p.sent[0], "Session deleted: One") || !strings.Contains(p.sent[0], "Session deleted: Three") {
|
|
t.Fatalf("reply = %q, want combined delete summary", p.sent[0])
|
|
}
|
|
}
|
|
|
|
func TestCmdDelete_BatchRange(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "plain"}
|
|
agent := &stubDeleteAgent{stubListAgent: stubListAgent{sessions: []AgentSessionInfo{
|
|
{ID: "session-1", Summary: "One"},
|
|
{ID: "session-2", Summary: "Two"},
|
|
{ID: "session-3", Summary: "Three"},
|
|
{ID: "session-4", Summary: "Four"},
|
|
{ID: "session-5", Summary: "Five"},
|
|
{ID: "session-6", Summary: "Six"},
|
|
{ID: "session-7", Summary: "Seven"},
|
|
{ID: "session-8", Summary: "Eight"},
|
|
}}}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
msg := &Message{SessionKey: "test:user1", ReplyCtx: "ctx"}
|
|
|
|
e.cmdDelete(p, msg, []string{"3-7"})
|
|
|
|
if got, want := strings.Join(agent.deleted, ","), "session-3,session-4,session-5,session-6,session-7"; got != want {
|
|
t.Fatalf("deleted = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestCmdDelete_BatchMixedSyntax(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "plain"}
|
|
agent := &stubDeleteAgent{stubListAgent: stubListAgent{sessions: []AgentSessionInfo{
|
|
{ID: "session-1", Summary: "One"},
|
|
{ID: "session-2", Summary: "Two"},
|
|
{ID: "session-3", Summary: "Three"},
|
|
{ID: "session-4", Summary: "Four"},
|
|
{ID: "session-5", Summary: "Five"},
|
|
{ID: "session-6", Summary: "Six"},
|
|
{ID: "session-7", Summary: "Seven"},
|
|
{ID: "session-8", Summary: "Eight"},
|
|
}}}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
msg := &Message{SessionKey: "test:user1", ReplyCtx: "ctx"}
|
|
|
|
e.cmdDelete(p, msg, []string{"1,3-5,8"})
|
|
|
|
if got, want := strings.Join(agent.deleted, ","), "session-1,session-3,session-4,session-5,session-8"; got != want {
|
|
t.Fatalf("deleted = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestCmdDelete_InvalidExplicitBatchSyntaxShowsUsage(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "plain"}
|
|
agent := &stubDeleteAgent{stubListAgent: stubListAgent{sessions: []AgentSessionInfo{
|
|
{ID: "session-1", Summary: "One"},
|
|
{ID: "session-2", Summary: "Two"},
|
|
{ID: "session-3", Summary: "Three"},
|
|
}}}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
msg := &Message{SessionKey: "test:user1", ReplyCtx: "ctx"}
|
|
|
|
e.cmdDelete(p, msg, []string{"1,3-a,8"})
|
|
|
|
if len(agent.deleted) != 0 {
|
|
t.Fatalf("deleted = %v, want none", agent.deleted)
|
|
}
|
|
if len(p.sent) != 1 || p.sent[0] != e.i18n.T(MsgDeleteUsage) {
|
|
t.Fatalf("sent = %v, want usage", p.sent)
|
|
}
|
|
}
|
|
|
|
func TestCmdDelete_WhitespaceSeparatedArgsAreRejected(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "plain"}
|
|
agent := &stubDeleteAgent{stubListAgent: stubListAgent{sessions: []AgentSessionInfo{
|
|
{ID: "session-1", Summary: "One"},
|
|
{ID: "session-2", Summary: "Two"},
|
|
{ID: "session-3", Summary: "Three"},
|
|
}}}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
msg := &Message{SessionKey: "test:user1", ReplyCtx: "ctx"}
|
|
|
|
e.cmdDelete(p, msg, []string{"1", "2", "3"})
|
|
|
|
if len(agent.deleted) != 0 {
|
|
t.Fatalf("deleted = %v, want none", agent.deleted)
|
|
}
|
|
if len(p.sent) != 1 || p.sent[0] != e.i18n.T(MsgDeleteUsage) {
|
|
t.Fatalf("sent = %v, want usage", p.sent)
|
|
}
|
|
}
|
|
|
|
func TestCmdDelete_SingleSessionPrefixStillWorks(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "plain"}
|
|
agent := &stubDeleteAgent{stubListAgent: stubListAgent{sessions: []AgentSessionInfo{
|
|
{ID: "abc123456789", Summary: "One"},
|
|
{ID: "def987654321", Summary: "Two"},
|
|
}}}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
msg := &Message{SessionKey: "test:user1", ReplyCtx: "ctx"}
|
|
|
|
e.cmdDelete(p, msg, []string{"abc123"})
|
|
|
|
if got, want := strings.Join(agent.deleted, ","), "abc123456789"; got != want {
|
|
t.Fatalf("deleted = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestCmdDelete_NoArgsOnCardPlatformShowsDeleteModeCard(t *testing.T) {
|
|
p := &stubCardPlatform{stubPlatformEngine: stubPlatformEngine{n: "feishu"}}
|
|
agent := &stubDeleteAgent{stubListAgent: stubListAgent{sessions: []AgentSessionInfo{
|
|
{ID: "session-1", Summary: "One"},
|
|
{ID: "session-2", Summary: "Two"},
|
|
}}}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
msg := &Message{SessionKey: "feishu:user1", ReplyCtx: "ctx"}
|
|
|
|
e.cmdDelete(p, msg, nil)
|
|
|
|
if len(p.repliedCards) != 1 {
|
|
t.Fatalf("replied cards = %d, want 1", len(p.repliedCards))
|
|
}
|
|
card := p.repliedCards[0]
|
|
if got := countCardActionValues(card, "act:/delete-mode toggle "); got != 2 {
|
|
t.Fatalf("toggle action count = %d, want 2", got)
|
|
}
|
|
if _, ok := findCardAction(card, "act:/delete-mode cancel"); !ok {
|
|
t.Fatal("expected delete mode cancel action")
|
|
}
|
|
}
|
|
|
|
func TestDeleteMode_ToggleSelectionUpdatesCard(t *testing.T) {
|
|
p := &stubCardPlatform{stubPlatformEngine: stubPlatformEngine{n: "feishu"}}
|
|
agent := &stubDeleteAgent{stubListAgent: stubListAgent{sessions: []AgentSessionInfo{
|
|
{ID: "session-1", Summary: "One"},
|
|
{ID: "session-2", Summary: "Two"},
|
|
}}}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
msg := &Message{SessionKey: "feishu:user1", ReplyCtx: "ctx"}
|
|
|
|
e.cmdDelete(p, msg, nil)
|
|
card := e.handleCardNav("act:/delete-mode toggle session-2", msg.SessionKey)
|
|
if card == nil {
|
|
t.Fatal("expected delete mode card after toggle")
|
|
}
|
|
|
|
btn, ok := findCardAction(card, "act:/delete-mode toggle session-2")
|
|
if !ok {
|
|
t.Fatal("expected toggle action for selected session")
|
|
}
|
|
if btn.Type != "primary" {
|
|
t.Fatalf("selected toggle type = %q, want primary", btn.Type)
|
|
}
|
|
if !strings.Contains(card.RenderText(), "1 selected") {
|
|
t.Fatalf("card text = %q, want selected count", card.RenderText())
|
|
}
|
|
}
|
|
|
|
func TestDeleteMode_ConfirmAndSubmitDeletesSelectedSessions(t *testing.T) {
|
|
p := &stubCardPlatform{stubPlatformEngine: stubPlatformEngine{n: "feishu"}}
|
|
agent := &stubDeleteAgent{stubListAgent: stubListAgent{sessions: []AgentSessionInfo{
|
|
{ID: "session-1", Summary: "One"},
|
|
{ID: "session-2", Summary: "Two"},
|
|
{ID: "session-3", Summary: "Three"},
|
|
}}}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
msg := &Message{SessionKey: "feishu:user1", ReplyCtx: "ctx"}
|
|
|
|
e.cmdDelete(p, msg, nil)
|
|
_ = e.handleCardNav("act:/delete-mode toggle session-1", msg.SessionKey)
|
|
_ = e.handleCardNav("act:/delete-mode toggle session-3", msg.SessionKey)
|
|
|
|
confirmCard := e.handleCardNav("act:/delete-mode confirm", msg.SessionKey)
|
|
if confirmCard == nil {
|
|
t.Fatal("expected confirmation card")
|
|
}
|
|
confirmText := confirmCard.RenderText()
|
|
if !strings.Contains(confirmText, "One") || !strings.Contains(confirmText, "Three") {
|
|
t.Fatalf("confirmation text = %q, want selected session names", confirmText)
|
|
}
|
|
|
|
resultCard := e.handleCardNav("act:/delete-mode submit", msg.SessionKey)
|
|
if resultCard == nil {
|
|
t.Fatal("expected result card after submit")
|
|
}
|
|
if got, want := strings.Join(agent.deleted, ","), "session-1,session-3"; got != want {
|
|
t.Fatalf("deleted = %q, want %q", got, want)
|
|
}
|
|
if !strings.Contains(resultCard.RenderText(), "Session deleted: One") {
|
|
t.Fatalf("result text = %q, want delete result", resultCard.RenderText())
|
|
}
|
|
}
|
|
|
|
func TestDeleteMode_SubmitReportsMissingSelectedSessions(t *testing.T) {
|
|
p := &stubCardPlatform{stubPlatformEngine: stubPlatformEngine{n: "feishu"}}
|
|
agent := &stubDeleteAgent{stubListAgent: stubListAgent{sessions: []AgentSessionInfo{
|
|
{ID: "session-1", Summary: "One"},
|
|
{ID: "session-2", Summary: "Two"},
|
|
{ID: "session-3", Summary: "Three"},
|
|
}}}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
msg := &Message{SessionKey: "feishu:user1", ReplyCtx: "ctx"}
|
|
|
|
e.cmdDelete(p, msg, nil)
|
|
_ = e.handleCardNav("act:/delete-mode toggle session-1", msg.SessionKey)
|
|
_ = e.handleCardNav("act:/delete-mode toggle session-3", msg.SessionKey)
|
|
|
|
agent.sessions = []AgentSessionInfo{
|
|
{ID: "session-1", Summary: "One"},
|
|
{ID: "session-2", Summary: "Two"},
|
|
}
|
|
|
|
resultCard := e.handleCardNav("act:/delete-mode submit", msg.SessionKey)
|
|
if resultCard == nil {
|
|
t.Fatal("expected result card after submit")
|
|
}
|
|
resultText := resultCard.RenderText()
|
|
if !strings.Contains(resultText, "Session deleted: One") {
|
|
t.Fatalf("result text = %q, want deleted session line", resultText)
|
|
}
|
|
if !strings.Contains(resultText, "Missing selected session") || !strings.Contains(resultText, "session-3") {
|
|
t.Fatalf("result text = %q, want missing selected session to be reported", resultText)
|
|
}
|
|
}
|
|
|
|
func TestDeleteMode_CancelReturnsListCard(t *testing.T) {
|
|
p := &stubCardPlatform{stubPlatformEngine: stubPlatformEngine{n: "feishu"}}
|
|
agent := &stubDeleteAgent{stubListAgent: stubListAgent{sessions: []AgentSessionInfo{
|
|
{ID: "session-1", Summary: "One"},
|
|
{ID: "session-2", Summary: "Two"},
|
|
}}}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
msg := &Message{SessionKey: "feishu:user1", ReplyCtx: "ctx"}
|
|
|
|
e.cmdDelete(p, msg, nil)
|
|
card := e.handleCardNav("act:/delete-mode cancel", msg.SessionKey)
|
|
if card == nil {
|
|
t.Fatal("expected list card after cancel")
|
|
}
|
|
if got := countCardActionValues(card, "act:/switch "); got != 2 {
|
|
t.Fatalf("switch action count = %d, want 2", got)
|
|
}
|
|
}
|
|
|
|
func TestDeleteMode_ConfirmWithoutSelectionShowsHint(t *testing.T) {
|
|
p := &stubCardPlatform{stubPlatformEngine: stubPlatformEngine{n: "feishu"}}
|
|
agent := &stubDeleteAgent{stubListAgent: stubListAgent{sessions: []AgentSessionInfo{
|
|
{ID: "session-1", Summary: "One"},
|
|
{ID: "session-2", Summary: "Two"},
|
|
}}}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
msg := &Message{SessionKey: "feishu:user1", ReplyCtx: "ctx"}
|
|
|
|
e.cmdDelete(p, msg, nil)
|
|
card := e.handleCardNav("act:/delete-mode confirm", msg.SessionKey)
|
|
if card == nil {
|
|
t.Fatal("expected delete mode card when confirming empty selection")
|
|
}
|
|
if !strings.Contains(card.RenderText(), "Select at least one session.") {
|
|
t.Fatalf("card text = %q, want empty-selection hint", card.RenderText())
|
|
}
|
|
}
|
|
|
|
func TestDeleteMode_PageNavigationPreservesSelection(t *testing.T) {
|
|
p := &stubCardPlatform{stubPlatformEngine: stubPlatformEngine{n: "feishu"}}
|
|
sessions := make([]AgentSessionInfo, 0, 8)
|
|
for i := 1; i <= 8; i++ {
|
|
sessions = append(sessions, AgentSessionInfo{ID: fmt.Sprintf("session-%d", i), Summary: fmt.Sprintf("Session %d", i)})
|
|
}
|
|
agent := &stubDeleteAgent{stubListAgent: stubListAgent{sessions: sessions}}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
msg := &Message{SessionKey: "feishu:user1", ReplyCtx: "ctx"}
|
|
|
|
e.cmdDelete(p, msg, nil)
|
|
_ = e.handleCardNav("act:/delete-mode toggle session-1", msg.SessionKey)
|
|
pageTwo := e.handleCardNav("act:/delete-mode page 2", msg.SessionKey)
|
|
if pageTwo == nil {
|
|
t.Fatal("expected page 2 card")
|
|
}
|
|
if !strings.Contains(pageTwo.RenderText(), "1 selected") {
|
|
t.Fatalf("page 2 text = %q, want preserved selected count", pageTwo.RenderText())
|
|
}
|
|
pageOne := e.handleCardNav("act:/delete-mode page 1", msg.SessionKey)
|
|
if pageOne == nil {
|
|
t.Fatal("expected page 1 card")
|
|
}
|
|
btn, ok := findCardAction(pageOne, "act:/delete-mode toggle session-1")
|
|
if !ok {
|
|
t.Fatal("expected toggle action for session-1")
|
|
}
|
|
if btn.Type != "primary" {
|
|
t.Fatalf("selected button type = %q, want primary", btn.Type)
|
|
}
|
|
}
|
|
|
|
func TestDeleteMode_SubmitBlocksActiveSession(t *testing.T) {
|
|
p := &stubCardPlatform{stubPlatformEngine: stubPlatformEngine{n: "feishu"}}
|
|
agent := &stubDeleteAgent{stubListAgent: stubListAgent{sessions: []AgentSessionInfo{
|
|
{ID: "session-1", Summary: "One"},
|
|
{ID: "session-2", Summary: "Two"},
|
|
}}}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
msg := &Message{SessionKey: "feishu:user1", ReplyCtx: "ctx"}
|
|
e.sessions.GetOrCreateActive(msg.SessionKey).AgentSessionID = "session-1"
|
|
|
|
e.cmdDelete(p, msg, nil)
|
|
_ = e.handleCardNav("act:/delete-mode toggle session-1", msg.SessionKey)
|
|
resultCard := e.handleCardNav("act:/delete-mode submit", msg.SessionKey)
|
|
if resultCard == nil {
|
|
t.Fatal("expected result card")
|
|
}
|
|
if len(agent.deleted) != 0 {
|
|
t.Fatalf("deleted = %v, want none", agent.deleted)
|
|
}
|
|
if !strings.Contains(resultCard.RenderText(), "Cannot delete the currently active session") {
|
|
t.Fatalf("result text = %q, want active-session warning", resultCard.RenderText())
|
|
}
|
|
}
|
|
|
|
func TestExecuteCardActionStop_PreservesQuietStateWithoutCleanupReinsert(t *testing.T) {
|
|
e := newTestEngine()
|
|
e.interactiveMu.Lock()
|
|
e.interactiveStates["test:user1"] = &interactiveState{quiet: true}
|
|
e.interactiveMu.Unlock()
|
|
|
|
e.executeCardAction("/stop", "", "test:user1")
|
|
|
|
e.interactiveMu.Lock()
|
|
state := e.interactiveStates["test:user1"]
|
|
e.interactiveMu.Unlock()
|
|
if state == nil {
|
|
t.Fatal("expected interactive state to remain for quiet preservation")
|
|
}
|
|
state.mu.Lock()
|
|
defer state.mu.Unlock()
|
|
if !state.quiet {
|
|
t.Fatal("expected quiet state to remain enabled")
|
|
}
|
|
if state.pending != nil {
|
|
t.Fatal("expected pending permission to be cleared")
|
|
}
|
|
}
|
|
|
|
func TestCmdLang_UsesInlineButtonsOnButtonOnlyPlatform(t *testing.T) {
|
|
p := &stubInlineButtonPlatform{stubPlatformEngine: stubPlatformEngine{n: "inline-only"}}
|
|
e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish)
|
|
|
|
e.cmdLang(p, &Message{SessionKey: "test:user1", ReplyCtx: "ctx"}, nil)
|
|
|
|
if len(p.buttonRows) == 0 {
|
|
t.Fatal("expected /lang to send inline buttons on button-only platform")
|
|
}
|
|
if got := p.buttonRows[0][0].Data; got != "cmd:/lang en" {
|
|
t.Fatalf("first /lang button = %q, want %q", got, "cmd:/lang en")
|
|
}
|
|
}
|
|
|
|
func TestCmdLang_UsesPlainTextChoicesOnPlatformWithoutCardsOrButtons(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "plain"}
|
|
e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish)
|
|
|
|
e.cmdLang(p, &Message{SessionKey: "test:user1", ReplyCtx: "ctx"}, nil)
|
|
|
|
if len(p.sent) != 1 {
|
|
t.Fatalf("sent messages = %d, want 1", len(p.sent))
|
|
}
|
|
if !strings.Contains(p.sent[0], "/lang en") || !strings.Contains(p.sent[0], "/lang auto") {
|
|
t.Fatalf("lang text = %q, want plain-text language choices", p.sent[0])
|
|
}
|
|
}
|
|
|
|
func TestCmdProvider_UsesLegacyTextOnPlatformWithoutCardSupport(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "plain"}
|
|
agent := &stubProviderAgent{
|
|
providers: []ProviderConfig{
|
|
{Name: "openai", BaseURL: "https://api.openai.com", Model: "gpt-4.1"},
|
|
{Name: "azure", BaseURL: "https://azure.example", Model: "gpt-4.1-mini"},
|
|
},
|
|
active: "openai",
|
|
}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
|
|
e.cmdProvider(p, &Message{SessionKey: "test:user1", ReplyCtx: "ctx"}, nil)
|
|
|
|
if len(p.sent) != 1 {
|
|
t.Fatalf("sent messages = %d, want 1", len(p.sent))
|
|
}
|
|
if !strings.Contains(p.sent[0], "Active provider") {
|
|
t.Fatalf("provider text = %q, want current provider section", p.sent[0])
|
|
}
|
|
if !strings.Contains(p.sent[0], "openai") || !strings.Contains(p.sent[0], "azure") {
|
|
t.Fatalf("provider text = %q, want provider list", p.sent[0])
|
|
}
|
|
if !strings.Contains(p.sent[0], "switch") {
|
|
t.Fatalf("provider text = %q, want switch hint", p.sent[0])
|
|
}
|
|
}
|
|
|
|
func TestCmdModel_UsesInlineButtonsOnButtonOnlyPlatform(t *testing.T) {
|
|
p := &stubInlineButtonPlatform{stubPlatformEngine: stubPlatformEngine{n: "inline-only"}}
|
|
agent := &stubModelModeAgent{}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
|
|
e.cmdModel(p, &Message{SessionKey: "test:user1", ReplyCtx: "ctx"}, nil)
|
|
|
|
if len(p.buttonRows) == 0 {
|
|
t.Fatal("expected /model to send inline buttons on button-only platform")
|
|
}
|
|
if got := p.buttonRows[0][0].Data; got != "cmd:/model 1" {
|
|
t.Fatalf("first /model button = %q, want %q", got, "cmd:/model 1")
|
|
}
|
|
}
|
|
|
|
func TestCmdReasoning_UsesInlineButtonsOnButtonOnlyPlatform(t *testing.T) {
|
|
p := &stubInlineButtonPlatform{stubPlatformEngine: stubPlatformEngine{n: "inline-only"}}
|
|
agent := &stubModelModeAgent{}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
|
|
e.cmdReasoning(p, &Message{SessionKey: "test:user1", ReplyCtx: "ctx"}, nil)
|
|
|
|
if len(p.buttonRows) == 0 {
|
|
t.Fatal("expected /reasoning to send inline buttons on button-only platform")
|
|
}
|
|
if got := p.buttonRows[0][0].Data; got != "cmd:/reasoning 1" {
|
|
t.Fatalf("first /reasoning button = %q, want %q", got, "cmd:/reasoning 1")
|
|
}
|
|
if got := p.buttonRows[0][0].Text; got != "low" {
|
|
t.Fatalf("first /reasoning button text = %q, want low", got)
|
|
}
|
|
}
|
|
|
|
func TestCmdReasoning_SwitchesEffortAndResetsSession(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "plain"}
|
|
agent := &stubModelModeAgent{}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
msg := &Message{SessionKey: "test:user1", ReplyCtx: "ctx"}
|
|
|
|
s := e.sessions.GetOrCreateActive(msg.SessionKey)
|
|
s.AgentSessionID = "existing-session"
|
|
s.AddHistory("user", "hello")
|
|
|
|
e.cmdReasoning(p, msg, []string{"3"})
|
|
|
|
if agent.reasoningEffort != "high" {
|
|
t.Fatalf("reasoning effort = %q, want high", agent.reasoningEffort)
|
|
}
|
|
if s.AgentSessionID != "" {
|
|
t.Fatalf("AgentSessionID = %q, want cleared", s.AgentSessionID)
|
|
}
|
|
if len(s.History) != 0 {
|
|
t.Fatalf("history length = %d, want 0", len(s.History))
|
|
}
|
|
if len(p.sent) != 1 || !strings.Contains(p.sent[0], "Reasoning effort switched to `high`") {
|
|
t.Fatalf("sent = %v, want reasoning changed message", p.sent)
|
|
}
|
|
}
|
|
|
|
func TestCmdReasoning_RejectsMinimal(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "plain"}
|
|
agent := &stubModelModeAgent{}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
msg := &Message{SessionKey: "test:user1", ReplyCtx: "ctx"}
|
|
|
|
e.cmdReasoning(p, msg, []string{"minimal"})
|
|
|
|
if agent.reasoningEffort != "" {
|
|
t.Fatalf("reasoning effort = %q, want unchanged empty", agent.reasoningEffort)
|
|
}
|
|
if len(p.sent) != 1 || !strings.Contains(p.sent[0], "/reasoning <number>") || strings.Contains(p.sent[0], "minimal") {
|
|
t.Fatalf("sent = %v, want usage without minimal", p.sent)
|
|
}
|
|
}
|
|
|
|
func TestCmdMode_UsesInlineButtonsOnButtonOnlyPlatform(t *testing.T) {
|
|
p := &stubInlineButtonPlatform{stubPlatformEngine: stubPlatformEngine{n: "inline-only"}}
|
|
agent := &stubModelModeAgent{}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
|
|
e.cmdMode(p, &Message{SessionKey: "test:user1", ReplyCtx: "ctx"}, nil)
|
|
|
|
if len(p.buttonRows) == 0 {
|
|
t.Fatal("expected /mode to send inline buttons on button-only platform")
|
|
}
|
|
if got := p.buttonRows[0][0].Data; got != "cmd:/mode default" {
|
|
t.Fatalf("first /mode button = %q, want %q", got, "cmd:/mode default")
|
|
}
|
|
}
|
|
|
|
func TestCmdStatus_UsesLegacyTextOnPlatformWithoutCardSupport(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "plain"}
|
|
e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish)
|
|
msg := &Message{SessionKey: "test:user1", ReplyCtx: "ctx"}
|
|
|
|
e.cmdStatus(p, msg)
|
|
|
|
if len(p.sent) != 1 {
|
|
t.Fatalf("sent messages = %d, want 1", len(p.sent))
|
|
}
|
|
if !strings.Contains(p.sent[0], "Status") {
|
|
t.Fatalf("status text = %q, want legacy status text", p.sent[0])
|
|
}
|
|
if strings.Contains(p.sent[0], "[← Back]") {
|
|
t.Fatalf("status text = %q, should not be card fallback text", p.sent[0])
|
|
}
|
|
}
|
|
|
|
func TestCmdUsage_UnsupportedAgent(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "plain"}
|
|
e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish)
|
|
msg := &Message{SessionKey: "test:user1", ReplyCtx: "ctx"}
|
|
|
|
e.handleCommand(p, msg, "/usage")
|
|
|
|
if len(p.sent) != 1 {
|
|
t.Fatalf("sent messages = %d, want 1", len(p.sent))
|
|
}
|
|
if !strings.Contains(strings.ToLower(p.sent[0]), "does not support") {
|
|
t.Fatalf("sent = %q, want unsupported usage message", p.sent[0])
|
|
}
|
|
}
|
|
|
|
func TestCmdUsage_Success(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "plain"}
|
|
agent := &stubUsageAgent{
|
|
report: &UsageReport{
|
|
Provider: "codex",
|
|
Email: "dev@example.com",
|
|
Plan: "team",
|
|
Buckets: []UsageBucket{
|
|
{
|
|
Name: "Rate limit",
|
|
Allowed: true,
|
|
LimitReached: false,
|
|
Windows: []UsageWindow{
|
|
{Name: "Primary", UsedPercent: 23, WindowSeconds: 18000, ResetAfterSeconds: 6665},
|
|
{Name: "Secondary", UsedPercent: 42, WindowSeconds: 604800, ResetAfterSeconds: 512698},
|
|
},
|
|
},
|
|
{
|
|
Name: "Code review",
|
|
Allowed: true,
|
|
LimitReached: false,
|
|
Windows: []UsageWindow{
|
|
{Name: "Primary", UsedPercent: 0, WindowSeconds: 604800, ResetAfterSeconds: 604800},
|
|
},
|
|
},
|
|
},
|
|
Credits: &UsageCredits{
|
|
HasCredits: false,
|
|
Unlimited: false,
|
|
},
|
|
},
|
|
}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
msg := &Message{SessionKey: "test:user1", ReplyCtx: "ctx"}
|
|
|
|
e.handleCommand(p, msg, "/usage")
|
|
|
|
if len(p.sent) != 1 {
|
|
t.Fatalf("sent messages = %d, want 1", len(p.sent))
|
|
}
|
|
got := p.sent[0]
|
|
for _, want := range []string{
|
|
"Account: dev@example.com (team)",
|
|
"5h limit",
|
|
"Remaining: 77%",
|
|
"Resets: 1h 51m",
|
|
"5h limit",
|
|
"7d limit",
|
|
"Remaining: 58%",
|
|
"Resets: 5d 22h 24m",
|
|
} {
|
|
if !strings.Contains(got, want) {
|
|
t.Fatalf("usage text = %q, want substring %q", got, want)
|
|
}
|
|
}
|
|
if strings.Contains(got, "```") {
|
|
t.Fatalf("usage text = %q, should not use code block on plain platform", got)
|
|
}
|
|
}
|
|
|
|
func TestCmdUsage_UsesCardOnCardPlatform(t *testing.T) {
|
|
p := &stubCardPlatform{stubPlatformEngine: stubPlatformEngine{n: "feishu"}}
|
|
agent := &stubUsageAgent{
|
|
report: &UsageReport{
|
|
Email: "dev@example.com",
|
|
Plan: "team",
|
|
Buckets: []UsageBucket{
|
|
{
|
|
Name: "Rate limit",
|
|
Allowed: true,
|
|
LimitReached: false,
|
|
Windows: []UsageWindow{
|
|
{Name: "Primary", UsedPercent: 23, WindowSeconds: 18000, ResetAfterSeconds: 6665},
|
|
{Name: "Secondary", UsedPercent: 42, WindowSeconds: 604800, ResetAfterSeconds: 512698},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangChinese)
|
|
msg := &Message{SessionKey: "test:user1", ReplyCtx: "ctx"}
|
|
|
|
e.handleCommand(p, msg, "/usage")
|
|
|
|
if len(p.repliedCards) != 1 {
|
|
t.Fatalf("replied cards = %d, want 1", len(p.repliedCards))
|
|
}
|
|
if len(p.sent) != 0 {
|
|
t.Fatalf("sent text = %v, want no plain text fallback", p.sent)
|
|
}
|
|
text := p.repliedCards[0].RenderText()
|
|
for _, want := range []string{
|
|
"账号:dev@example.com (team)",
|
|
"5小时限额",
|
|
"剩余:77%",
|
|
"重置:1小时 51分钟",
|
|
"7日限额",
|
|
"剩余:58%",
|
|
"重置:5天 22小时 24分钟",
|
|
} {
|
|
if !strings.Contains(text, want) {
|
|
t.Fatalf("card text = %q, want substring %q", text, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCmdUsage_LocalizedChinese(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "plain"}
|
|
agent := &stubUsageAgent{
|
|
report: &UsageReport{
|
|
Email: "dev@example.com",
|
|
Plan: "team",
|
|
Buckets: []UsageBucket{
|
|
{
|
|
Name: "Rate limit",
|
|
Allowed: true,
|
|
LimitReached: false,
|
|
Windows: []UsageWindow{
|
|
{Name: "Primary", UsedPercent: 23, WindowSeconds: 18000, ResetAfterSeconds: 6665},
|
|
{Name: "Secondary", UsedPercent: 42, WindowSeconds: 604800, ResetAfterSeconds: 512698},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangChinese)
|
|
msg := &Message{SessionKey: "test:user1", ReplyCtx: "ctx"}
|
|
|
|
e.handleCommand(p, msg, "/usage")
|
|
|
|
if len(p.sent) != 1 {
|
|
t.Fatalf("sent messages = %d, want 1", len(p.sent))
|
|
}
|
|
got := p.sent[0]
|
|
for _, want := range []string{
|
|
"账号:dev@example.com (team)",
|
|
"5小时限额",
|
|
"剩余:77%",
|
|
"重置:1小时 51分钟",
|
|
"7日限额",
|
|
"剩余:58%",
|
|
"重置:5天 22小时 24分钟",
|
|
} {
|
|
if !strings.Contains(got, want) {
|
|
t.Fatalf("usage text = %q, want substring %q", got, want)
|
|
}
|
|
}
|
|
if strings.Contains(got, "```") {
|
|
t.Fatalf("usage text = %q, should not use code block on plain platform", got)
|
|
}
|
|
}
|
|
|
|
func TestCmdCommands_UsesLegacyTextOnPlatformWithoutCardSupport(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "plain"}
|
|
e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish)
|
|
e.AddCommand("deploy", "Deploy app", "ship it", "", "", "config")
|
|
|
|
e.cmdCommands(p, &Message{SessionKey: "test:user1", ReplyCtx: "ctx"}, nil)
|
|
|
|
if len(p.sent) != 1 {
|
|
t.Fatalf("sent messages = %d, want 1", len(p.sent))
|
|
}
|
|
if !strings.Contains(p.sent[0], "/deploy") {
|
|
t.Fatalf("commands text = %q, want legacy command list", p.sent[0])
|
|
}
|
|
if strings.Contains(p.sent[0], "[← Back]") {
|
|
t.Fatalf("commands text = %q, should not be card fallback text", p.sent[0])
|
|
}
|
|
}
|
|
|
|
func TestCmdConfig_UsesLegacyTextOnPlatformWithoutCardSupport(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "plain"}
|
|
e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish)
|
|
|
|
e.cmdConfig(p, &Message{SessionKey: "test:user1", ReplyCtx: "ctx"}, nil)
|
|
|
|
if len(p.sent) != 1 {
|
|
t.Fatalf("sent messages = %d, want 1", len(p.sent))
|
|
}
|
|
if !strings.Contains(p.sent[0], "thinking_max_len") {
|
|
t.Fatalf("config text = %q, want legacy config list", p.sent[0])
|
|
}
|
|
if strings.Contains(p.sent[0], "[← Back]") {
|
|
t.Fatalf("config text = %q, should not be card fallback text", p.sent[0])
|
|
}
|
|
}
|
|
|
|
func TestCmdAlias_UsesLegacyTextOnPlatformWithoutCardSupport(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "plain"}
|
|
e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish)
|
|
e.AddAlias("ls", "/list")
|
|
|
|
e.cmdAlias(p, &Message{SessionKey: "test:user1", ReplyCtx: "ctx"}, nil)
|
|
|
|
if len(p.sent) != 1 {
|
|
t.Fatalf("sent messages = %d, want 1", len(p.sent))
|
|
}
|
|
if !strings.Contains(p.sent[0], "ls") || !strings.Contains(p.sent[0], "/list") {
|
|
t.Fatalf("alias text = %q, want legacy alias list", p.sent[0])
|
|
}
|
|
if strings.Contains(p.sent[0], "[← Back]") {
|
|
t.Fatalf("alias text = %q, should not be card fallback text", p.sent[0])
|
|
}
|
|
}
|
|
|
|
func TestCmdSkills_UsesLegacyTextOnPlatformWithoutCardSupport(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "plain"}
|
|
e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish)
|
|
temp := t.TempDir()
|
|
skillDir := temp + "/demo"
|
|
if err := os.Mkdir(skillDir, 0o755); err != nil {
|
|
t.Fatalf("mkdir skill dir: %v", err)
|
|
}
|
|
if err := os.WriteFile(skillDir+"/SKILL.md", []byte("---\ndescription: Demo skill\n---\nDo demo"), 0o644); err != nil {
|
|
t.Fatalf("write skill file: %v", err)
|
|
}
|
|
e.skills.SetDirs([]string{temp})
|
|
|
|
e.cmdSkills(p, &Message{SessionKey: "test:user1", ReplyCtx: "ctx"})
|
|
|
|
if len(p.sent) != 1 {
|
|
t.Fatalf("sent messages = %d, want 1", len(p.sent))
|
|
}
|
|
if !strings.Contains(p.sent[0], "/demo") {
|
|
t.Fatalf("skills text = %q, want legacy skills list", p.sent[0])
|
|
}
|
|
if strings.Contains(p.sent[0], "[← Back]") {
|
|
t.Fatalf("skills text = %q, should not be card fallback text", p.sent[0])
|
|
}
|
|
}
|
|
|
|
func TestRenderListCard_MakesEveryVisibleSessionClickable(t *testing.T) {
|
|
sessions := make([]AgentSessionInfo, 0, 7)
|
|
base := time.Date(2026, 3, 9, 10, 0, 0, 0, time.UTC)
|
|
for i := 0; i < 7; i++ {
|
|
sessions = append(sessions, AgentSessionInfo{
|
|
ID: "agent-session-" + string(rune('A'+i)),
|
|
Summary: "Session summary",
|
|
MessageCount: i + 1,
|
|
ModifiedAt: base.Add(time.Duration(i) * time.Minute),
|
|
})
|
|
}
|
|
|
|
e := NewEngine("test", &stubListAgent{sessions: sessions}, []Platform{&stubPlatformEngine{n: "test"}}, "", LangEnglish)
|
|
e.sessions.GetOrCreateActive("test:user1").AgentSessionID = sessions[5].ID
|
|
|
|
card, err := e.renderListCard("test:user1", 1)
|
|
if err != nil {
|
|
t.Fatalf("renderListCard returned error: %v", err)
|
|
}
|
|
|
|
if got := countCardActionValues(card, "act:/switch "); got != len(sessions) {
|
|
t.Fatalf("switch action count = %d, want %d", got, len(sessions))
|
|
}
|
|
|
|
btn, ok := findCardAction(card, "act:/switch 6")
|
|
if !ok {
|
|
t.Fatal("expected active session switch action to exist")
|
|
}
|
|
if btn.Type != "primary" {
|
|
t.Fatalf("active session button type = %q, want primary", btn.Type)
|
|
}
|
|
}
|
|
|
|
func TestRenderHelpCard_DefaultsToSessionTab(t *testing.T) {
|
|
e := NewEngine("test", &stubAgent{}, []Platform{&stubPlatformEngine{n: "test"}}, "", LangEnglish)
|
|
|
|
card := e.renderHelpCard()
|
|
text := card.RenderText()
|
|
|
|
if got := countCardActionValues(card, "nav:/help "); got != 4 {
|
|
t.Fatalf("help tab action count = %d, want 4", got)
|
|
}
|
|
btn, ok := findCardAction(card, "nav:/help session")
|
|
if !ok {
|
|
t.Fatal("expected session help tab to exist")
|
|
}
|
|
if btn.Type != "primary" {
|
|
t.Fatalf("session help tab type = %q, want primary", btn.Type)
|
|
}
|
|
if btn.Text != "Session Management" {
|
|
t.Fatalf("session help tab text = %q, want full title", btn.Text)
|
|
}
|
|
if !strings.Contains(text, "**/new**") {
|
|
t.Fatalf("default help text = %q, want session commands", text)
|
|
}
|
|
if strings.Contains(text, "**Session Management**") {
|
|
t.Fatalf("default help text = %q, should not repeat tab title in body", text)
|
|
}
|
|
if strings.Contains(text, "**/model**") {
|
|
t.Fatalf("default help text = %q, should not include agent commands", text)
|
|
}
|
|
}
|
|
|
|
func TestHandleCardNav_HelpSwitchesTabs(t *testing.T) {
|
|
e := NewEngine("test", &stubAgent{}, []Platform{&stubPlatformEngine{n: "test"}}, "", LangEnglish)
|
|
|
|
card := e.handleCardNav("nav:/help agent", "test:user1")
|
|
if card == nil {
|
|
t.Fatal("expected help nav card")
|
|
}
|
|
text := card.RenderText()
|
|
|
|
if !strings.Contains(text, "**/model**") {
|
|
t.Fatalf("agent help text = %q, want agent commands", text)
|
|
}
|
|
if strings.Contains(text, "**Agent Configuration**") {
|
|
t.Fatalf("agent help text = %q, should not repeat tab title in body", text)
|
|
}
|
|
if strings.Contains(text, "**/new**") {
|
|
t.Fatalf("agent help text = %q, should not include session commands", text)
|
|
}
|
|
}
|