Files
cc-connect/core/engine_test.go
T
Shawn 8ec95a280c fix(core): prevent infinite loop in drainEvents on closed channel (#152)
drainEvents uses a select loop with a default case to drain buffered
events. When the channel is closed, receives always succeed immediately
with a zero value, so the default case never triggers and the function
spins in an infinite loop, consuming 100% CPU.

Check the ok value from the receive and return immediately when the
channel is closed. Add regression tests for both closed and open
channel scenarios.
2026-03-15 19:19:53 +08:00

2775 lines
84 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, _ []FileAttachment) 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.SetAgentSessionID("session-123", "test")
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_ToggleSelectionReturnsUpdatedCard(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 card update after toggle")
}
if !strings.Contains(card.RenderText(), "1 selected") {
t.Fatalf("card text = %q, want selected count", card.RenderText())
}
confirmCard := e.handleCardNav("act:/delete-mode confirm", msg.SessionKey)
if confirmCard == nil {
t.Fatal("expected confirmation card")
}
if !strings.Contains(confirmCard.RenderText(), "Two") {
t.Fatalf("confirmation text = %q, want selected session", confirmCard.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).SetAgentSessionID("session-1", "test")
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 TestDeleteMode_ActiveSessionMarkedWithArrowAndNotSelectable(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).SetAgentSessionID("session-1", "test")
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 _, ok := findCardAction(card, "act:/delete-mode toggle session-1"); ok {
t.Fatal("active session should not be toggle-selectable")
}
if _, ok := findCardAction(card, "act:/delete-mode noop session-1"); !ok {
t.Fatal("expected noop action for active session")
}
if got := countCardActionValues(card, "act:/delete-mode toggle "); got != 1 {
t.Fatalf("toggle action count = %d, want 1", got)
}
if !strings.Contains(card.RenderText(), "▶ **1.**") {
t.Fatalf("card text = %q, want arrow marker for active session", card.RenderText())
}
}
func TestDeleteMode_FormSubmitShowsConfirmThenDeletes(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)
confirmCard := e.handleCardNav("act:/delete-mode form-submit session-1,session-3", msg.SessionKey)
if confirmCard == nil {
t.Fatal("expected confirm card after form-submit")
}
if len(agent.deleted) != 0 {
t.Fatalf("deleted = %v, want none before confirm", agent.deleted)
}
confirmText := confirmCard.RenderText()
if !strings.Contains(confirmText, "One") || !strings.Contains(confirmText, "Three") {
t.Fatalf("confirm text = %q, want selected sessions", 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 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.SetAgentSessionID("existing-session", "test")
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.GetAgentSessionID() != "" {
t.Fatalf("AgentSessionID = %q, want cleared", s.GetAgentSessionID())
}
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").SetAgentSessionID(sessions[5].ID, "test")
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)
}
}
// --- AskUserQuestion tests ---
func testQuestions() []UserQuestion {
return []UserQuestion{{
Question: "Which database?",
Header: "Setup",
Options: []UserQuestionOption{
{Label: "PostgreSQL", Description: "Recommended for production"},
{Label: "SQLite", Description: "Lightweight, file-based"},
{Label: "MySQL", Description: "Popular open-source"},
},
MultiSelect: false,
}}
}
func testMultiQuestions() []UserQuestion {
return []UserQuestion{
{
Question: "Which database?",
Header: "Database",
Options: []UserQuestionOption{
{Label: "PostgreSQL"},
{Label: "SQLite"},
},
},
{
Question: "Which framework?",
Header: "Framework",
Options: []UserQuestionOption{
{Label: "Gin"},
{Label: "Echo"},
},
},
}
}
func TestResolveAskQuestionAnswer_NumericIndex(t *testing.T) {
e := newTestEngine()
q := testQuestions()[0]
got := e.resolveAskQuestionAnswer(q, "2")
if got != "SQLite" {
t.Errorf("expected SQLite, got %s", got)
}
}
func TestResolveAskQuestionAnswer_ButtonCallback(t *testing.T) {
e := newTestEngine()
q := testQuestions()[0]
got := e.resolveAskQuestionAnswer(q, "askq:0:1")
if got != "PostgreSQL" {
t.Errorf("expected PostgreSQL, got %s", got)
}
}
func TestResolveAskQuestionAnswer_FreeText(t *testing.T) {
e := newTestEngine()
q := testQuestions()[0]
got := e.resolveAskQuestionAnswer(q, "Redis")
if got != "Redis" {
t.Errorf("expected Redis, got %s", got)
}
}
func TestResolveAskQuestionAnswer_MultiSelect(t *testing.T) {
e := newTestEngine()
q := testQuestions()[0]
q.MultiSelect = true
got := e.resolveAskQuestionAnswer(q, "1,3")
if got != "PostgreSQL, MySQL" {
t.Errorf("expected 'PostgreSQL, MySQL', got %s", got)
}
}
func TestResolveAskQuestionAnswer_OutOfRange(t *testing.T) {
e := newTestEngine()
q := testQuestions()[0]
got := e.resolveAskQuestionAnswer(q, "99")
if got != "99" {
t.Errorf("expected raw '99' for out-of-range, got %s", got)
}
}
func TestBuildAskQuestionResponse(t *testing.T) {
input := map[string]any{
"questions": []any{map[string]any{"question": "Which?"}},
}
collected := map[int]string{0: "PostgreSQL", 1: "Gin"}
result := buildAskQuestionResponse(input, testQuestions(), collected)
answers, ok := result["answers"].(map[string]any)
if !ok {
t.Fatal("expected answers map")
}
if answers["0"] != "PostgreSQL" {
t.Errorf("expected answer[0]=PostgreSQL, got %v", answers["0"])
}
if answers["1"] != "Gin" {
t.Errorf("expected answer[1]=Gin, got %v", answers["1"])
}
if _, ok := result["questions"]; !ok {
t.Error("expected original questions to be preserved")
}
}
func TestSendAskQuestionPrompt_CardPlatform(t *testing.T) {
e := newTestEngine()
p := &stubCardPlatform{stubPlatformEngine: stubPlatformEngine{n: "feishu"}}
e.sendAskQuestionPrompt(p, "ctx", testQuestions(), 0)
if len(p.sentCards) != 1 {
t.Fatalf("expected 1 card, got %d", len(p.sentCards))
}
card := p.sentCards[0]
if card.Header == nil || card.Header.Color != "blue" {
t.Errorf("expected blue header, got %+v", card.Header)
}
askqCount := countCardActionValues(card, "askq:")
if askqCount != 3 {
t.Errorf("expected 3 askq buttons, got %d", askqCount)
}
}
func TestSendAskQuestionPrompt_CardPlatform_MultiQuestion_ShowsIndex(t *testing.T) {
e := newTestEngine()
p := &stubCardPlatform{stubPlatformEngine: stubPlatformEngine{n: "feishu"}}
qs := testMultiQuestions()
e.sendAskQuestionPrompt(p, "ctx", qs, 0)
if len(p.sentCards) != 1 {
t.Fatalf("expected 1 card, got %d", len(p.sentCards))
}
card := p.sentCards[0]
if !strings.Contains(card.Header.Title, "(1/2)") {
t.Errorf("expected (1/2) in title, got %s", card.Header.Title)
}
}
func TestSendAskQuestionPrompt_InlineButtonPlatform(t *testing.T) {
e := newTestEngine()
p := &stubInlineButtonPlatform{stubPlatformEngine: stubPlatformEngine{n: "telegram"}}
e.sendAskQuestionPrompt(p, "ctx", testQuestions(), 0)
if len(p.buttonRows) != 3 {
t.Fatalf("expected 3 button rows, got %d", len(p.buttonRows))
}
if p.buttonRows[0][0].Data != "askq:0:1" {
t.Errorf("expected askq:0:1, got %s", p.buttonRows[0][0].Data)
}
}
func TestSendAskQuestionPrompt_PlainPlatform(t *testing.T) {
e := newTestEngine()
p := &stubPlatformEngine{n: "plain"}
e.sendAskQuestionPrompt(p, "ctx", testQuestions(), 0)
if len(p.sent) != 1 {
t.Fatal("expected 1 message")
}
msg := p.sent[0]
if !strings.Contains(msg, "Which database?") {
t.Errorf("expected question text, got %s", msg)
}
if !strings.Contains(msg, "1. **PostgreSQL**") {
t.Errorf("expected numbered options, got %s", msg)
}
}
func TestHandlePendingPermission_AskUserQuestion_SingleQuestion(t *testing.T) {
e := newTestEngine()
p := &stubPlatformEngine{n: "test"}
rec := &recordingAgentSession{}
state := &interactiveState{
agentSession: rec,
platform: p,
replyCtx: "ctx",
pending: &pendingPermission{
RequestID: "req-1",
ToolName: "AskUserQuestion",
ToolInput: map[string]any{
"questions": []any{map[string]any{"question": "Which?"}},
},
Questions: testQuestions(),
Resolved: make(chan struct{}),
},
}
e.interactiveMu.Lock()
e.interactiveStates["test:chat:user1"] = state
e.interactiveMu.Unlock()
handled := e.handlePendingPermission(p, &Message{
SessionKey: "test:chat:user1",
UserID: "user1",
Content: "2",
ReplyCtx: "ctx",
}, "2")
if !handled {
t.Fatal("expected handlePendingPermission to return true")
}
if rec.calls != 1 {
t.Fatalf("expected 1 RespondPermission call, got %d", rec.calls)
}
answers, ok := rec.lastResult.UpdatedInput["answers"].(map[string]any)
if !ok {
t.Fatal("expected answers in updatedInput")
}
if answers["0"] != "SQLite" {
t.Errorf("expected answer=SQLite, got %v", answers["0"])
}
state.mu.Lock()
if state.pending != nil {
t.Error("expected pending to be cleared after response")
}
state.mu.Unlock()
}
func TestHandlePendingPermission_AskUserQuestion_MultiQuestion_Sequential(t *testing.T) {
e := newTestEngine()
p := &stubPlatformEngine{n: "test"}
rec := &recordingAgentSession{}
qs := testMultiQuestions()
state := &interactiveState{
agentSession: rec,
platform: p,
replyCtx: "ctx",
pending: &pendingPermission{
RequestID: "req-1",
ToolName: "AskUserQuestion",
ToolInput: map[string]any{"questions": []any{}},
Questions: qs,
Resolved: make(chan struct{}),
},
}
e.interactiveMu.Lock()
e.interactiveStates["test:chat:user1"] = state
e.interactiveMu.Unlock()
// Answer question 0 — should NOT resolve yet
handled := e.handlePendingPermission(p, &Message{
SessionKey: "test:chat:user1",
UserID: "user1",
Content: "1",
ReplyCtx: "ctx",
}, "1")
if !handled {
t.Fatal("expected handled=true for question 0")
}
if rec.calls != 0 {
t.Fatalf("should not have called RespondPermission yet, got %d calls", rec.calls)
}
state.mu.Lock()
if state.pending == nil {
t.Fatal("pending should still exist (more questions)")
}
if state.pending.CurrentQuestion != 1 {
t.Errorf("expected CurrentQuestion=1, got %d", state.pending.CurrentQuestion)
}
state.mu.Unlock()
// Answer question 1 — should resolve
handled = e.handlePendingPermission(p, &Message{
SessionKey: "test:chat:user1",
UserID: "user1",
Content: "2",
ReplyCtx: "ctx",
}, "2")
if !handled {
t.Fatal("expected handled=true for question 1")
}
if rec.calls != 1 {
t.Fatalf("expected 1 RespondPermission call, got %d", rec.calls)
}
answers, ok := rec.lastResult.UpdatedInput["answers"].(map[string]any)
if !ok {
t.Fatal("expected answers in updatedInput")
}
if answers["0"] != "PostgreSQL" {
t.Errorf("expected answer[0]=PostgreSQL, got %v", answers["0"])
}
if answers["1"] != "Echo" {
t.Errorf("expected answer[1]=Echo, got %v", answers["1"])
}
state.mu.Lock()
if state.pending != nil {
t.Error("expected pending to be cleared after all questions answered")
}
state.mu.Unlock()
}
func TestHandlePendingPermission_AskUserQuestion_SkipsPermFlow(t *testing.T) {
e := newTestEngine()
p := &stubPlatformEngine{n: "test"}
rec := &recordingAgentSession{}
state := &interactiveState{
agentSession: rec,
platform: p,
replyCtx: "ctx",
pending: &pendingPermission{
RequestID: "req-1",
ToolName: "AskUserQuestion",
ToolInput: map[string]any{
"questions": []any{map[string]any{"question": "Which?"}},
},
Questions: testQuestions(),
Resolved: make(chan struct{}),
},
}
e.interactiveMu.Lock()
e.interactiveStates["test:chat:user1"] = state
e.interactiveMu.Unlock()
// "allow" should NOT be interpreted as permission allow; should be treated as free text answer
handled := e.handlePendingPermission(p, &Message{
SessionKey: "test:chat:user1",
UserID: "user1",
Content: "allow",
ReplyCtx: "ctx",
}, "allow")
if !handled {
t.Fatal("expected handled=true")
}
answers, ok := rec.lastResult.UpdatedInput["answers"].(map[string]any)
if !ok {
t.Fatal("expected answers in updatedInput")
}
if answers["0"] != "allow" {
t.Errorf("expected free text 'allow' as answer, got %v", answers["0"])
}
}
// ──────────────────────────────────────────────────────────────
// Session routing / cleanup CAS tests
// ──────────────────────────────────────────────────────────────
// controllableAgentSession is an AgentSession stub whose session ID, liveness,
// and events channel can be controlled by the test.
type controllableAgentSession struct {
sessionID string
alive bool
events chan Event
closed chan struct{} // closed when Close() is called
}
func newControllableSession(id string) *controllableAgentSession {
return &controllableAgentSession{
sessionID: id,
alive: true,
events: make(chan Event, 8),
closed: make(chan struct{}),
}
}
func (s *controllableAgentSession) Send(_ string, _ []ImageAttachment, _ []FileAttachment) error {
return nil
}
func (s *controllableAgentSession) RespondPermission(_ string, _ PermissionResult) error { return nil }
func (s *controllableAgentSession) Events() <-chan Event { return s.events }
func (s *controllableAgentSession) CurrentSessionID() string { return s.sessionID }
func (s *controllableAgentSession) Alive() bool { return s.alive }
func (s *controllableAgentSession) Close() error {
s.alive = false
close(s.events)
select {
case <-s.closed:
default:
close(s.closed)
}
return nil
}
// controllableAgent lets tests control which session is returned by StartSession.
type controllableAgent struct {
nextSession AgentSession
}
func (a *controllableAgent) Name() string { return "controllable" }
func (a *controllableAgent) StartSession(_ context.Context, _ string) (AgentSession, error) {
if a.nextSession != nil {
return a.nextSession, nil
}
return newControllableSession("default"), nil
}
func (a *controllableAgent) ListSessions(_ context.Context) ([]AgentSessionInfo, error) {
return nil, nil
}
func (a *controllableAgent) Stop() error { return nil }
// TestCleanupCAS_SkipsWhenStateReplaced verifies that cleanupInteractiveState
// with an expected state pointer is a no-op when the map entry has been replaced.
// This is the core of the /new race fix: old goroutine's cleanup must not delete
// a replacement state created by a new turn.
func TestCleanupCAS_SkipsWhenStateReplaced(t *testing.T) {
e := newTestEngine()
key := "test:user1"
oldState := &interactiveState{agentSession: newControllableSession("old")}
newState := &interactiveState{agentSession: newControllableSession("new")}
// Place the NEW state in the map (simulating: /new already cleaned up and
// a new turn created a replacement state).
e.interactiveMu.Lock()
e.interactiveStates[key] = newState
e.interactiveMu.Unlock()
// Old goroutine calls cleanup with the OLD state pointer — should be skipped.
e.cleanupInteractiveState(key, oldState)
e.interactiveMu.Lock()
current := e.interactiveStates[key]
e.interactiveMu.Unlock()
if current != newState {
t.Fatal("CAS cleanup deleted the replacement state — race not prevented")
}
}
// TestCleanupCAS_DeletesWhenStateMatches verifies that cleanup proceeds normally
// when the expected state matches the current map entry.
func TestCleanupCAS_DeletesWhenStateMatches(t *testing.T) {
e := newTestEngine()
key := "test:user1"
state := &interactiveState{agentSession: newControllableSession("s1")}
e.interactiveMu.Lock()
e.interactiveStates[key] = state
e.interactiveMu.Unlock()
e.cleanupInteractiveState(key, state)
e.interactiveMu.Lock()
current := e.interactiveStates[key]
e.interactiveMu.Unlock()
if current != nil {
t.Fatal("expected state to be deleted when expected pointer matches")
}
}
// TestCleanupCAS_UnconditionalWithoutExpected verifies that cleanup without an
// expected pointer always deletes (backward compat for command handlers).
func TestCleanupCAS_UnconditionalWithoutExpected(t *testing.T) {
e := newTestEngine()
key := "test:user1"
state := &interactiveState{agentSession: newControllableSession("s1")}
e.interactiveMu.Lock()
e.interactiveStates[key] = state
e.interactiveMu.Unlock()
// No expected pointer — unconditional cleanup (used by /new, /switch).
e.cleanupInteractiveState(key)
e.interactiveMu.Lock()
current := e.interactiveStates[key]
e.interactiveMu.Unlock()
if current != nil {
t.Fatal("expected unconditional cleanup to delete state")
}
}
// TestSessionMismatch_RecyclesStaleAgent verifies that getOrCreateInteractiveStateWith
// detects when the running agent session ID differs from the active Session's
// AgentSessionID and creates a fresh agent instead of reusing the stale one.
func TestSessionMismatch_RecyclesStaleAgent(t *testing.T) {
newSess := newControllableSession("new-agent-id")
agent := &controllableAgent{nextSession: newSess}
p := &stubPlatformEngine{n: "test"}
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
key := "test:user1"
// Seed a live agent session with ID "old-agent-id".
oldSess := newControllableSession("old-agent-id")
e.interactiveMu.Lock()
e.interactiveStates[key] = &interactiveState{
agentSession: oldSess,
platform: p,
replyCtx: "ctx",
}
e.interactiveMu.Unlock()
// The active Session now wants a DIFFERENT agent session ID.
session := &Session{AgentSessionID: "new-agent-id"}
state := e.getOrCreateInteractiveStateWith(key, p, "ctx", session, nil)
if state.agentSession == oldSess {
t.Fatal("expected stale agent session to be replaced")
}
if state.agentSession != newSess {
t.Fatal("expected new agent session from StartSession")
}
// Old session should be closed asynchronously.
select {
case <-oldSess.closed:
case <-time.After(2 * time.Second):
t.Fatal("old agent session was not closed after mismatch")
}
}
// TestSessionMismatch_DoesNotLeakQuiet verifies that after a session mismatch,
// the new state gets defaultQuiet instead of inheriting quiet from the stale state.
func TestSessionMismatch_DoesNotLeakQuiet(t *testing.T) {
agent := &controllableAgent{nextSession: newControllableSession("new-id")}
p := &stubPlatformEngine{n: "test"}
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
key := "test:user1"
// Seed a stale state with quiet=true.
e.interactiveMu.Lock()
e.interactiveStates[key] = &interactiveState{
agentSession: newControllableSession("old-id"),
platform: p,
replyCtx: "ctx",
quiet: true,
}
e.interactiveMu.Unlock()
// Active session wants "new-id", which mismatches "old-id".
session := &Session{AgentSessionID: "new-id"}
state := e.getOrCreateInteractiveStateWith(key, p, "ctx", session, nil)
state.mu.Lock()
q := state.quiet
state.mu.Unlock()
if q {
t.Fatal("quiet leaked from stale state into replacement — ok=false fix not working")
}
}
// TestSessionMismatch_ReusesWhenIDsMatch verifies that getOrCreateInteractiveStateWith
// returns the existing state when agent session IDs match (no unnecessary recycling).
func TestSessionMismatch_ReusesWhenIDsMatch(t *testing.T) {
agent := &controllableAgent{}
p := &stubPlatformEngine{n: "test"}
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
key := "test:user1"
existingSess := newControllableSession("matching-id")
existingState := &interactiveState{
agentSession: existingSess,
platform: p,
replyCtx: "ctx",
}
e.interactiveMu.Lock()
e.interactiveStates[key] = existingState
e.interactiveMu.Unlock()
session := &Session{AgentSessionID: "matching-id"}
state := e.getOrCreateInteractiveStateWith(key, p, "ctx", session, nil)
if state != existingState {
t.Fatal("expected existing state to be reused when session IDs match")
}
}
// TestSessionIDWriteback_ImmediateAfterStartSession verifies that after
// StartSession, the agent's CurrentSessionID is immediately written back
// to the Session's AgentSessionID when it was previously empty.
func TestSessionIDWriteback_ImmediateAfterStartSession(t *testing.T) {
sess := newControllableSession("agent-uuid-123")
agent := &controllableAgent{nextSession: sess}
p := &stubPlatformEngine{n: "test"}
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
key := "test:user1"
session := &Session{AgentSessionID: ""} // empty — no prior binding
e.getOrCreateInteractiveStateWith(key, p, "ctx", session, nil)
got := session.GetAgentSessionID()
if got != "agent-uuid-123" {
t.Fatalf("AgentSessionID = %q, want %q — immediate writeback not working", got, "agent-uuid-123")
}
}
// TestSessionIDWriteback_DoesNotOverwriteExisting verifies that immediate
// writeback does not clobber an existing AgentSessionID (e.g. from --resume).
func TestSessionIDWriteback_DoesNotOverwriteExisting(t *testing.T) {
sess := newControllableSession("new-uuid")
agent := &controllableAgent{nextSession: sess}
p := &stubPlatformEngine{n: "test"}
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
key := "test:user1"
session := &Session{AgentSessionID: "existing-uuid"}
e.getOrCreateInteractiveStateWith(key, p, "ctx", session, nil)
got := session.GetAgentSessionID()
if got != "existing-uuid" {
t.Fatalf("AgentSessionID = %q, want %q — writeback should not overwrite", got, "existing-uuid")
}
}
// TestStaleGoroutineCleanup_RaceSimulation simulates the full race scenario:
// old turn still processing → /new creates new Session → new turn starts →
// old turn exits and calls cleanup. Verifies the new state survives.
func TestStaleGoroutineCleanup_RaceSimulation(t *testing.T) {
p := &stubPlatformEngine{n: "test"}
newSess := newControllableSession("new-agent")
agent := &controllableAgent{nextSession: newSess}
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
key := "test:user1"
// Step 1: Old turn created state S1 with old agent.
oldSess := newControllableSession("old-agent")
oldState := &interactiveState{
agentSession: oldSess,
platform: p,
replyCtx: "ctx",
}
e.interactiveMu.Lock()
e.interactiveStates[key] = oldState
e.interactiveMu.Unlock()
// Step 2: /new runs — unconditional cleanup deletes S1.
e.cleanupInteractiveState(key)
// Step 3: New turn creates Session B and calls getOrCreateInteractiveStateWith.
sessionB := &Session{AgentSessionID: ""}
newState := e.getOrCreateInteractiveStateWith(key, p, "ctx", sessionB, nil)
// Verify S2 is in the map.
e.interactiveMu.Lock()
current := e.interactiveStates[key]
e.interactiveMu.Unlock()
if current != newState {
t.Fatal("new state not in map")
}
// Step 4: Old goroutine exits and calls cleanup with OLD state pointer.
// This simulates processInteractiveEvents channelClosed path.
e.cleanupInteractiveState(key, oldState)
// Verify: new state must survive.
e.interactiveMu.Lock()
afterCleanup := e.interactiveStates[key]
e.interactiveMu.Unlock()
if afterCleanup != newState {
t.Fatal("stale goroutine's cleanup deleted the replacement state — CAS not working")
}
if newState.agentSession.Alive() != true {
t.Fatal("replacement agent session was killed by stale cleanup")
}
}
func TestSplitMessageUTF8Safety(t *testing.T) {
t.Run("ASCII short", func(t *testing.T) {
result := splitMessage("hello", 10)
if len(result) != 1 || result[0] != "hello" {
t.Fatalf("expected single chunk 'hello', got %v", result)
}
})
t.Run("CJK characters split at rune boundary", func(t *testing.T) {
// 10 CJK characters (each 3 bytes in UTF-8), total 30 bytes
input := "你好世界测试一二三四"
if len([]rune(input)) != 10 {
t.Fatalf("expected 10 runes, got %d", len([]rune(input)))
}
// maxLen=5 runes should split into 2 chunks of 5 runes each
chunks := splitMessage(input, 5)
if len(chunks) != 2 {
t.Fatalf("expected 2 chunks, got %d: %v", len(chunks), chunks)
}
if chunks[0] != "你好世界测" {
t.Errorf("chunk[0] = %q, want %q", chunks[0], "你好世界测")
}
if chunks[1] != "试一二三四" {
t.Errorf("chunk[1] = %q, want %q", chunks[1], "试一二三四")
}
})
t.Run("emoji split at rune boundary", func(t *testing.T) {
// Emoji: 4 bytes each in UTF-8
input := "😀😁😂🤣😄😅"
runes := []rune(input)
if len(runes) != 6 {
t.Fatalf("expected 6 runes, got %d", len(runes))
}
chunks := splitMessage(input, 3)
if len(chunks) != 2 {
t.Fatalf("expected 2 chunks, got %d: %v", len(chunks), chunks)
}
if chunks[0] != "😀😁😂" {
t.Errorf("chunk[0] = %q, want %q", chunks[0], "😀😁😂")
}
if chunks[1] != "🤣😄😅" {
t.Errorf("chunk[1] = %q, want %q", chunks[1], "🤣😄😅")
}
})
t.Run("prefers newline split", func(t *testing.T) {
input := "abcde\nfghij"
chunks := splitMessage(input, 8)
if len(chunks) != 2 {
t.Fatalf("expected 2 chunks, got %d: %v", len(chunks), chunks)
}
// Should split at newline (rune index 5), which is >= 8/2=4
if chunks[0] != "abcde\n" {
t.Errorf("chunk[0] = %q, want %q", chunks[0], "abcde\n")
}
if chunks[1] != "fghij" {
t.Errorf("chunk[1] = %q, want %q", chunks[1], "fghij")
}
})
t.Run("CJK with newline split", func(t *testing.T) {
input := "你好\n世界测试一二三四"
chunks := splitMessage(input, 5)
if len(chunks) < 2 {
t.Fatalf("expected at least 2 chunks, got %d: %v", len(chunks), chunks)
}
// First chunk should split at the newline
if chunks[0] != "你好\n" {
t.Errorf("chunk[0] = %q, want %q", chunks[0], "你好\n")
}
})
}
// ── setupMemoryFile / /cron setup / /bind setup ──────────────
type stubMemoryAgent struct {
stubAgent
memFile string
}
func (a *stubMemoryAgent) ProjectMemoryFile() string { return a.memFile }
func (a *stubMemoryAgent) GlobalMemoryFile() string { return "" }
type stubNativePromptAgent struct {
stubAgent
}
func (a *stubNativePromptAgent) HasSystemPromptSupport() bool { return true }
func TestSetupMemoryFile_WritesInstructions(t *testing.T) {
tmpDir := t.TempDir()
memFile := filepath.Join(tmpDir, "AGENTS.md")
p := &stubPlatformEngine{n: "plain"}
agent := &stubMemoryAgent{memFile: memFile}
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
result, baseName, err := e.setupMemoryFile()
if result != setupOK {
t.Fatalf("result = %d, want setupOK; err = %v", result, err)
}
if baseName != "AGENTS.md" {
t.Errorf("baseName = %q, want AGENTS.md", baseName)
}
content, _ := os.ReadFile(memFile)
if !strings.Contains(string(content), ccConnectInstructionMarker) {
t.Error("expected instruction marker in file")
}
if !strings.Contains(string(content), "cc-connect cron add") {
t.Error("expected cron instructions in file")
}
}
func TestSetupMemoryFile_Idempotent(t *testing.T) {
tmpDir := t.TempDir()
memFile := filepath.Join(tmpDir, "AGENTS.md")
p := &stubPlatformEngine{n: "plain"}
agent := &stubMemoryAgent{memFile: memFile}
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
r1, _, _ := e.setupMemoryFile()
if r1 != setupOK {
t.Fatalf("first call: result = %d, want setupOK", r1)
}
r2, _, _ := e.setupMemoryFile()
if r2 != setupExists {
t.Fatalf("second call: result = %d, want setupExists", r2)
}
}
func TestSetupMemoryFile_NativeAgent(t *testing.T) {
p := &stubPlatformEngine{n: "plain"}
agent := &stubNativePromptAgent{}
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
result, _, _ := e.setupMemoryFile()
if result != setupNative {
t.Fatalf("result = %d, want setupNative", result)
}
}
func TestSetupMemoryFile_NoMemorySupport(t *testing.T) {
p := &stubPlatformEngine{n: "plain"}
agent := &stubAgent{}
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
result, _, _ := e.setupMemoryFile()
if result != setupNoMemory {
t.Fatalf("result = %d, want setupNoMemory", result)
}
}
func TestCmdCronSetup_WritesAndReplies(t *testing.T) {
tmpDir := t.TempDir()
memFile := filepath.Join(tmpDir, "AGENTS.md")
p := &stubPlatformEngine{n: "plain"}
agent := &stubMemoryAgent{memFile: memFile}
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
e.cronScheduler = &CronScheduler{}
msg := &Message{SessionKey: "test:user1", ReplyCtx: "ctx"}
e.cmdCron(p, msg, []string{"setup"})
if len(p.sent) != 1 {
t.Fatalf("sent = %d, want 1", len(p.sent))
}
if !strings.Contains(p.sent[0], "AGENTS.md") {
t.Errorf("reply = %q, want to contain filename", p.sent[0])
}
if !strings.Contains(p.sent[0], "natural language") {
t.Errorf("reply = %q, want cron-specific success message", p.sent[0])
}
content, _ := os.ReadFile(memFile)
if !strings.Contains(string(content), ccConnectInstructionMarker) {
t.Error("expected instructions written to file")
}
}
func TestCmdCronSetup_NativeAgentSkips(t *testing.T) {
p := &stubPlatformEngine{n: "plain"}
agent := &stubNativePromptAgent{}
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
e.cronScheduler = &CronScheduler{}
msg := &Message{SessionKey: "test:user1", ReplyCtx: "ctx"}
e.cmdCron(p, msg, []string{"setup"})
if len(p.sent) != 1 {
t.Fatalf("sent = %d, want 1", len(p.sent))
}
if !strings.Contains(p.sent[0], "natively supports") {
t.Errorf("reply = %q, want native support message", p.sent[0])
}
}
func TestCmdBindSetup_UsesSharedLogic(t *testing.T) {
tmpDir := t.TempDir()
memFile := filepath.Join(tmpDir, "AGENTS.md")
p := &stubPlatformEngine{n: "plain"}
agent := &stubMemoryAgent{memFile: memFile}
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
msg := &Message{SessionKey: "test:user1", ReplyCtx: "ctx"}
e.cmdBindSetup(p, msg)
if len(p.sent) != 1 {
t.Fatalf("sent = %d, want 1", len(p.sent))
}
if !strings.Contains(p.sent[0], "AGENTS.md") {
t.Errorf("reply = %q, want to contain filename", p.sent[0])
}
content, _ := os.ReadFile(memFile)
if !strings.Contains(string(content), ccConnectInstructionMarker) {
t.Error("expected instructions written to file")
}
}
func TestDrainEventsClosedChannel(t *testing.T) {
ch := make(chan Event, 2)
ch <- Event{Type: EventToolUse, Content: "a"}
ch <- Event{Type: EventToolUse, Content: "b"}
close(ch)
done := make(chan struct{})
go func() {
drainEvents(ch)
close(done)
}()
select {
case <-done:
// ok — returned promptly
case <-time.After(2 * time.Second):
t.Fatal("drainEvents did not return on closed channel (infinite loop)")
}
}
func TestDrainEventsOpenChannel(t *testing.T) {
ch := make(chan Event, 3)
ch <- Event{Type: EventToolUse, Content: "a"}
ch <- Event{Type: EventToolUse, Content: "b"}
done := make(chan struct{})
go func() {
drainEvents(ch)
close(done)
}()
select {
case <-done:
// ok
case <-time.After(2 * time.Second):
t.Fatal("drainEvents did not return on open channel with buffered events")
}
// Channel should now be empty.
select {
case <-ch:
t.Fatal("expected channel to be drained")
default:
}
}