Files
cc-connect/core/engine_test.go
T
chenhg5andClaude 07cd76f2bb feat(workspace): add multi-workspace support with bind command and i18n messages
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>
2026-03-13 09:13:49 +08:00

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)
}
}