13141 lines
408 KiB
Go
13141 lines
408 KiB
Go
package core
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
"unicode/utf8"
|
|
)
|
|
|
|
// --- 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
|
|
mu sync.Mutex
|
|
}
|
|
|
|
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.mu.Lock()
|
|
p.sent = append(p.sent, content)
|
|
p.mu.Unlock()
|
|
return nil
|
|
}
|
|
func (p *stubPlatformEngine) Send(_ context.Context, _ any, content string) error {
|
|
p.mu.Lock()
|
|
p.sent = append(p.sent, content)
|
|
p.mu.Unlock()
|
|
return nil
|
|
}
|
|
func (p *stubPlatformEngine) Stop() error { return nil }
|
|
|
|
func (p *stubPlatformEngine) getSent() []string {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
cp := make([]string, len(p.sent))
|
|
copy(cp, p.sent)
|
|
return cp
|
|
}
|
|
|
|
func (p *stubPlatformEngine) clearSent() {
|
|
p.mu.Lock()
|
|
p.sent = nil
|
|
p.mu.Unlock()
|
|
}
|
|
|
|
type recallCheckingPlatform struct {
|
|
stubPlatformEngine
|
|
recalled bool
|
|
checked []any
|
|
}
|
|
|
|
func (p *recallCheckingPlatform) IsMessageRecalled(_ context.Context, replyCtx any) (bool, error) {
|
|
p.mu.Lock()
|
|
p.checked = append(p.checked, replyCtx)
|
|
p.mu.Unlock()
|
|
return p.recalled, nil
|
|
}
|
|
|
|
func (p *recallCheckingPlatform) checkedReplyCtxs() []any {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
out := make([]any, len(p.checked))
|
|
copy(out, p.checked)
|
|
return out
|
|
}
|
|
|
|
type stubCronReplyTargetPlatform struct {
|
|
stubPlatformEngine
|
|
reconstructSessionKey string
|
|
resolvedSessionKey string
|
|
resolveTitle string
|
|
}
|
|
|
|
func (p *stubCronReplyTargetPlatform) ReconstructReplyCtx(sessionKey string) (any, error) {
|
|
p.reconstructSessionKey = sessionKey
|
|
return "base-rctx", nil
|
|
}
|
|
|
|
func (p *stubCronReplyTargetPlatform) ResolveCronReplyTarget(sessionKey string, title string) (string, any, error) {
|
|
p.resolvedSessionKey = sessionKey
|
|
p.resolveTitle = title
|
|
return "discord:thread-fresh", "fresh-rctx", nil
|
|
}
|
|
|
|
type resultAgent struct {
|
|
session AgentSession
|
|
}
|
|
|
|
func (a *resultAgent) Name() string { return "stub" }
|
|
func (a *resultAgent) StartSession(_ context.Context, _ string) (AgentSession, error) {
|
|
return a.session, nil
|
|
}
|
|
func (a *resultAgent) ListSessions(_ context.Context) ([]AgentSessionInfo, error) { return nil, nil }
|
|
func (a *resultAgent) Stop() error { return nil }
|
|
|
|
type sessionEnvRecordingAgent struct {
|
|
stubAgent
|
|
session AgentSession
|
|
mu sync.Mutex
|
|
env []string
|
|
}
|
|
|
|
func (a *sessionEnvRecordingAgent) StartSession(_ context.Context, _ string) (AgentSession, error) {
|
|
if a.session != nil {
|
|
return a.session, nil
|
|
}
|
|
return &stubAgentSession{}, nil
|
|
}
|
|
|
|
func (a *sessionEnvRecordingAgent) SetSessionEnv(env []string) {
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
a.env = append([]string(nil), env...)
|
|
}
|
|
|
|
func (a *sessionEnvRecordingAgent) EnvValue(key string) string {
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
prefix := key + "="
|
|
for _, entry := range a.env {
|
|
if strings.HasPrefix(entry, prefix) {
|
|
return strings.TrimPrefix(entry, prefix)
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
type resultAgentSession struct {
|
|
events chan Event
|
|
result string
|
|
sendOnce sync.Once
|
|
sentPrompts []string
|
|
}
|
|
|
|
func newResultAgentSession(result string) *resultAgentSession {
|
|
return &resultAgentSession{
|
|
events: make(chan Event, 1),
|
|
result: result,
|
|
}
|
|
}
|
|
|
|
func (s *resultAgentSession) Send(prompt string, _ []ImageAttachment, _ []FileAttachment) error {
|
|
s.sentPrompts = append(s.sentPrompts, prompt)
|
|
s.sendOnce.Do(func() {
|
|
s.events <- Event{Type: EventResult, Content: s.result, Done: true}
|
|
})
|
|
return nil
|
|
}
|
|
|
|
func (s *resultAgentSession) RespondPermission(_ string, _ PermissionResult) error { return nil }
|
|
func (s *resultAgentSession) Events() <-chan Event { return s.events }
|
|
func (s *resultAgentSession) CurrentSessionID() string { return "result-session" }
|
|
func (s *resultAgentSession) Alive() bool { return true }
|
|
func (s *resultAgentSession) Close() error { return nil }
|
|
|
|
type stubLifecyclePlatform struct {
|
|
stubPlatformEngine
|
|
handler PlatformLifecycleHandler
|
|
registerCalls int
|
|
registeredCommands []BotCommandInfo
|
|
cardNavSetCalls int
|
|
startCalls int
|
|
stopCalls int
|
|
}
|
|
|
|
func (p *stubLifecyclePlatform) Start(MessageHandler) error {
|
|
p.startCalls++
|
|
return nil
|
|
}
|
|
|
|
func (p *stubLifecyclePlatform) Stop() error {
|
|
p.stopCalls++
|
|
return nil
|
|
}
|
|
|
|
func (p *stubLifecyclePlatform) SetLifecycleHandler(h PlatformLifecycleHandler) {
|
|
p.handler = h
|
|
}
|
|
|
|
func (p *stubLifecyclePlatform) RegisterCommands(commands []BotCommandInfo) error {
|
|
p.registerCalls++
|
|
p.registeredCommands = append([]BotCommandInfo(nil), commands...)
|
|
return nil
|
|
}
|
|
|
|
func (p *stubLifecyclePlatform) SetCardNavigationHandler(CardNavigationHandler) {
|
|
p.cardNavSetCalls++
|
|
}
|
|
|
|
type blockingRegisterPlatform struct {
|
|
stubLifecyclePlatform
|
|
registerStarted chan struct{}
|
|
allowRegister chan struct{}
|
|
stopCalled chan struct{}
|
|
registerOnce sync.Once
|
|
stopOnce sync.Once
|
|
}
|
|
|
|
func newBlockingRegisterPlatform(name string) *blockingRegisterPlatform {
|
|
return &blockingRegisterPlatform{
|
|
stubLifecyclePlatform: stubLifecyclePlatform{
|
|
stubPlatformEngine: stubPlatformEngine{n: name},
|
|
},
|
|
registerStarted: make(chan struct{}),
|
|
allowRegister: make(chan struct{}),
|
|
stopCalled: make(chan struct{}),
|
|
}
|
|
}
|
|
|
|
func (p *blockingRegisterPlatform) RegisterCommands([]BotCommandInfo) error {
|
|
p.registerOnce.Do(func() {
|
|
close(p.registerStarted)
|
|
})
|
|
<-p.allowRegister
|
|
p.registerCalls++
|
|
return nil
|
|
}
|
|
|
|
func (p *blockingRegisterPlatform) Stop() error {
|
|
p.stopCalls++
|
|
p.stopOnce.Do(func() {
|
|
close(p.stopCalled)
|
|
})
|
|
return nil
|
|
}
|
|
|
|
type stubMediaPlatform struct {
|
|
stubPlatformEngine
|
|
images []ImageAttachment
|
|
files []FileAttachment
|
|
}
|
|
|
|
func (p *stubMediaPlatform) SendImage(_ context.Context, _ any, img ImageAttachment) error {
|
|
p.images = append(p.images, img)
|
|
return nil
|
|
}
|
|
|
|
func (p *stubMediaPlatform) SendFile(_ context.Context, _ any, file FileAttachment) error {
|
|
p.files = append(p.files, file)
|
|
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
|
|
mu sync.Mutex
|
|
repliedCards []*Card
|
|
sentCards []*Card
|
|
refreshedCards []*Card
|
|
cardErr error
|
|
}
|
|
|
|
func (p *stubCardPlatform) ReplyCard(_ context.Context, _ any, card *Card) error {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
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 {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
if p.cardErr != nil {
|
|
return p.cardErr
|
|
}
|
|
p.sentCards = append(p.sentCards, card)
|
|
return nil
|
|
}
|
|
|
|
func (p *stubCardPlatform) ReconstructReplyCtx(sessionKey string) (any, error) {
|
|
return "reconstructed-ctx:" + sessionKey, nil
|
|
}
|
|
|
|
func (p *stubCardPlatform) RefreshCard(_ context.Context, _ string, card *Card) error {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
if p.cardErr != nil {
|
|
return p.cardErr
|
|
}
|
|
p.refreshedCards = append(p.refreshedCards, card)
|
|
return nil
|
|
}
|
|
|
|
func (p *stubCardPlatform) getRefreshedCards() []*Card {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
dst := make([]*Card, len(p.refreshedCards))
|
|
copy(dst, p.refreshedCards)
|
|
return dst
|
|
}
|
|
|
|
type stubCompactProgressPlatform struct {
|
|
stubPlatformEngine
|
|
style string
|
|
supportPayload bool
|
|
previewMu sync.Mutex
|
|
previewStarts []string
|
|
previewEdits []string
|
|
}
|
|
|
|
func (p *stubCompactProgressPlatform) ProgressStyle() string {
|
|
if p.style == "" {
|
|
return "compact"
|
|
}
|
|
return p.style
|
|
}
|
|
|
|
func (p *stubCompactProgressPlatform) SupportsProgressCardPayload() bool {
|
|
return p.supportPayload
|
|
}
|
|
|
|
func (p *stubCompactProgressPlatform) SendPreviewStart(_ context.Context, _ any, content string) (any, error) {
|
|
p.previewMu.Lock()
|
|
p.previewStarts = append(p.previewStarts, content)
|
|
p.previewMu.Unlock()
|
|
return "preview-handle", nil
|
|
}
|
|
|
|
func (p *stubCompactProgressPlatform) UpdateMessage(_ context.Context, _ any, content string) error {
|
|
p.previewMu.Lock()
|
|
p.previewEdits = append(p.previewEdits, content)
|
|
p.previewMu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
func (p *stubCompactProgressPlatform) BuildRichCard(status CardStatus, title string, steps []ToolStep, markdown string, streaming bool, elapsed time.Duration) string {
|
|
var b strings.Builder
|
|
fmt.Fprintf(&b, "rich status=%s title=%s streaming=%t elapsed=%s\n", status, title, streaming, elapsed)
|
|
for _, step := range steps {
|
|
fmt.Fprintf(&b, "step=%+v\n", step)
|
|
}
|
|
if markdown != "" {
|
|
fmt.Fprintf(&b, "markdown=%s\n", markdown)
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func (p *stubCompactProgressPlatform) getPreviewStarts() []string {
|
|
p.previewMu.Lock()
|
|
defer p.previewMu.Unlock()
|
|
out := make([]string, len(p.previewStarts))
|
|
copy(out, p.previewStarts)
|
|
return out
|
|
}
|
|
|
|
func (p *stubCompactProgressPlatform) getPreviewEdits() []string {
|
|
p.previewMu.Lock()
|
|
defer p.previewMu.Unlock()
|
|
out := make([]string, len(p.previewEdits))
|
|
copy(out, p.previewEdits)
|
|
return out
|
|
}
|
|
|
|
type stubModelModeAgent struct {
|
|
stubAgent
|
|
model string
|
|
mode string
|
|
reasoningEffort string
|
|
providers []ProviderConfig
|
|
active string
|
|
}
|
|
|
|
type stubStrictModelAgent struct {
|
|
stubModelModeAgent
|
|
models []ModelOption
|
|
calls int
|
|
}
|
|
|
|
type stubLiveModeSession struct {
|
|
stubAgentSession
|
|
modes []string
|
|
}
|
|
|
|
func (s *stubLiveModeSession) SetLiveMode(mode string) bool {
|
|
s.modes = append(s.modes, mode)
|
|
return true
|
|
}
|
|
|
|
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", Alias: "gpt"},
|
|
{Name: "gpt-4.1-mini", Desc: "Fast"},
|
|
}
|
|
}
|
|
|
|
func (a *stubStrictModelAgent) AvailableModels(_ context.Context) []ModelOption {
|
|
a.calls++
|
|
return append([]ModelOption(nil), a.models...)
|
|
}
|
|
|
|
func (a *stubModelModeAgent) SetProviders(providers []ProviderConfig) {
|
|
a.providers = providers
|
|
}
|
|
|
|
func (a *stubModelModeAgent) GetActiveProvider() *ProviderConfig {
|
|
for i := range a.providers {
|
|
if a.providers[i].Name == a.active {
|
|
return &a.providers[i]
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (a *stubModelModeAgent) ListProviders() []ProviderConfig {
|
|
result := make([]ProviderConfig, len(a.providers))
|
|
copy(result, a.providers)
|
|
return result
|
|
}
|
|
|
|
func (a *stubModelModeAgent) 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
|
|
}
|
|
|
|
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 namedStubModelModeAgent struct {
|
|
stubModelModeAgent
|
|
name string
|
|
}
|
|
|
|
func (a *namedStubModelModeAgent) Name() string {
|
|
if a.name == "" {
|
|
return "named-stub-model"
|
|
}
|
|
return a.name
|
|
}
|
|
|
|
type namedStubWorkspaceOptionAgent struct {
|
|
namedStubModelModeAgent
|
|
opts map[string]any
|
|
runAsUser string
|
|
runAsEnv []string
|
|
}
|
|
|
|
func (a *namedStubWorkspaceOptionAgent) WorkspaceAgentOptions() map[string]any {
|
|
out := make(map[string]any, len(a.opts))
|
|
for k, v := range a.opts {
|
|
out[k] = v
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (a *namedStubWorkspaceOptionAgent) GetRunAsUser() string { return a.runAsUser }
|
|
|
|
func (a *namedStubWorkspaceOptionAgent) GetRunAsEnv() []string {
|
|
if len(a.runAsEnv) == 0 {
|
|
return nil
|
|
}
|
|
out := make([]string, len(a.runAsEnv))
|
|
copy(out, a.runAsEnv)
|
|
return out
|
|
}
|
|
|
|
type stubWorkDirAgent struct {
|
|
stubAgent
|
|
workDir string
|
|
}
|
|
|
|
func (a *stubWorkDirAgent) SetWorkDir(dir string) {
|
|
a.workDir = dir
|
|
}
|
|
|
|
func (a *stubWorkDirAgent) GetWorkDir() string {
|
|
return a.workDir
|
|
}
|
|
|
|
type namedStubWorkDirAgent struct {
|
|
stubWorkDirAgent
|
|
name string
|
|
}
|
|
|
|
func (a *namedStubWorkDirAgent) Name() string {
|
|
if a.name == "" {
|
|
return "named-stub-workdir"
|
|
}
|
|
return a.name
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// waitDeleteModePhase polls the delete-mode state for the given session key
|
|
// until it reaches the target phase or the timeout expires.
|
|
func waitDeleteModePhase(t *testing.T, e *Engine, sessionKey, targetPhase string) {
|
|
t.Helper()
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
dm := e.getDeleteModeState(sessionKey)
|
|
if dm != nil && dm.phase == targetPhase {
|
|
return
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
t.Fatalf("timed out waiting for delete mode phase %q", targetPhase)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
type stubReplyFooterAgent struct {
|
|
stubModelModeAgent
|
|
workDir string
|
|
report *UsageReport
|
|
err error
|
|
}
|
|
|
|
func (a *stubReplyFooterAgent) SetWorkDir(dir string) {
|
|
a.workDir = dir
|
|
}
|
|
|
|
func (a *stubReplyFooterAgent) GetWorkDir() string {
|
|
return a.workDir
|
|
}
|
|
|
|
func (a *stubReplyFooterAgent) GetUsage(_ context.Context) (*UsageReport, error) {
|
|
return a.report, a.err
|
|
}
|
|
|
|
func newTestEngine() *Engine {
|
|
return NewEngine("test", &stubAgent{}, []Platform{&stubPlatformEngine{n: "test"}}, "", LangEnglish)
|
|
}
|
|
|
|
func TestEngineSendToSessionWithAttachments(t *testing.T) {
|
|
p := &stubMediaPlatform{stubPlatformEngine: stubPlatformEngine{n: "test"}}
|
|
e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish)
|
|
e.interactiveStates["session-1"] = &interactiveState{
|
|
platform: p,
|
|
replyCtx: "ctx-1",
|
|
}
|
|
|
|
err := e.SendToSessionWithAttachments(
|
|
"session-1",
|
|
"delivery ready",
|
|
[]ImageAttachment{{MimeType: "image/png", Data: []byte("img"), FileName: "chart.png"}},
|
|
[]FileAttachment{{MimeType: "text/plain", Data: []byte("doc"), FileName: "report.txt"}},
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("SendToSessionWithAttachments returned error: %v", err)
|
|
}
|
|
|
|
if got := p.getSent(); len(got) != 1 || got[0] != "delivery ready" {
|
|
t.Fatalf("sent text = %#v, want one message", got)
|
|
}
|
|
if len(p.images) != 1 || p.images[0].FileName != "chart.png" {
|
|
t.Fatalf("images = %#v", p.images)
|
|
}
|
|
if len(p.files) != 1 || p.files[0].FileName != "report.txt" {
|
|
t.Fatalf("files = %#v", p.files)
|
|
}
|
|
}
|
|
|
|
func TestEngineSendToSessionWithAttachments_UnsupportedPlatform(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "test"}
|
|
e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish)
|
|
e.interactiveStates["session-1"] = &interactiveState{
|
|
platform: p,
|
|
replyCtx: "ctx-1",
|
|
}
|
|
|
|
err := e.SendToSessionWithAttachments(
|
|
"session-1",
|
|
"delivery ready",
|
|
[]ImageAttachment{{MimeType: "image/png", Data: []byte("img"), FileName: "chart.png"}},
|
|
nil,
|
|
)
|
|
if err == nil {
|
|
t.Fatal("expected unsupported attachment send to fail")
|
|
}
|
|
if got := p.getSent(); len(got) != 0 {
|
|
t.Fatalf("sent text = %#v, want no sends on failure", got)
|
|
}
|
|
}
|
|
|
|
func TestEngineSendToSessionWithAttachments_DisabledByConfig(t *testing.T) {
|
|
p := &stubMediaPlatform{stubPlatformEngine: stubPlatformEngine{n: "test"}}
|
|
e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish)
|
|
e.SetAttachmentSendEnabled(false)
|
|
e.interactiveStates["session-1"] = &interactiveState{
|
|
platform: p,
|
|
replyCtx: "ctx-1",
|
|
}
|
|
|
|
err := e.SendToSessionWithAttachments(
|
|
"session-1",
|
|
"delivery ready",
|
|
nil,
|
|
[]FileAttachment{{MimeType: "text/plain", Data: []byte("doc"), FileName: "report.txt"}},
|
|
)
|
|
if err == nil {
|
|
t.Fatal("expected attachment send to be blocked")
|
|
}
|
|
if !errors.Is(err, ErrAttachmentSendDisabled) {
|
|
t.Fatalf("err = %v, want ErrAttachmentSendDisabled", err)
|
|
}
|
|
if got := p.getSent(); len(got) != 0 {
|
|
t.Fatalf("sent text = %#v, want no sends when disabled", got)
|
|
}
|
|
if len(p.files) != 0 {
|
|
t.Fatalf("files = %#v, want no files sent when disabled", p.files)
|
|
}
|
|
}
|
|
|
|
func TestEngineSendToSessionWithAttachments_MultiWorkspaceRawSessionKey(t *testing.T) {
|
|
p := &stubMediaPlatform{stubPlatformEngine: stubPlatformEngine{n: "test"}}
|
|
e := NewEngine("test", &stubAgent{}, []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)
|
|
}
|
|
normalizedWsDir := normalizeWorkspacePath(wsDir)
|
|
channelID := "C123"
|
|
rawKey := "slack:" + channelID + ":U1"
|
|
e.workspaceBindings.Bind("project:test", channelID, "chan", normalizedWsDir)
|
|
|
|
iKey := normalizedWsDir + ":" + rawKey
|
|
e.interactiveStates[iKey] = &interactiveState{
|
|
platform: p,
|
|
replyCtx: "ctx-1",
|
|
}
|
|
|
|
err := e.SendToSessionWithAttachments(rawKey, "delivery ready", nil, nil)
|
|
if err != nil {
|
|
t.Fatalf("SendToSessionWithAttachments returned error: %v", err)
|
|
}
|
|
if got := p.getSent(); len(got) != 1 || got[0] != "delivery ready" {
|
|
t.Fatalf("sent text = %#v, want one message", got)
|
|
}
|
|
}
|
|
|
|
// stubProactiveSendPlatform implements ReplyContextReconstruct for proactive
|
|
// SendToSessionWithAttachments when there is no interactive session.
|
|
type stubProactiveSendPlatform struct {
|
|
stubMediaPlatform
|
|
reconstructKey string
|
|
}
|
|
|
|
func (p *stubProactiveSendPlatform) ReconstructReplyCtx(sessionKey string) (any, error) {
|
|
p.reconstructKey = sessionKey
|
|
return "proactive-rctx", nil
|
|
}
|
|
|
|
func TestEngineSendToSessionWithAttachments_WorkspacePrefixedSessionKey(t *testing.T) {
|
|
p := &stubProactiveSendPlatform{
|
|
stubMediaPlatform: stubMediaPlatform{stubPlatformEngine: stubPlatformEngine{n: "slack"}},
|
|
}
|
|
e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish)
|
|
|
|
prefixed := "/tmp/myproject:slack:C123:U1"
|
|
err := e.SendToSessionWithAttachments(prefixed, "delivery ready", nil, nil)
|
|
if err != nil {
|
|
t.Fatalf("SendToSessionWithAttachments returned error: %v", err)
|
|
}
|
|
if p.reconstructKey != "slack:C123:U1" {
|
|
t.Fatalf("ReconstructReplyCtx key = %q, want slack:C123:U1", p.reconstructKey)
|
|
}
|
|
if got := p.getSent(); len(got) != 1 || got[0] != "delivery ready" {
|
|
t.Fatalf("sent text = %#v, want one message", got)
|
|
}
|
|
}
|
|
|
|
func TestEngineStart_DefersAsyncPlatformReadyInitialization(t *testing.T) {
|
|
p := &stubLifecyclePlatform{stubPlatformEngine: stubPlatformEngine{n: "telegram"}}
|
|
e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish)
|
|
e.AddCommand("help", "help", "", "", "", "test")
|
|
|
|
if err := e.Start(); err != nil {
|
|
t.Fatalf("Start: %v", err)
|
|
}
|
|
if p.handler == nil {
|
|
t.Fatal("lifecycle handler not installed")
|
|
}
|
|
if p.registerCalls != 0 {
|
|
t.Fatalf("registerCalls = %d, want 0 before ready", p.registerCalls)
|
|
}
|
|
if p.cardNavSetCalls != 0 {
|
|
t.Fatalf("cardNavSetCalls = %d, want 0 before ready", p.cardNavSetCalls)
|
|
}
|
|
}
|
|
|
|
func TestEngine_OnPlatformReady_IsIdempotentUntilUnavailable(t *testing.T) {
|
|
p := &stubLifecyclePlatform{stubPlatformEngine: stubPlatformEngine{n: "telegram"}}
|
|
e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish)
|
|
e.AddCommand("help", "help", "", "", "", "test")
|
|
|
|
if err := e.Start(); err != nil {
|
|
t.Fatalf("Start: %v", err)
|
|
}
|
|
|
|
e.OnPlatformReady(p)
|
|
e.OnPlatformReady(p)
|
|
|
|
if p.registerCalls != 1 {
|
|
t.Fatalf("registerCalls = %d, want 1", p.registerCalls)
|
|
}
|
|
if p.cardNavSetCalls != 1 {
|
|
t.Fatalf("cardNavSetCalls = %d, want 1", p.cardNavSetCalls)
|
|
}
|
|
|
|
e.OnPlatformUnavailable(p, errors.New("lost"))
|
|
e.OnPlatformReady(p)
|
|
|
|
if p.registerCalls != 2 {
|
|
t.Fatalf("registerCalls after recover = %d, want 2", p.registerCalls)
|
|
}
|
|
}
|
|
|
|
func TestEngine_OnPlatformUnavailable_IsIdempotent(t *testing.T) {
|
|
p := &stubLifecyclePlatform{stubPlatformEngine: stubPlatformEngine{n: "telegram"}}
|
|
e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish)
|
|
e.AddCommand("help", "help", "", "", "", "test")
|
|
|
|
if err := e.Start(); err != nil {
|
|
t.Fatalf("Start: %v", err)
|
|
}
|
|
|
|
e.OnPlatformReady(p)
|
|
e.OnPlatformUnavailable(p, errors.New("lost"))
|
|
e.OnPlatformUnavailable(p, errors.New("lost-again"))
|
|
e.OnPlatformReady(p)
|
|
|
|
if p.registerCalls != 2 {
|
|
t.Fatalf("registerCalls after duplicate unavailable = %d, want 2", p.registerCalls)
|
|
}
|
|
}
|
|
|
|
func TestEngine_LifecycleCallbacksIgnoredAfterStopBegins(t *testing.T) {
|
|
p := &stubLifecyclePlatform{stubPlatformEngine: stubPlatformEngine{n: "telegram"}}
|
|
e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish)
|
|
e.AddCommand("help", "help", "", "", "", "test")
|
|
|
|
if err := e.Start(); err != nil {
|
|
t.Fatalf("Start: %v", err)
|
|
}
|
|
|
|
if err := e.Stop(); err != nil {
|
|
t.Fatalf("Stop: %v", err)
|
|
}
|
|
e.OnPlatformReady(p)
|
|
e.OnPlatformUnavailable(p, errors.New("late"))
|
|
|
|
if p.registerCalls != 0 {
|
|
t.Fatalf("registerCalls = %d, want 0 after stop", p.registerCalls)
|
|
}
|
|
}
|
|
|
|
func TestEngine_StopDoesNotWaitForBlockedPlatformCapabilityInit(t *testing.T) {
|
|
p := newBlockingRegisterPlatform("telegram")
|
|
e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish)
|
|
e.AddCommand("help", "help", "", "", "", "test")
|
|
|
|
if err := e.Start(); err != nil {
|
|
t.Fatalf("Start: %v", err)
|
|
}
|
|
|
|
readyDone := make(chan struct{})
|
|
go func() {
|
|
e.OnPlatformReady(p)
|
|
close(readyDone)
|
|
}()
|
|
|
|
select {
|
|
case <-p.registerStarted:
|
|
case <-time.After(200 * time.Millisecond):
|
|
t.Fatal("RegisterCommands was not called")
|
|
}
|
|
|
|
stopDone := make(chan error, 1)
|
|
go func() {
|
|
stopDone <- e.Stop()
|
|
}()
|
|
|
|
select {
|
|
case err := <-stopDone:
|
|
if err != nil {
|
|
t.Fatalf("Stop: %v", err)
|
|
}
|
|
case <-time.After(200 * time.Millisecond):
|
|
t.Fatal("Stop blocked on platform capability initialization")
|
|
}
|
|
|
|
select {
|
|
case <-p.stopCalled:
|
|
case <-time.After(200 * time.Millisecond):
|
|
t.Fatal("platform Stop was not called while RegisterCommands was blocked")
|
|
}
|
|
|
|
close(p.allowRegister)
|
|
|
|
select {
|
|
case <-readyDone:
|
|
case <-time.After(200 * time.Millisecond):
|
|
t.Fatal("OnPlatformReady did not finish after RegisterCommands was released")
|
|
}
|
|
}
|
|
|
|
func TestProcessInteractiveEvents_SuppressesDuplicateSideChannelText(t *testing.T) {
|
|
p := &stubMediaPlatform{stubPlatformEngine: stubPlatformEngine{n: "test"}}
|
|
e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish)
|
|
sessionKey := "test:user1"
|
|
session := e.sessions.GetOrCreateActive(sessionKey)
|
|
agentSession := newControllableSession("s1")
|
|
state := &interactiveState{
|
|
agentSession: agentSession,
|
|
platform: p,
|
|
replyCtx: "ctx-1",
|
|
}
|
|
e.interactiveStates[sessionKey] = state
|
|
|
|
sideText := "已发送 AGENTS.md 文件给你。"
|
|
if err := e.SendToSessionWithAttachments(sessionKey, sideText, nil, []FileAttachment{{
|
|
MimeType: "text/markdown",
|
|
Data: []byte("body"),
|
|
FileName: "AGENTS.md",
|
|
}}); err != nil {
|
|
t.Fatalf("SendToSessionWithAttachments returned error: %v", err)
|
|
}
|
|
|
|
agentSession.events <- Event{Type: EventResult, Content: sideText, Done: true}
|
|
e.processInteractiveEvents(state, session, e.sessions, sessionKey, "m1", time.Now(), nil, nil, nil)
|
|
|
|
if got := p.getSent(); len(got) != 1 || got[0] != sideText {
|
|
t.Fatalf("sent text = %#v, want one side-channel message", got)
|
|
}
|
|
}
|
|
|
|
func TestProcessInteractiveEvents_SuppressesDuplicateSideChannelTextWithContextIndicator(t *testing.T) {
|
|
p := &stubMediaPlatform{stubPlatformEngine: stubPlatformEngine{n: "test"}}
|
|
e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish)
|
|
sessionKey := "test:user1"
|
|
session := e.sessions.GetOrCreateActive(sessionKey)
|
|
agentSession := newControllableSession("s1")
|
|
state := &interactiveState{
|
|
agentSession: agentSession,
|
|
platform: p,
|
|
replyCtx: "ctx-1",
|
|
}
|
|
e.interactiveStates[sessionKey] = state
|
|
|
|
sideText := "已发送 AGENTS.md 文件给你。"
|
|
if err := e.SendToSessionWithAttachments(sessionKey, sideText, nil, []FileAttachment{{
|
|
MimeType: "text/markdown",
|
|
Data: []byte("body"),
|
|
FileName: "AGENTS.md",
|
|
}}); err != nil {
|
|
t.Fatalf("SendToSessionWithAttachments returned error: %v", err)
|
|
}
|
|
|
|
agentSession.events <- Event{Type: EventResult, Content: sideText, InputTokens: 52000, Done: true}
|
|
e.processInteractiveEvents(state, session, e.sessions, sessionKey, "m1", time.Now(), nil, nil, nil)
|
|
|
|
if got := p.getSent(); len(got) != 1 || got[0] != sideText {
|
|
t.Fatalf("sent text = %#v, want only the side-channel message without duplicate ctx reply", got)
|
|
}
|
|
}
|
|
|
|
func TestProcessInteractiveEvents_DoesNotSuppressDifferentFinalText(t *testing.T) {
|
|
p := &stubMediaPlatform{stubPlatformEngine: stubPlatformEngine{n: "test"}}
|
|
e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish)
|
|
sessionKey := "test:user1"
|
|
session := e.sessions.GetOrCreateActive(sessionKey)
|
|
agentSession := newControllableSession("s1")
|
|
state := &interactiveState{
|
|
agentSession: agentSession,
|
|
platform: p,
|
|
replyCtx: "ctx-1",
|
|
}
|
|
e.interactiveStates[sessionKey] = state
|
|
|
|
if err := e.SendToSessionWithAttachments(sessionKey, "已发送 AGENTS.md 文件给你。", nil, []FileAttachment{{
|
|
MimeType: "text/markdown",
|
|
Data: []byte("body"),
|
|
FileName: "AGENTS.md",
|
|
}}); err != nil {
|
|
t.Fatalf("SendToSessionWithAttachments returned error: %v", err)
|
|
}
|
|
|
|
finalText := "文件已发出,另外我也把使用方法整理好了。"
|
|
agentSession.events <- Event{Type: EventResult, Content: finalText, Done: true}
|
|
e.processInteractiveEvents(state, session, e.sessions, sessionKey, "m1", time.Now(), nil, nil, nil)
|
|
|
|
if got := p.getSent(); len(got) != 2 || got[0] == got[1] {
|
|
t.Fatalf("sent text = %#v, want side-channel and final reply", got)
|
|
}
|
|
if got := p.getSent()[1]; got != finalText {
|
|
t.Fatalf("final sent text = %q, want %q", got, finalText)
|
|
}
|
|
}
|
|
|
|
func TestProcessInteractiveEvents_AppendsReplyFooterWhenEnabled(t *testing.T) {
|
|
homeDir := t.TempDir()
|
|
t.Setenv("HOME", homeDir)
|
|
t.Setenv("USERPROFILE", homeDir)
|
|
|
|
agent := &stubReplyFooterAgent{
|
|
stubModelModeAgent: stubModelModeAgent{
|
|
model: "gpt-5.4",
|
|
reasoningEffort: "xhigh",
|
|
},
|
|
workDir: filepath.Join(homeDir, "codes", "cc-connect"),
|
|
report: &UsageReport{
|
|
Buckets: []UsageBucket{{
|
|
Name: "Rate limit",
|
|
Windows: []UsageWindow{{
|
|
Name: "Primary",
|
|
UsedPercent: 0,
|
|
WindowSeconds: 18000,
|
|
}},
|
|
}},
|
|
},
|
|
}
|
|
p := &stubPlatformEngine{n: "telegram"}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
e.SetReplyFooterEnabled(true)
|
|
|
|
sessionKey := "telegram:user-footer"
|
|
session := e.sessions.GetOrCreateActive(sessionKey)
|
|
agentSession := newControllableSession("s-footer")
|
|
state := &interactiveState{
|
|
agentSession: agentSession,
|
|
platform: p,
|
|
replyCtx: "ctx-footer",
|
|
}
|
|
e.interactiveStates[sessionKey] = state
|
|
|
|
agentSession.events <- Event{Type: EventResult, Content: "answer", Done: true}
|
|
e.processInteractiveEvents(state, session, e.sessions, sessionKey, "m-footer", time.Now(), nil, nil, state.replyCtx)
|
|
|
|
sent := p.getSent()
|
|
if len(sent) != 1 {
|
|
t.Fatalf("sent = %#v, want one final reply", sent)
|
|
}
|
|
want := "answer\n\n*gpt-5.4 · xhigh · 100% left · ~/codes/cc-connect*"
|
|
if sent[0] != want {
|
|
t.Fatalf("final reply = %q, want %q", sent[0], want)
|
|
}
|
|
}
|
|
|
|
func TestProcessInteractiveEvents_AppendsContextIndicatorInsideReplyFooter(t *testing.T) {
|
|
homeDir := t.TempDir()
|
|
t.Setenv("HOME", homeDir)
|
|
t.Setenv("USERPROFILE", homeDir)
|
|
|
|
agent := &stubReplyFooterAgent{
|
|
stubModelModeAgent: stubModelModeAgent{model: "glm-5.1"},
|
|
workDir: filepath.Join(homeDir, "code", "TechStudio", "projects", "core", "agents", "ceo"),
|
|
}
|
|
p := &stubPlatformEngine{n: "telegram"}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
e.SetReplyFooterEnabled(true)
|
|
|
|
sessionKey := "telegram:user-footer-context"
|
|
session := e.sessions.GetOrCreateActive(sessionKey)
|
|
agentSession := newControllableSession("s-footer-context")
|
|
state := &interactiveState{
|
|
agentSession: agentSession,
|
|
platform: p,
|
|
replyCtx: "ctx-footer-context",
|
|
agent: agent,
|
|
}
|
|
e.interactiveStates[sessionKey] = state
|
|
|
|
agentSession.events <- Event{Type: EventResult, Content: "answer", InputTokens: 28000, Done: true}
|
|
e.processInteractiveEvents(state, session, e.sessions, sessionKey, "m-footer-context", time.Now(), nil, nil, state.replyCtx)
|
|
|
|
sent := p.getSent()
|
|
if len(sent) != 1 {
|
|
t.Fatalf("sent = %#v, want one final reply", sent)
|
|
}
|
|
want := "answer\n\n*[ctx: ~14%] · glm-5.1 · ~/code/TechStudio/projects/core/agents/ceo*"
|
|
if sent[0] != want {
|
|
t.Fatalf("final reply = %q, want %q", sent[0], want)
|
|
}
|
|
}
|
|
|
|
func TestProcessInteractiveEvents_ToolSegmentsKeepFinalFooter(t *testing.T) {
|
|
homeDir := t.TempDir()
|
|
t.Setenv("HOME", homeDir)
|
|
t.Setenv("USERPROFILE", homeDir)
|
|
|
|
agent := &stubReplyFooterAgent{
|
|
stubModelModeAgent: stubModelModeAgent{model: "glm-5.1"},
|
|
workDir: filepath.Join(homeDir, "code", "TechStudio", "projects", "core", "agents", "ceo"),
|
|
}
|
|
p := &stubPlatformEngine{n: "telegram"}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
e.SetReplyFooterEnabled(true)
|
|
e.SetDisplayConfig(DisplayCfg{ThinkingMessages: true, ThinkingMaxLen: 300, ToolMaxLen: 500, ToolMessages: true})
|
|
|
|
sessionKey := "telegram:user-tool-footer"
|
|
session := e.sessions.GetOrCreateActive(sessionKey)
|
|
agentSession := newControllableSession("s-tool-footer")
|
|
state := &interactiveState{
|
|
agentSession: agentSession,
|
|
platform: p,
|
|
replyCtx: "ctx-tool-footer",
|
|
agent: agent,
|
|
}
|
|
e.interactiveStates[sessionKey] = state
|
|
|
|
agentSession.events <- Event{Type: EventText, Content: "先检查一下。"}
|
|
agentSession.events <- Event{Type: EventToolUse, ToolName: "Bash", ToolInput: "pwd"}
|
|
agentSession.events <- Event{Type: EventText, Content: "已处理完成。"}
|
|
agentSession.events <- Event{Type: EventResult, Content: "已处理完成。", InputTokens: 28000, Done: true}
|
|
e.processInteractiveEvents(state, session, e.sessions, sessionKey, "m-tool-footer", time.Now(), nil, nil, state.replyCtx)
|
|
|
|
sent := p.getSent()
|
|
if len(sent) == 0 {
|
|
t.Fatal("sent = nil, want final reply")
|
|
}
|
|
final := sent[len(sent)-1]
|
|
want := "已处理完成。\n\n*[ctx: ~14%] · glm-5.1 · ~/code/TechStudio/projects/core/agents/ceo*"
|
|
if final != want {
|
|
t.Fatalf("final reply = %q, want %q\nall sent = %#v", final, want, sent)
|
|
}
|
|
}
|
|
|
|
func TestProcessInteractiveEvents_DropsStandaloneEllipsisProgress(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "telegram"}
|
|
e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish)
|
|
e.SetDisplayConfig(DisplayCfg{ThinkingMessages: true, ThinkingMaxLen: 300, ToolMaxLen: 500, ToolMessages: true})
|
|
|
|
sessionKey := "telegram:user-ellipsis"
|
|
session := e.sessions.GetOrCreateActive(sessionKey)
|
|
agentSession := newControllableSession("s-ellipsis")
|
|
state := &interactiveState{
|
|
agentSession: agentSession,
|
|
platform: p,
|
|
replyCtx: "ctx-ellipsis",
|
|
}
|
|
e.interactiveStates[sessionKey] = state
|
|
|
|
agentSession.events <- Event{Type: EventThinking, Content: "..."}
|
|
agentSession.events <- Event{Type: EventText, Content: "..."}
|
|
agentSession.events <- Event{Type: EventResult, Content: "done", Done: true}
|
|
e.processInteractiveEvents(state, session, e.sessions, sessionKey, "m-ellipsis", time.Now(), nil, nil, state.replyCtx)
|
|
|
|
sent := p.getSent()
|
|
if len(sent) != 1 || sent[0] != "done" {
|
|
t.Fatalf("sent = %#v, want only final answer without standalone ellipsis progress", sent)
|
|
}
|
|
}
|
|
|
|
func TestProcessInteractiveEvents_DoesNotAppendReplyFooterWhenDisabled(t *testing.T) {
|
|
homeDir := t.TempDir()
|
|
t.Setenv("HOME", homeDir)
|
|
|
|
agent := &stubReplyFooterAgent{
|
|
stubModelModeAgent: stubModelModeAgent{
|
|
model: "gpt-5.4",
|
|
reasoningEffort: "xhigh",
|
|
},
|
|
workDir: filepath.Join(homeDir, "codes", "cc-connect"),
|
|
report: &UsageReport{
|
|
Buckets: []UsageBucket{{
|
|
Name: "Rate limit",
|
|
Windows: []UsageWindow{{
|
|
Name: "Primary",
|
|
UsedPercent: 0,
|
|
WindowSeconds: 18000,
|
|
}},
|
|
}},
|
|
},
|
|
}
|
|
p := &stubPlatformEngine{n: "telegram"}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
e.SetReplyFooterEnabled(false)
|
|
|
|
sessionKey := "telegram:user-footer-off"
|
|
session := e.sessions.GetOrCreateActive(sessionKey)
|
|
agentSession := newControllableSession("s-footer-off")
|
|
state := &interactiveState{
|
|
agentSession: agentSession,
|
|
platform: p,
|
|
replyCtx: "ctx-footer-off",
|
|
}
|
|
e.interactiveStates[sessionKey] = state
|
|
|
|
agentSession.events <- Event{Type: EventResult, Content: "answer", Done: true}
|
|
e.processInteractiveEvents(state, session, e.sessions, sessionKey, "m-footer-off", time.Now(), nil, nil, state.replyCtx)
|
|
|
|
sent := p.getSent()
|
|
if len(sent) != 1 {
|
|
t.Fatalf("sent = %#v, want one final reply", sent)
|
|
}
|
|
if sent[0] != "answer" {
|
|
t.Fatalf("final reply = %q, want plain answer without footer", sent[0])
|
|
}
|
|
}
|
|
|
|
func TestProcessInteractiveEvents_ReplyFooterPrefersSessionRuntimeState(t *testing.T) {
|
|
homeDir := t.TempDir()
|
|
t.Setenv("HOME", homeDir)
|
|
t.Setenv("USERPROFILE", homeDir)
|
|
|
|
agent := &stubReplyFooterAgent{
|
|
stubModelModeAgent: stubModelModeAgent{
|
|
model: "agent-model",
|
|
reasoningEffort: "medium",
|
|
},
|
|
workDir: filepath.Join(homeDir, "codes", "agent-default"),
|
|
report: &UsageReport{
|
|
Buckets: []UsageBucket{{
|
|
Name: "Rate limit",
|
|
Windows: []UsageWindow{{
|
|
Name: "Primary",
|
|
UsedPercent: 80,
|
|
WindowSeconds: 18000,
|
|
}},
|
|
}},
|
|
},
|
|
}
|
|
p := &stubPlatformEngine{n: "telegram"}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
e.SetReplyFooterEnabled(true)
|
|
|
|
sessionKey := "telegram:user-footer-runtime"
|
|
session := e.sessions.GetOrCreateActive(sessionKey)
|
|
agentSession := newControllableSession("s-footer-runtime")
|
|
agentSession.model = "gpt-5.4"
|
|
agentSession.reasoningEffort = "xhigh"
|
|
agentSession.workDir = filepath.Join(homeDir, "codes", "cc-connect")
|
|
agentSession.report = &UsageReport{
|
|
Buckets: []UsageBucket{{
|
|
Name: "Rate limit",
|
|
Windows: []UsageWindow{{
|
|
Name: "Primary",
|
|
UsedPercent: 0,
|
|
WindowSeconds: 18000,
|
|
}},
|
|
}},
|
|
}
|
|
agentSession.contextUsage = &ContextUsage{
|
|
UsedTokens: 181424,
|
|
BaselineTokens: 12000,
|
|
TotalTokens: 50821769,
|
|
ContextWindow: 258400,
|
|
}
|
|
state := &interactiveState{
|
|
agentSession: agentSession,
|
|
platform: p,
|
|
replyCtx: "ctx-footer-runtime",
|
|
agent: agent,
|
|
}
|
|
e.interactiveStates[sessionKey] = state
|
|
|
|
agentSession.events <- Event{Type: EventResult, Content: "answer", Done: true}
|
|
e.processInteractiveEvents(state, session, e.sessions, sessionKey, "m-footer-runtime", time.Now(), nil, nil, state.replyCtx)
|
|
|
|
sent := p.getSent()
|
|
if len(sent) != 1 {
|
|
t.Fatalf("sent = %#v, want one final reply", sent)
|
|
}
|
|
want := "answer\n\n*gpt-5.4 · xhigh · 31% left · ~/codes/cc-connect*"
|
|
if sent[0] != want {
|
|
t.Fatalf("final reply = %q, want %q", sent[0], want)
|
|
}
|
|
}
|
|
|
|
// Regression: an agent that only exposes a workdir (no model/effort/usage)
|
|
// must not emit a footer at all. Previously this produced a footer like
|
|
// "*~*" when the agent was running in the user's home directory, which
|
|
// rendered as a bare "~" on Feishu/Weixin.
|
|
func TestProcessInteractiveEvents_SuppressesReplyFooterWhenOnlyWorkDir(t *testing.T) {
|
|
homeDir := t.TempDir()
|
|
t.Setenv("HOME", homeDir)
|
|
|
|
agent := &stubWorkDirAgent{workDir: homeDir}
|
|
p := &stubPlatformEngine{n: "telegram"}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
e.SetReplyFooterEnabled(true)
|
|
|
|
sessionKey := "telegram:user-footer-workdir-only"
|
|
session := e.sessions.GetOrCreateActive(sessionKey)
|
|
agentSession := newControllableSession("s-footer-workdir-only")
|
|
state := &interactiveState{
|
|
agentSession: agentSession,
|
|
platform: p,
|
|
replyCtx: "ctx-footer-workdir-only",
|
|
agent: agent,
|
|
}
|
|
e.interactiveStates[sessionKey] = state
|
|
|
|
agentSession.events <- Event{Type: EventResult, Content: "answer", Done: true}
|
|
e.processInteractiveEvents(state, session, e.sessions, sessionKey, "m-footer-workdir-only", time.Now(), nil, nil, state.replyCtx)
|
|
|
|
sent := p.getSent()
|
|
if len(sent) != 1 {
|
|
t.Fatalf("sent = %#v, want one final reply", sent)
|
|
}
|
|
if sent[0] != "answer" {
|
|
t.Fatalf("final reply = %q, want plain answer without footer", sent[0])
|
|
}
|
|
}
|
|
|
|
func TestProcessInteractiveEvents_HiddenToolProgressKeepsPreviewOnFinalize(t *testing.T) {
|
|
p := &mockKeepPreviewPlatform{}
|
|
p.n = "feishu"
|
|
e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish)
|
|
e.SetDisplayConfig(DisplayCfg{ThinkingMessages: true, ThinkingMaxLen: 300, ToolMaxLen: 500, ToolMessages: false})
|
|
sessionKey := "test:user1"
|
|
session := e.sessions.GetOrCreateActive(sessionKey)
|
|
agentSession := newControllableSession("s1")
|
|
state := &interactiveState{
|
|
agentSession: agentSession,
|
|
platform: p,
|
|
replyCtx: "ctx-1",
|
|
}
|
|
e.interactiveStates[sessionKey] = state
|
|
|
|
agentSession.events <- Event{Type: EventText, Content: "final response"}
|
|
agentSession.events <- Event{Type: EventToolUse, ToolName: "Bash", ToolInput: "echo hi"}
|
|
agentSession.events <- Event{Type: EventResult, Content: "", Done: true}
|
|
|
|
e.processInteractiveEvents(state, session, e.sessions, sessionKey, "m1", time.Now(), nil, nil, nil)
|
|
|
|
if got := p.getSent(); len(got) != 0 {
|
|
t.Fatalf("sent text = %#v, want no plain-text fallback sends", got)
|
|
}
|
|
|
|
p.mu.Lock()
|
|
deletedCount := len(p.deleted)
|
|
previewMsgs := append([]string(nil), p.messages...)
|
|
p.mu.Unlock()
|
|
|
|
if deletedCount != 0 {
|
|
t.Fatalf("deleted previews = %d, want 0", deletedCount)
|
|
}
|
|
if len(previewMsgs) == 0 || previewMsgs[len(previewMsgs)-1] != "update:final response" {
|
|
t.Fatalf("preview messages = %#v, want in-place final update", previewMsgs)
|
|
}
|
|
}
|
|
|
|
func TestProcessInteractiveEvents_ToolMessagesDisabledSuppressesToolProgressOnly(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "telegram"}
|
|
e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish)
|
|
e.SetDisplayConfig(DisplayCfg{ThinkingMessages: true, ThinkingMaxLen: 300, ToolMaxLen: 500, ToolMessages: false})
|
|
sessionKey := "telegram:user1"
|
|
session := e.sessions.GetOrCreateActive(sessionKey)
|
|
agentSession := newControllableSession("s1")
|
|
state := &interactiveState{
|
|
agentSession: agentSession,
|
|
platform: p,
|
|
replyCtx: "ctx-1",
|
|
}
|
|
e.interactiveStates[sessionKey] = state
|
|
|
|
agentSession.events <- Event{Type: EventThinking, Content: "planning"}
|
|
agentSession.events <- Event{Type: EventToolUse, ToolName: "Bash", ToolInput: "echo hi"}
|
|
agentSession.events <- Event{Type: EventToolResult, ToolName: "Bash", ToolResult: "hi"}
|
|
agentSession.events <- Event{Type: EventText, Content: "done"}
|
|
agentSession.events <- Event{Type: EventResult, Content: "done", Done: true}
|
|
|
|
e.processInteractiveEvents(state, session, e.sessions, sessionKey, "m1", time.Now(), nil, nil, nil)
|
|
|
|
sent := p.getSent()
|
|
if len(sent) < 1 || len(sent) > 2 {
|
|
t.Fatalf("sent = %#v, want final response with optional standalone thinking message", sent)
|
|
}
|
|
for _, msg := range sent {
|
|
if strings.Contains(msg, "Bash") || strings.Contains(msg, "echo hi") || strings.Contains(msg, "hi") {
|
|
t.Fatalf("tool progress should stay hidden, got %q", msg)
|
|
}
|
|
}
|
|
if len(sent) == 2 && !strings.Contains(sent[0], "planning") {
|
|
t.Fatalf("thinking message = %q, want planning", sent[0])
|
|
}
|
|
if sent[len(sent)-1] != "done" {
|
|
t.Fatalf("final message = %q, want done", sent[len(sent)-1])
|
|
}
|
|
}
|
|
|
|
func TestProcessInteractiveEvents_CompactProgressCoalescesThinkingAndToolUse(t *testing.T) {
|
|
p := &stubCompactProgressPlatform{stubPlatformEngine: stubPlatformEngine{n: "feishu"}}
|
|
e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish)
|
|
sessionKey := "feishu:user1"
|
|
session := e.sessions.GetOrCreateActive(sessionKey)
|
|
agentSession := newControllableSession("s1")
|
|
state := &interactiveState{
|
|
agentSession: agentSession,
|
|
platform: p,
|
|
replyCtx: "ctx-compact",
|
|
}
|
|
e.interactiveStates[sessionKey] = state
|
|
|
|
agentSession.events <- Event{Type: EventThinking, Content: "Thinking about command"}
|
|
agentSession.events <- Event{Type: EventToolUse, ToolName: "Bash", ToolInput: "pwd"}
|
|
agentSession.events <- Event{Type: EventText, Content: "done"}
|
|
agentSession.events <- Event{Type: EventResult, Content: "done", Done: true}
|
|
|
|
e.processInteractiveEvents(state, session, e.sessions, sessionKey, "m1", time.Now(), nil, nil, state.replyCtx)
|
|
|
|
sent := p.getSent()
|
|
if len(sent) != 1 || sent[0] != "done" {
|
|
t.Fatalf("sent = %#v, want only final assistant reply", sent)
|
|
}
|
|
|
|
starts := p.getPreviewStarts()
|
|
if len(starts) != 1 {
|
|
t.Fatalf("preview starts = %d, want 1", len(starts))
|
|
}
|
|
if !strings.Contains(starts[0], "Thinking") {
|
|
t.Fatalf("start preview should contain thinking text, got %q", starts[0])
|
|
}
|
|
|
|
edits := p.getPreviewEdits()
|
|
if len(edits) != 1 {
|
|
t.Fatalf("preview edits = %d, want 1", len(edits))
|
|
}
|
|
if !strings.Contains(edits[0], "pwd") {
|
|
t.Fatalf("updated preview should contain tool input, got %q", edits[0])
|
|
}
|
|
}
|
|
|
|
func TestProcessInteractiveEvents_CardProgressUsesCardTemplate(t *testing.T) {
|
|
p := &stubCompactProgressPlatform{
|
|
stubPlatformEngine: stubPlatformEngine{n: "feishu"},
|
|
style: "card",
|
|
}
|
|
e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish)
|
|
sessionKey := "feishu:user2"
|
|
session := e.sessions.GetOrCreateActive(sessionKey)
|
|
agentSession := newControllableSession("s2")
|
|
state := &interactiveState{
|
|
agentSession: agentSession,
|
|
platform: p,
|
|
replyCtx: "ctx-card",
|
|
}
|
|
e.interactiveStates[sessionKey] = state
|
|
|
|
agentSession.events <- Event{Type: EventThinking, Content: "Plan first"}
|
|
agentSession.events <- Event{Type: EventToolUse, ToolName: "Bash", ToolInput: "echo hi"}
|
|
agentSession.events <- Event{Type: EventText, Content: "done"}
|
|
agentSession.events <- Event{Type: EventResult, Content: "done", Done: true}
|
|
|
|
e.processInteractiveEvents(state, session, e.sessions, sessionKey, "m2", time.Now(), nil, nil, state.replyCtx)
|
|
|
|
sent := p.getSent()
|
|
if len(sent) != 1 || sent[0] != "done" {
|
|
t.Fatalf("sent = %#v, want only final assistant reply", sent)
|
|
}
|
|
|
|
starts := p.getPreviewStarts()
|
|
if len(starts) != 1 {
|
|
t.Fatalf("preview starts = %d, want 1", len(starts))
|
|
}
|
|
if !strings.Contains(starts[0], "**Progress**") {
|
|
t.Fatalf("start preview should contain fallback progress title, got %q", starts[0])
|
|
}
|
|
if !strings.Contains(starts[0], "1.") {
|
|
t.Fatalf("start preview should contain first item index, got %q", starts[0])
|
|
}
|
|
|
|
edits := p.getPreviewEdits()
|
|
if len(edits) != 1 {
|
|
t.Fatalf("preview edits = %d, want 1", len(edits))
|
|
}
|
|
if !strings.Contains(edits[0], "2.") {
|
|
t.Fatalf("updated preview should contain second item index, got %q", edits[0])
|
|
}
|
|
if !strings.Contains(edits[0], "echo hi") {
|
|
t.Fatalf("updated preview should contain tool command, got %q", edits[0])
|
|
}
|
|
}
|
|
|
|
func TestProcessInteractiveEvents_FinalReplyUsesWorkspaceForReferenceRendering(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("TransformLocalReferences path handling assumes Unix separators")
|
|
}
|
|
p := &stubPlatformEngine{n: "feishu"}
|
|
a := &namedStubModelModeAgent{name: "codex"}
|
|
e := NewEngine("test", a, []Platform{p}, "", LangEnglish)
|
|
e.SetReferenceConfig(ReferenceRenderCfg{
|
|
NormalizeAgents: []string{"codex"},
|
|
RenderPlatforms: []string{"feishu"},
|
|
DisplayPath: "relative",
|
|
MarkerStyle: "emoji",
|
|
EnclosureStyle: "code",
|
|
})
|
|
|
|
sessionKey := "feishu:user-relative"
|
|
session := e.sessions.GetOrCreateActive(sessionKey)
|
|
agentSession := newControllableSession("s-relative")
|
|
state := &interactiveState{
|
|
agentSession: agentSession,
|
|
platform: p,
|
|
replyCtx: "ctx-relative",
|
|
workspaceDir: "/root/code",
|
|
}
|
|
e.interactiveStates[sessionKey] = state
|
|
|
|
agentSession.events <- Event{
|
|
Type: EventResult,
|
|
Content: "/root/code/demo-repo/src/services/user_profile_service.ts:42",
|
|
Done: true,
|
|
}
|
|
|
|
e.processInteractiveEvents(state, session, e.sessions, sessionKey, "m-relative", time.Now(), nil, nil, state.replyCtx)
|
|
|
|
sent := p.getSent()
|
|
if len(sent) != 1 {
|
|
t.Fatalf("sent = %#v, want one final reply", sent)
|
|
}
|
|
if got := sent[0]; got != "📄 `demo-repo/src/services/user_profile_service.ts:42`" {
|
|
t.Fatalf("final reply = %q, want workspace-relative rendered reference", got)
|
|
}
|
|
}
|
|
|
|
func TestProcessInteractiveEvents_FinalReplyRemainsRawWhenReferencesDisabled(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "feishu"}
|
|
a := &namedStubModelModeAgent{name: "codex"}
|
|
e := NewEngine("test", a, []Platform{p}, "", LangEnglish)
|
|
e.SetReferenceConfig(ReferenceRenderCfg{
|
|
NormalizeAgents: []string{},
|
|
RenderPlatforms: []string{"feishu"},
|
|
DisplayPath: "relative",
|
|
MarkerStyle: "emoji",
|
|
EnclosureStyle: "code",
|
|
})
|
|
|
|
sessionKey := "feishu:user-relative-raw"
|
|
session := e.sessions.GetOrCreateActive(sessionKey)
|
|
agentSession := newControllableSession("s-relative-raw")
|
|
state := &interactiveState{
|
|
agentSession: agentSession,
|
|
platform: p,
|
|
replyCtx: "ctx-relative-raw",
|
|
workspaceDir: "/root/code/demo",
|
|
}
|
|
e.interactiveStates[sessionKey] = state
|
|
|
|
raw := "Check [/root/code/demo/ui/recovery_contact_form.tsx](/root/code/demo/ui/recovery_contact_form.tsx) and /root/code/demo/ui/recovery_contact_form.tsx:11"
|
|
agentSession.events <- Event{
|
|
Type: EventResult,
|
|
Content: raw,
|
|
Done: true,
|
|
}
|
|
|
|
e.processInteractiveEvents(state, session, e.sessions, sessionKey, "m-relative-raw", time.Now(), nil, nil, state.replyCtx)
|
|
|
|
sent := p.getSent()
|
|
if len(sent) != 1 {
|
|
t.Fatalf("sent = %#v, want one final reply", sent)
|
|
}
|
|
if got := sent[0]; got != raw {
|
|
t.Fatalf("final reply = %q, want raw unchanged content %q", got, raw)
|
|
}
|
|
}
|
|
|
|
func TestProcessInteractiveEvents_CardProgressUsesStructuredPayloadWhenSupported(t *testing.T) {
|
|
p := &stubCompactProgressPlatform{
|
|
stubPlatformEngine: stubPlatformEngine{n: "feishu"},
|
|
style: "card",
|
|
supportPayload: true,
|
|
}
|
|
e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish)
|
|
sessionKey := "feishu:user3"
|
|
session := e.sessions.GetOrCreateActive(sessionKey)
|
|
agentSession := newControllableSession("s3")
|
|
state := &interactiveState{
|
|
agentSession: agentSession,
|
|
platform: p,
|
|
replyCtx: "ctx-card-structured",
|
|
}
|
|
e.interactiveStates[sessionKey] = state
|
|
|
|
agentSession.events <- Event{Type: EventThinking, Content: "Plan first"}
|
|
agentSession.events <- Event{Type: EventToolUse, ToolName: "Bash", ToolInput: "echo hi"}
|
|
agentSession.events <- Event{Type: EventText, Content: "done"}
|
|
agentSession.events <- Event{Type: EventResult, Content: "done", Done: true}
|
|
|
|
e.processInteractiveEvents(state, session, e.sessions, sessionKey, "m3", time.Now(), nil, nil, state.replyCtx)
|
|
|
|
starts := p.getPreviewStarts()
|
|
if len(starts) != 1 {
|
|
t.Fatalf("preview starts = %d, want 1", len(starts))
|
|
}
|
|
if !strings.HasPrefix(starts[0], ProgressCardPayloadPrefix) {
|
|
t.Fatalf("start preview should be structured payload, got %q", starts[0])
|
|
}
|
|
startPayload, ok := ParseProgressCardPayload(starts[0])
|
|
if !ok {
|
|
t.Fatalf("start preview should parse as structured payload, got %q", starts[0])
|
|
}
|
|
if len(startPayload.Items) != 1 {
|
|
t.Fatalf("start payload items = %d, want 1", len(startPayload.Items))
|
|
}
|
|
if startPayload.Items[0].Kind != ProgressEntryThinking {
|
|
t.Fatalf("start payload kind = %q, want %q", startPayload.Items[0].Kind, ProgressEntryThinking)
|
|
}
|
|
if startPayload.State != ProgressCardStateRunning {
|
|
t.Fatalf("start payload state = %q, want %q", startPayload.State, ProgressCardStateRunning)
|
|
}
|
|
|
|
edits := p.getPreviewEdits()
|
|
if len(edits) != 2 {
|
|
t.Fatalf("preview edits = %d, want 2", len(edits))
|
|
}
|
|
updatePayload, ok := ParseProgressCardPayload(edits[0])
|
|
if !ok {
|
|
t.Fatalf("update preview should parse as structured payload, got %q", edits[0])
|
|
}
|
|
if len(updatePayload.Items) != 2 {
|
|
t.Fatalf("update payload items = %d, want 2", len(updatePayload.Items))
|
|
}
|
|
if !strings.Contains(updatePayload.Items[1].Text, "echo hi") {
|
|
t.Fatalf("second payload item should contain tool command, got %q", updatePayload.Items[1].Text)
|
|
}
|
|
|
|
finalPayload, ok := ParseProgressCardPayload(edits[1])
|
|
if !ok {
|
|
t.Fatalf("final preview should parse as structured payload, got %q", edits[1])
|
|
}
|
|
if finalPayload.State != ProgressCardStateCompleted {
|
|
t.Fatalf("final payload state = %q, want %q", finalPayload.State, ProgressCardStateCompleted)
|
|
}
|
|
}
|
|
|
|
func TestProcessInteractiveEvents_RichCardShowsThinkingContent(t *testing.T) {
|
|
p := &stubCompactProgressPlatform{
|
|
stubPlatformEngine: stubPlatformEngine{n: "feishu"},
|
|
style: "card",
|
|
supportPayload: true,
|
|
}
|
|
e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish)
|
|
e.SetDisplayConfig(DisplayCfg{
|
|
ThinkingMessages: true,
|
|
ThinkingMaxLen: 300,
|
|
ToolMaxLen: 500,
|
|
ToolMessages: true,
|
|
Mode: "rich",
|
|
})
|
|
sessionKey := "feishu:user-rich-thinking"
|
|
session := e.sessions.GetOrCreateActive(sessionKey)
|
|
agentSession := newControllableSession("s-rich-thinking")
|
|
state := &interactiveState{
|
|
agentSession: agentSession,
|
|
platform: p,
|
|
replyCtx: "ctx-rich-thinking",
|
|
}
|
|
e.interactiveStates[sessionKey] = state
|
|
|
|
agentSession.events <- Event{Type: EventThinking, Content: "Inspecting event routing"}
|
|
agentSession.events <- Event{Type: EventText, Content: "answer"}
|
|
agentSession.events <- Event{Type: EventResult, Content: "answer", Done: true}
|
|
|
|
e.processInteractiveEvents(state, session, e.sessions, sessionKey, "m-rich-thinking", time.Now(), nil, nil, state.replyCtx)
|
|
|
|
starts := p.getPreviewStarts()
|
|
if len(starts) != 1 {
|
|
t.Fatalf("preview starts = %d, want 1", len(starts))
|
|
}
|
|
if !strings.Contains(starts[0], "Inspecting event routing") {
|
|
t.Fatalf("rich card start should contain thinking content, got %q", starts[0])
|
|
}
|
|
}
|
|
|
|
func TestProcessInteractiveEvents_RichCardCoalescesToolResult(t *testing.T) {
|
|
p := &stubCompactProgressPlatform{
|
|
stubPlatformEngine: stubPlatformEngine{n: "feishu"},
|
|
style: "card",
|
|
supportPayload: true,
|
|
}
|
|
e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish)
|
|
e.SetDisplayConfig(DisplayCfg{
|
|
ThinkingMessages: true,
|
|
ThinkingMaxLen: 300,
|
|
ToolMaxLen: 500,
|
|
ToolMessages: true,
|
|
Mode: "rich",
|
|
})
|
|
sessionKey := "feishu:user-rich-tool-result"
|
|
session := e.sessions.GetOrCreateActive(sessionKey)
|
|
agentSession := newControllableSession("s-rich-tool-result")
|
|
state := &interactiveState{
|
|
agentSession: agentSession,
|
|
platform: p,
|
|
replyCtx: "ctx-rich-tool-result",
|
|
}
|
|
e.interactiveStates[sessionKey] = state
|
|
|
|
code := 0
|
|
success := true
|
|
agentSession.events <- Event{Type: EventToolUse, ToolName: "Bash", ToolInput: "echo hi"}
|
|
agentSession.events <- Event{Type: EventToolResult, ToolName: "Bash", ToolResult: "hi", ToolStatus: "completed", ToolExitCode: &code, ToolSuccess: &success}
|
|
agentSession.events <- Event{Type: EventText, Content: "done"}
|
|
agentSession.events <- Event{Type: EventResult, Content: "done", Done: true}
|
|
|
|
e.processInteractiveEvents(state, session, e.sessions, sessionKey, "m-rich-tool-result", time.Now(), nil, nil, state.replyCtx)
|
|
|
|
starts := p.getPreviewStarts()
|
|
if len(starts) != 1 {
|
|
t.Fatalf("preview starts = %d, want only the rich card start and no separate progress card", len(starts))
|
|
}
|
|
rendered := strings.Join(append(starts, p.getPreviewEdits()...), "\n")
|
|
for _, want := range []string{"echo hi", "completed", "hi"} {
|
|
if !strings.Contains(rendered, want) {
|
|
t.Fatalf("rich card should contain %q, got %q", want, rendered)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAgentSystemPrompt_MentionsAttachmentSend(t *testing.T) {
|
|
prompt := AgentSystemPrompt()
|
|
if !strings.Contains(prompt, "cc-connect send --image") {
|
|
t.Fatalf("prompt missing image send instructions: %q", prompt)
|
|
}
|
|
if !strings.Contains(prompt, "cc-connect send --file") {
|
|
t.Fatalf("prompt missing file send instructions: %q", prompt)
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// --- 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 /")
|
|
}
|
|
}
|
|
|
|
func TestResolveDisabledCmds_Wildcard(t *testing.T) {
|
|
m := resolveDisabledCmds([]string{"*"})
|
|
for _, bc := range builtinCommands {
|
|
if !m[bc.id] {
|
|
t.Errorf("wildcard should disable %q", bc.id)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestResolveDisabledCmds_Specific(t *testing.T) {
|
|
m := resolveDisabledCmds([]string{"upgrade", "/restart", "Help"})
|
|
if !m["upgrade"] {
|
|
t.Error("upgrade should be disabled")
|
|
}
|
|
if !m["restart"] {
|
|
t.Error("restart should be disabled (slash stripped)")
|
|
}
|
|
if !m["help"] {
|
|
t.Error("help should be disabled (case insensitive)")
|
|
}
|
|
if m["shell"] {
|
|
t.Error("shell should not be disabled")
|
|
}
|
|
}
|
|
|
|
func TestResolveDisabledCmds_Empty(t *testing.T) {
|
|
m1 := resolveDisabledCmds(nil)
|
|
if len(m1) != 0 {
|
|
t.Errorf("nil input should produce empty map, got %d entries", len(m1))
|
|
}
|
|
m2 := resolveDisabledCmds([]string{})
|
|
if len(m2) != 0 {
|
|
t.Errorf("empty input should produce empty map, got %d entries", len(m2))
|
|
}
|
|
}
|
|
|
|
func TestEngine_DisabledCommandsWildcard(t *testing.T) {
|
|
e := newTestEngine()
|
|
e.SetDisabledCommands([]string{"*"})
|
|
|
|
p := &stubPlatformEngine{n: "test"}
|
|
msg := &Message{SessionKey: "test:u1", UserID: "user1", ReplyCtx: "ctx"}
|
|
|
|
e.handleCommand(p, msg, "/help")
|
|
if len(p.sent) != 1 {
|
|
t.Fatalf("expected 1 reply, got %d", len(p.sent))
|
|
}
|
|
if !strings.Contains(p.sent[0], "disabled") && !strings.Contains(p.sent[0], "禁用") {
|
|
t.Errorf("expected disabled message, got: %s", p.sent[0])
|
|
}
|
|
}
|
|
|
|
// --- 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], "requires 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; wait for it to complete.
|
|
time.Sleep(500 * time.Millisecond)
|
|
|
|
for _, s := range p.getSent() {
|
|
if strings.Contains(s, "admin") {
|
|
t.Errorf("admin user should not be blocked, got: %s", s)
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- role-based ACL tests ---
|
|
|
|
func TestEngine_RoleBasedACL_AdminCanRunAll(t *testing.T) {
|
|
e := newTestEngine()
|
|
e.SetDisabledCommands([]string{"help", "status"}) // project-level disables
|
|
|
|
urm := NewUserRoleManager()
|
|
urm.Configure("member", []RoleInput{
|
|
{Name: "admin", UserIDs: []string{"admin1"}, DisabledCommands: []string{}},
|
|
{Name: "member", UserIDs: []string{"*"}, DisabledCommands: []string{"*"}},
|
|
})
|
|
e.SetUserRoles(urm)
|
|
|
|
p := &stubPlatformEngine{n: "test"}
|
|
msg := &Message{SessionKey: "test:a1", UserID: "admin1", ReplyCtx: "ctx"}
|
|
e.handleCommand(p, msg, "/help")
|
|
|
|
// Admin role has disabled_commands=[], so /help should NOT be blocked
|
|
for _, s := range p.sent {
|
|
if strings.Contains(s, "disabled") || strings.Contains(s, "禁用") {
|
|
t.Errorf("admin should not have /help disabled, got: %s", s)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestEngine_RoleBasedACL_MemberBlocked(t *testing.T) {
|
|
e := newTestEngine()
|
|
|
|
urm := NewUserRoleManager()
|
|
urm.Configure("member", []RoleInput{
|
|
{Name: "admin", UserIDs: []string{"admin1"}, DisabledCommands: []string{}},
|
|
{Name: "member", UserIDs: []string{"*"}, DisabledCommands: []string{"*"}},
|
|
})
|
|
e.SetUserRoles(urm)
|
|
|
|
p := &stubPlatformEngine{n: "test"}
|
|
msg := &Message{SessionKey: "test:u1", UserID: "user1", ReplyCtx: "ctx"}
|
|
e.handleCommand(p, msg, "/help")
|
|
|
|
if len(p.sent) != 1 {
|
|
t.Fatalf("expected 1 reply, got %d", len(p.sent))
|
|
}
|
|
if !strings.Contains(p.sent[0], "disabled") && !strings.Contains(p.sent[0], "禁用") {
|
|
t.Errorf("member should have /help disabled, got: %s", p.sent[0])
|
|
}
|
|
}
|
|
|
|
func TestEngine_RoleBasedACL_NoUserID_UsesDefaultRole(t *testing.T) {
|
|
e := newTestEngine()
|
|
e.SetDisabledCommands([]string{"help"}) // project-level disables /help
|
|
|
|
// Default role "member" has wildcard with disabled_commands=["*"]
|
|
urm := NewUserRoleManager()
|
|
urm.Configure("member", []RoleInput{
|
|
{Name: "admin", UserIDs: []string{"admin1"}, DisabledCommands: []string{}},
|
|
{Name: "member", UserIDs: []string{"*"}, DisabledCommands: []string{"*"}},
|
|
})
|
|
e.SetUserRoles(urm)
|
|
|
|
p := &stubPlatformEngine{n: "test"}
|
|
msg := &Message{SessionKey: "test:anon", UserID: "", ReplyCtx: "ctx"} // no UserID
|
|
e.handleCommand(p, msg, "/help")
|
|
|
|
// Empty UserID resolves to default/wildcard role, which disables all commands
|
|
if len(p.sent) != 1 || (!strings.Contains(p.sent[0], "disabled") && !strings.Contains(p.sent[0], "禁用")) {
|
|
t.Errorf("empty UserID should resolve to default role ACL, got: %v", p.sent)
|
|
}
|
|
}
|
|
|
|
func TestEngine_RoleBasedACL_NoUsersConfig_Legacy(t *testing.T) {
|
|
e := newTestEngine()
|
|
e.SetDisabledCommands([]string{"help"})
|
|
// No SetUserRoles — legacy mode
|
|
|
|
p := &stubPlatformEngine{n: "test"}
|
|
msg := &Message{SessionKey: "test:u1", UserID: "user1", ReplyCtx: "ctx"}
|
|
e.handleCommand(p, msg, "/help")
|
|
|
|
if len(p.sent) != 1 || (!strings.Contains(p.sent[0], "disabled") && !strings.Contains(p.sent[0], "禁用")) {
|
|
t.Errorf("legacy mode should use project-level disabled_commands, got: %v", p.sent)
|
|
}
|
|
}
|
|
|
|
func TestEngine_CustomCommand_DisabledByRole(t *testing.T) {
|
|
e := newTestEngine()
|
|
e.commands.Add("deploy", "deploy command", "deploy it", "", "", "test")
|
|
|
|
urm := NewUserRoleManager()
|
|
urm.Configure("member", []RoleInput{
|
|
{Name: "admin", UserIDs: []string{"admin1"}, DisabledCommands: []string{}},
|
|
{Name: "member", UserIDs: []string{"*"}, DisabledCommands: []string{"deploy"}},
|
|
})
|
|
e.SetUserRoles(urm)
|
|
|
|
// Member should be blocked from custom command
|
|
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], "disabled") && !strings.Contains(p.sent[0], "禁用")) {
|
|
t.Errorf("custom command should be blocked for member, got: %v", p.sent)
|
|
}
|
|
|
|
// Admin should be allowed
|
|
p2 := &stubPlatformEngine{n: "test"}
|
|
msg2 := &Message{SessionKey: "test:a1", UserID: "admin1", ReplyCtx: "ctx"}
|
|
e.handleCommand(p2, msg2, "/deploy")
|
|
|
|
if len(p2.sent) > 0 && (strings.Contains(p2.sent[0], "disabled") || strings.Contains(p2.sent[0], "禁用")) {
|
|
t.Errorf("custom command should be allowed for admin, got: %v", p2.sent)
|
|
}
|
|
}
|
|
|
|
func TestEngine_SkillCommand_DisabledByRole(t *testing.T) {
|
|
e := newTestEngine()
|
|
|
|
// Create a temporary skill directory with a SKILL.md
|
|
dir := t.TempDir()
|
|
skillDir := filepath.Join(dir, "deploy-prod")
|
|
if err := os.MkdirAll(skillDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("deploy to production"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
e.skills.SetDirs([]string{dir})
|
|
|
|
urm := NewUserRoleManager()
|
|
urm.Configure("member", []RoleInput{
|
|
{Name: "admin", UserIDs: []string{"admin1"}, DisabledCommands: []string{}},
|
|
{Name: "member", UserIDs: []string{"*"}, DisabledCommands: []string{"deploy-prod"}},
|
|
})
|
|
e.SetUserRoles(urm)
|
|
|
|
// Member should be blocked from skill command
|
|
p := &stubPlatformEngine{n: "test"}
|
|
msg := &Message{SessionKey: "test:u1", UserID: "user1", ReplyCtx: "ctx"}
|
|
e.handleCommand(p, msg, "/deploy-prod")
|
|
|
|
if len(p.sent) != 1 || (!strings.Contains(p.sent[0], "disabled") && !strings.Contains(p.sent[0], "禁用")) {
|
|
t.Errorf("skill should be blocked for member, got: %v", p.sent)
|
|
}
|
|
|
|
// Admin should NOT be blocked (but may fail at session level — that's fine,
|
|
// we only check that the "disabled" message is NOT returned)
|
|
p2 := &stubPlatformEngine{n: "test"}
|
|
msg2 := &Message{SessionKey: "test:a1", UserID: "admin1", ReplyCtx: "ctx"}
|
|
e.handleCommand(p2, msg2, "/deploy-prod")
|
|
|
|
for _, s := range p2.sent {
|
|
if strings.Contains(s, "disabled") || strings.Contains(s, "禁用") {
|
|
t.Errorf("skill should be allowed for admin, got: %v", p2.sent)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestEngine_SkillCommand_DisabledByProjectLevel(t *testing.T) {
|
|
e := newTestEngine()
|
|
|
|
dir := t.TempDir()
|
|
skillDir := filepath.Join(dir, "my-skill")
|
|
if err := os.MkdirAll(skillDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("a skill"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
e.skills.SetDirs([]string{dir})
|
|
e.SetDisabledCommands([]string{"my-skill"})
|
|
|
|
p := &stubPlatformEngine{n: "test"}
|
|
msg := &Message{SessionKey: "test:u1", UserID: "user1", ReplyCtx: "ctx"}
|
|
e.handleCommand(p, msg, "/my-skill")
|
|
|
|
if len(p.sent) != 1 || (!strings.Contains(p.sent[0], "disabled") && !strings.Contains(p.sent[0], "禁用")) {
|
|
t.Errorf("skill should be blocked by project-level disabled_commands, got: %v", p.sent)
|
|
}
|
|
}
|
|
|
|
// --- role-based rate limit tests ---
|
|
|
|
func TestEngine_RateLimit_RoleSpecific(t *testing.T) {
|
|
e := newTestEngine()
|
|
|
|
urm := NewUserRoleManager()
|
|
urm.Configure("member", []RoleInput{
|
|
{Name: "admin", UserIDs: []string{"admin1"}, DisabledCommands: []string{},
|
|
RateLimit: &RateLimitCfg{MaxMessages: 50, Window: time.Minute}},
|
|
{Name: "member", UserIDs: []string{"*"}, DisabledCommands: []string{},
|
|
RateLimit: &RateLimitCfg{MaxMessages: 2, Window: time.Minute}},
|
|
})
|
|
e.SetUserRoles(urm)
|
|
|
|
// Member should be limited after 2 messages
|
|
msg := &Message{SessionKey: "test:u1", UserID: "user1"}
|
|
if !e.checkRateLimit(msg) {
|
|
t.Error("1st message should be allowed")
|
|
}
|
|
if !e.checkRateLimit(msg) {
|
|
t.Error("2nd message should be allowed")
|
|
}
|
|
if e.checkRateLimit(msg) {
|
|
t.Error("3rd message should be rate-limited")
|
|
}
|
|
|
|
// Admin should still be allowed
|
|
adminMsg := &Message{SessionKey: "test:a1", UserID: "admin1"}
|
|
if !e.checkRateLimit(adminMsg) {
|
|
t.Error("admin should not be rate-limited")
|
|
}
|
|
}
|
|
|
|
func TestEngine_RateLimit_NoUsersConfig_Legacy(t *testing.T) {
|
|
e := newTestEngine()
|
|
e.SetRateLimitCfg(RateLimitCfg{MaxMessages: 2, Window: time.Minute})
|
|
|
|
msg := &Message{SessionKey: "test:session1", UserID: "user1"}
|
|
if !e.checkRateLimit(msg) {
|
|
t.Error("1st should be allowed")
|
|
}
|
|
if !e.checkRateLimit(msg) {
|
|
t.Error("2nd should be allowed")
|
|
}
|
|
if e.checkRateLimit(msg) {
|
|
t.Error("3rd should be rate-limited")
|
|
}
|
|
|
|
// Different session key should be independent (legacy keying)
|
|
msg2 := &Message{SessionKey: "test:session2", UserID: "user1"}
|
|
if !e.checkRateLimit(msg2) {
|
|
t.Error("different session key should have independent bucket in legacy mode")
|
|
}
|
|
}
|
|
|
|
func TestEngine_RateLimit_GlobalFallback(t *testing.T) {
|
|
e := newTestEngine()
|
|
e.SetRateLimitCfg(RateLimitCfg{MaxMessages: 2, Window: time.Minute})
|
|
|
|
// User roles configured but role has no rate_limit
|
|
urm := NewUserRoleManager()
|
|
urm.Configure("member", []RoleInput{
|
|
{Name: "member", UserIDs: []string{"*"}, DisabledCommands: []string{}},
|
|
// No RateLimit on this role
|
|
})
|
|
e.SetUserRoles(urm)
|
|
|
|
msg := &Message{SessionKey: "test:s1", UserID: "user1"}
|
|
if !e.checkRateLimit(msg) {
|
|
t.Error("1st should be allowed")
|
|
}
|
|
if !e.checkRateLimit(msg) {
|
|
t.Error("2nd should be allowed")
|
|
}
|
|
if e.checkRateLimit(msg) {
|
|
t.Error("3rd should be rate-limited by global limiter")
|
|
}
|
|
|
|
// Same user, different session → should share limit (keyed by userID when users config active)
|
|
msg2 := &Message{SessionKey: "test:s2", UserID: "user1"}
|
|
if e.checkRateLimit(msg2) {
|
|
t.Error("same user from different session should still be rate-limited")
|
|
}
|
|
}
|
|
|
|
// --- 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)
|
|
}
|
|
// Normalize the path so it matches what resolveWorkspace/getOrCreateWorkspaceAgent will use
|
|
normalizedWsDir := normalizeWorkspacePath(wsDir)
|
|
channelID := "C123"
|
|
e.workspaceBindings.Bind("project:test", channelID, "chan", normalizedWsDir)
|
|
|
|
ws := e.workspacePool.GetOrCreate(normalizedWsDir)
|
|
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()
|
|
|
|
// Set up multi-workspace with proper bindings so interactiveKeyForSessionKey works
|
|
wsDir := t.TempDir()
|
|
bindingPath := filepath.Join(t.TempDir(), "bindings.json")
|
|
e.SetMultiWorkspace(t.TempDir(), bindingPath)
|
|
|
|
channelID := "C123"
|
|
e.workspaceBindings.Bind("project:test", channelID, "chan", wsDir)
|
|
|
|
sessionKey := "slack:" + channelID + ":U1"
|
|
// interactiveKeyForSessionKey resolves symlinks, so use the normalized path
|
|
interactiveKey := normalizeWorkspacePath(wsDir) + ":" + 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")
|
|
}
|
|
}
|
|
|
|
// Regression for the Discord thread_isolation + multi-workspace auto-bind
|
|
// path: workspace binding is keyed by the *parent* channel ID, but the
|
|
// sessionKey driving follow-up lookups is the *thread* ID.
|
|
//
|
|
// sessionContextForKey must follow the same fallback as
|
|
// interactiveKeyForSessionKey, otherwise commands like /compress would
|
|
// resolve the workspace state correctly via interactiveKeyForSessionKey
|
|
// (live-state scan finds it) but lock the *global* session manager via
|
|
// sessionContextForKey (channel-binding misses, falls through to
|
|
// e.agent/e.sessions). That mismatch lets a normal thread message run
|
|
// concurrently against the same workspace agent session — the exact
|
|
// race we just fixed in interactiveKeyForSessionKey.
|
|
func TestSessionContextForKey_RecoversWorkspaceFromLiveState(t *testing.T) {
|
|
baseDir := t.TempDir()
|
|
e := newTestEngineWithMultiWorkspaceAgent(t, baseDir)
|
|
|
|
// Workspace dir must exist so getOrCreateWorkspaceAgent can build under it.
|
|
wsDir := filepath.Join(baseDir, "ws-thread")
|
|
if err := os.MkdirAll(wsDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
threadSessionKey := "discord:T-thread"
|
|
storedKey := normalizeWorkspacePath(wsDir) + ":" + threadSessionKey
|
|
|
|
// Live state is keyed under the workspace prefix but no binding exists
|
|
// for the thread channel — exactly the Discord thread_isolation shape.
|
|
e.interactiveMu.Lock()
|
|
e.interactiveStates[storedKey] = &interactiveState{}
|
|
e.interactiveMu.Unlock()
|
|
|
|
agent, sessions := e.sessionContextForKey(threadSessionKey)
|
|
if agent == e.agent {
|
|
t.Fatal("sessionContextForKey returned the global agent; live-state recovery did not engage")
|
|
}
|
|
if sessions == e.sessions {
|
|
t.Fatal("sessionContextForKey returned the global session manager; live-state recovery did not engage")
|
|
}
|
|
}
|
|
|
|
// Same shape as the case above, but exercising interactiveKeyForSessionKey.
|
|
func TestInteractiveKeyForSessionKey_RecoversByLiveStateScan(t *testing.T) {
|
|
e := newTestEngine()
|
|
wsDir := t.TempDir()
|
|
bindingPath := filepath.Join(t.TempDir(), "bindings.json")
|
|
e.SetMultiWorkspace(t.TempDir(), bindingPath)
|
|
|
|
parentChannel := "C-parent"
|
|
threadID := "T-thread"
|
|
// Bind the workspace under the *parent* channel — mirrors what the
|
|
// Discord platform does when thread_isolation is on.
|
|
e.workspaceBindings.Bind("project:test", "discord:"+parentChannel, "chan", wsDir)
|
|
|
|
// Live interactive state is stored under the workspace-prefixed thread
|
|
// session key, exactly how processInteractiveMessageWith would key it.
|
|
threadSessionKey := "discord:" + threadID
|
|
storedInteractiveKey := normalizeWorkspacePath(wsDir) + ":" + threadSessionKey
|
|
e.interactiveMu.Lock()
|
|
e.interactiveStates[storedInteractiveKey] = &interactiveState{}
|
|
e.interactiveMu.Unlock()
|
|
|
|
got := e.interactiveKeyForSessionKey(threadSessionKey)
|
|
if got != storedInteractiveKey {
|
|
t.Errorf("interactiveKeyForSessionKey(%q) = %q, want %q (suffix-scan fallback failed)",
|
|
threadSessionKey, got, storedInteractiveKey)
|
|
}
|
|
}
|
|
|
|
func TestInteractiveKeyForSessionKey_PrefersCurrentBindingOverStaleState(t *testing.T) {
|
|
// When a channel is rebound to a new workspace while old workspace state
|
|
// hasn't been cleaned up, the *current* binding must win. Otherwise the
|
|
// rebinding silently strands sessions on the old workspace, and a map-
|
|
// iteration race could send /stop or pending replies to the wrong state.
|
|
e := newTestEngine()
|
|
wsBound := t.TempDir()
|
|
wsStale := t.TempDir()
|
|
bindingPath := filepath.Join(t.TempDir(), "bindings.json")
|
|
e.SetMultiWorkspace(t.TempDir(), bindingPath)
|
|
|
|
channelID := "C1"
|
|
sessionKey := "slack:" + channelID + ":U1"
|
|
e.workspaceBindings.Bind("project:test", "slack:"+channelID, "chan", wsBound)
|
|
|
|
// Stale state from before rebinding is still in the map.
|
|
staleKey := normalizeWorkspacePath(wsStale) + ":" + sessionKey
|
|
e.interactiveMu.Lock()
|
|
e.interactiveStates[staleKey] = &interactiveState{}
|
|
e.interactiveMu.Unlock()
|
|
|
|
want := normalizeWorkspacePath(wsBound) + ":" + sessionKey
|
|
if got := e.interactiveKeyForSessionKey(sessionKey); got != want {
|
|
t.Errorf("interactiveKeyForSessionKey = %q, want current-binding key %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestFindInteractiveKeyForSession(t *testing.T) {
|
|
e := newTestEngine()
|
|
bindingPath := filepath.Join(t.TempDir(), "bindings.json")
|
|
e.SetMultiWorkspace(t.TempDir(), bindingPath)
|
|
|
|
cases := []struct {
|
|
name string
|
|
stored []string
|
|
query string
|
|
expected string
|
|
}{
|
|
{"empty-query", []string{}, "", ""},
|
|
{"no-matches", []string{"/ws:slack:C1:U1"}, "discord:T1", ""},
|
|
{"exact-match", []string{"slack:C1:U1"}, "slack:C1:U1", "slack:C1:U1"},
|
|
{"suffix-match", []string{"/ws:discord:T1"}, "discord:T1", "/ws:discord:T1"},
|
|
{"first-of-multiple", []string{"/wsA:discord:T1", "/wsB:slack:C1:U1"}, "slack:C1:U1", "/wsB:slack:C1:U1"},
|
|
// Precedence: exact key beats suffix-matched workspace-prefixed key.
|
|
// Without this, map iteration order would be visible to callers, making
|
|
// /stop and pending-permission routing non-deterministic when both
|
|
// raw and workspace-prefixed states coexist.
|
|
{"exact-beats-prefixed", []string{"slack:C1:U1", "/ws:slack:C1:U1"}, "slack:C1:U1", "slack:C1:U1"},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
e.interactiveMu.Lock()
|
|
e.interactiveStates = make(map[string]*interactiveState)
|
|
for _, k := range tc.stored {
|
|
e.interactiveStates[k] = &interactiveState{}
|
|
}
|
|
e.interactiveMu.Unlock()
|
|
|
|
if got := e.findInteractiveKeyForSession(tc.query); got != tc.expected {
|
|
t.Errorf("findInteractiveKeyForSession(%q) = %q, want %q", tc.query, got, tc.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHandleMessage_MultiWorkspacePreservesCCSessionKey(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "discord"}
|
|
e := NewEngine("test", &stubAgent{}, []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)
|
|
}
|
|
normalizedWsDir := normalizeWorkspacePath(wsDir)
|
|
channelID := "C123"
|
|
e.workspaceBindings.Bind("project:test", channelID, "chan", normalizedWsDir)
|
|
|
|
wsAgent := &sessionEnvRecordingAgent{session: newResultAgentSession("ok")}
|
|
ws := e.workspacePool.GetOrCreate(normalizedWsDir)
|
|
ws.agent = wsAgent
|
|
ws.sessions = NewSessionManager("")
|
|
|
|
msg := &Message{
|
|
SessionKey: "discord:" + channelID + ":U1",
|
|
Platform: "discord",
|
|
UserID: "U1",
|
|
UserName: "user",
|
|
Content: "hello",
|
|
ReplyCtx: "ctx",
|
|
}
|
|
e.handleMessage(p, msg)
|
|
|
|
deadline := time.After(2 * time.Second)
|
|
for {
|
|
if got := wsAgent.EnvValue("CC_SESSION_KEY"); got != "" {
|
|
if got != msg.SessionKey {
|
|
t.Fatalf("CC_SESSION_KEY = %q, want %q", got, msg.SessionKey)
|
|
}
|
|
if strings.Contains(got, normalizedWsDir) {
|
|
t.Fatalf("CC_SESSION_KEY leaked workspace path: %q", got)
|
|
}
|
|
return
|
|
}
|
|
|
|
select {
|
|
case <-deadline:
|
|
t.Fatal("timed out waiting for CC_SESSION_KEY to be injected")
|
|
default:
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestHandleMessage_AutoResetOnIdle_RotatesToNewSession(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "test"}
|
|
agentSession := newResultAgentSession("fresh reply")
|
|
agent := &resultAgent{session: agentSession}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
e.SetResetOnIdle(60 * time.Minute)
|
|
|
|
key := "test:user1"
|
|
old := e.sessions.GetOrCreateActive(key)
|
|
old.AddHistory("user", "stale context")
|
|
old.SetAgentSessionID("old-session", "stub")
|
|
staleAt := time.Now().Add(-2 * time.Hour)
|
|
old.mu.Lock()
|
|
old.UpdatedAt = staleAt
|
|
old.mu.Unlock()
|
|
|
|
msg := &Message{
|
|
SessionKey: key,
|
|
Platform: "test",
|
|
UserID: "u1",
|
|
UserName: "user",
|
|
Content: "hello after idle",
|
|
ReplyCtx: "ctx",
|
|
}
|
|
e.handleMessage(p, msg)
|
|
|
|
deadline := time.After(2 * time.Second)
|
|
for {
|
|
active := e.sessions.GetOrCreateActive(key)
|
|
sent := p.getSent()
|
|
if active.ID != old.ID && len(active.GetHistory(0)) >= 2 && len(sent) >= 2 {
|
|
break
|
|
}
|
|
select {
|
|
case <-deadline:
|
|
t.Fatalf("timed out waiting for idle auto-reset, sent=%v active=%s old=%s", sent, active.ID, old.ID)
|
|
default:
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
}
|
|
|
|
active := e.sessions.GetOrCreateActive(key)
|
|
if active.ID == old.ID {
|
|
t.Fatal("expected a new active session after idle auto-reset")
|
|
}
|
|
if got := old.GetAgentSessionID(); got != "old-session" {
|
|
t.Fatalf("old session agent id = %q, want old-session preserved", got)
|
|
}
|
|
if got := len(old.GetHistory(0)); got != 1 {
|
|
t.Fatalf("old session history len = %d, want 1 preserved entry", got)
|
|
}
|
|
if got := old.GetUpdatedAt(); !got.Equal(staleAt) {
|
|
t.Fatalf("old session updated_at = %v, want unchanged %v", got, staleAt)
|
|
}
|
|
|
|
history := active.GetHistory(0)
|
|
if len(history) != 2 {
|
|
t.Fatalf("new session history len = %d, want 2", len(history))
|
|
}
|
|
if history[0].Role != "user" || history[0].Content != "hello after idle" {
|
|
t.Fatalf("unexpected first history entry: %#v", history[0])
|
|
}
|
|
if history[1].Role != "assistant" || history[1].Content != "fresh reply" {
|
|
t.Fatalf("unexpected second history entry: %#v", history[1])
|
|
}
|
|
|
|
sent := p.getSent()
|
|
if !strings.Contains(sent[0], "Session auto-reset") {
|
|
t.Fatalf("first reply = %q, want auto-reset notice", sent[0])
|
|
}
|
|
if got := sent[len(sent)-1]; got != "fresh reply" {
|
|
t.Fatalf("final reply = %q, want fresh reply", got)
|
|
}
|
|
}
|
|
|
|
func TestHandleMessage_AutoResetOnIdle_DoesNotRotateFreshSession(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "test"}
|
|
agentSession := newResultAgentSession("normal reply")
|
|
agent := &resultAgent{session: agentSession}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
e.SetResetOnIdle(60 * time.Minute)
|
|
|
|
key := "test:user1"
|
|
session := e.sessions.GetOrCreateActive(key)
|
|
session.AddHistory("user", "recent context")
|
|
session.SetAgentSessionID("existing-session", "stub")
|
|
recentAt := time.Now().Add(-5 * time.Minute)
|
|
session.mu.Lock()
|
|
session.UpdatedAt = recentAt
|
|
session.mu.Unlock()
|
|
|
|
msg := &Message{
|
|
SessionKey: key,
|
|
Platform: "test",
|
|
UserID: "u1",
|
|
UserName: "user",
|
|
Content: "follow up",
|
|
ReplyCtx: "ctx",
|
|
}
|
|
e.handleMessage(p, msg)
|
|
|
|
deadline := time.After(2 * time.Second)
|
|
for {
|
|
if len(session.GetHistory(0)) >= 3 {
|
|
break
|
|
}
|
|
select {
|
|
case <-deadline:
|
|
t.Fatalf("timed out waiting for normal turn, sent=%v", p.getSent())
|
|
default:
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
}
|
|
|
|
active := e.sessions.GetOrCreateActive(key)
|
|
if active.ID != session.ID {
|
|
t.Fatalf("active session = %s, want unchanged %s", active.ID, session.ID)
|
|
}
|
|
sent := p.getSent()
|
|
for _, line := range sent {
|
|
if strings.Contains(line, "Session auto-reset") {
|
|
t.Fatalf("unexpected auto-reset notice in replies: %v", sent)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestHandleMessage_AutoResetOnIdle_DoesNotTriggerForSlashCommand(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "test"}
|
|
e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish)
|
|
e.SetResetOnIdle(60 * time.Minute)
|
|
|
|
key := "test:user1"
|
|
session := e.sessions.GetOrCreateActive(key)
|
|
session.AddHistory("user", "stale context")
|
|
session.SetAgentSessionID("old-session", "stub")
|
|
staleAt := time.Now().Add(-2 * time.Hour)
|
|
session.mu.Lock()
|
|
session.UpdatedAt = staleAt
|
|
session.mu.Unlock()
|
|
|
|
msg := &Message{
|
|
SessionKey: key,
|
|
Platform: "test",
|
|
UserID: "u1",
|
|
UserName: "user",
|
|
Content: "/list",
|
|
ReplyCtx: "ctx",
|
|
}
|
|
e.handleMessage(p, msg)
|
|
|
|
active := e.sessions.GetOrCreateActive(key)
|
|
if active.ID != session.ID {
|
|
t.Fatalf("active session = %s, want unchanged %s", active.ID, session.ID)
|
|
}
|
|
for _, line := range p.getSent() {
|
|
if strings.Contains(line, "Session auto-reset") {
|
|
t.Fatalf("unexpected auto-reset notice for slash command: %v", p.getSent())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestConfigItems_ThinkingMessagesToggle(t *testing.T) {
|
|
e := newTestEngine()
|
|
items := e.configItems()
|
|
|
|
var item *configItem
|
|
for i := range items {
|
|
if items[i].key == "thinking_messages" {
|
|
item = &items[i]
|
|
break
|
|
}
|
|
}
|
|
if item == nil {
|
|
t.Fatal("expected thinking_messages config item")
|
|
}
|
|
if err := item.setFunc("false"); err != nil {
|
|
t.Fatalf("set thinking_messages: %v", err)
|
|
}
|
|
if e.display.ThinkingMessages {
|
|
t.Fatal("expected thinking messages to be disabled")
|
|
}
|
|
}
|
|
|
|
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 TestReply_DoesNotTransformLocalReferencesWhenEnabled(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "feishu"}
|
|
a := &namedStubModelModeAgent{name: "codex"}
|
|
e := NewEngine("test", a, []Platform{p}, "", LangEnglish)
|
|
e.SetBaseWorkDir("/root/code/demo")
|
|
e.SetReferenceConfig(ReferenceRenderCfg{
|
|
NormalizeAgents: []string{"codex"},
|
|
RenderPlatforms: []string{"feishu"},
|
|
DisplayPath: "relative",
|
|
MarkerStyle: "emoji",
|
|
EnclosureStyle: "code",
|
|
})
|
|
|
|
e.reply(p, "ctx", "See /root/code/demo/src/app.ts:42")
|
|
|
|
if len(p.sent) != 1 {
|
|
t.Fatalf("sent messages = %d, want 1", len(p.sent))
|
|
}
|
|
if got := p.sent[0]; got != "See /root/code/demo/src/app.ts:42" {
|
|
t.Fatalf("reply content = %q, want raw path", got)
|
|
}
|
|
}
|
|
|
|
func TestReplyWithCard_DoesNotTransformMarkdownOrFallback(t *testing.T) {
|
|
p := &stubCardPlatform{stubPlatformEngine: stubPlatformEngine{n: "feishu"}}
|
|
a := &namedStubModelModeAgent{name: "codex"}
|
|
e := NewEngine("test", a, []Platform{p}, "", LangEnglish)
|
|
e.SetBaseWorkDir("/root/code/demo")
|
|
e.SetReferenceConfig(ReferenceRenderCfg{
|
|
NormalizeAgents: []string{"codex"},
|
|
RenderPlatforms: []string{"feishu"},
|
|
DisplayPath: "basename",
|
|
MarkerStyle: "ascii",
|
|
EnclosureStyle: "code",
|
|
})
|
|
card := NewCard().Markdown("Inspect /root/code/demo/src/app.ts:42").Build()
|
|
|
|
e.replyWithCard(p, "ctx", card)
|
|
|
|
if len(p.repliedCards) != 1 {
|
|
t.Fatalf("replied cards = %d, want 1", len(p.repliedCards))
|
|
}
|
|
rendered := p.repliedCards[0]
|
|
md, ok := rendered.Elements[0].(CardMarkdown)
|
|
if !ok {
|
|
t.Fatalf("first card element = %T, want CardMarkdown", rendered.Elements[0])
|
|
}
|
|
if md.Content != "Inspect /root/code/demo/src/app.ts:42" {
|
|
t.Fatalf("card markdown = %q, want raw reference", md.Content)
|
|
}
|
|
if got := rendered.RenderText(); !strings.Contains(got, "/root/code/demo/src/app.ts:42") {
|
|
t.Fatalf("fallback RenderText() = %q, want raw reference", got)
|
|
}
|
|
}
|
|
|
|
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.SetAgentSessionID("session-123", "test")
|
|
e.sessions.SetSessionName("session-123", "Focus")
|
|
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], "Focus") {
|
|
t.Fatalf("current text = %q, want session name 'Focus'", 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 TestCmdCurrent_ShowsAgentSummaryWhenNoCustomName(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "plain"}
|
|
agent := &stubListAgent{sessions: []AgentSessionInfo{
|
|
{ID: "session-abc", Summary: "Fix the login bug", MessageCount: 5},
|
|
}}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
msg := &Message{SessionKey: "test:user1", ReplyCtx: "ctx"}
|
|
session := e.sessions.GetOrCreateActive(msg.SessionKey)
|
|
session.SetAgentSessionID("session-abc", "test")
|
|
|
|
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], "Fix the login bug") {
|
|
t.Fatalf("current text = %q, want agent summary 'Fix the login bug'", p.sent[0])
|
|
}
|
|
}
|
|
|
|
func TestCmdCurrent_ShowsUntitledWhenNoNameOrSummary(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "plain"}
|
|
agent := &stubListAgent{sessions: []AgentSessionInfo{
|
|
{ID: "session-xyz", Summary: "", MessageCount: 0},
|
|
}}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
msg := &Message{SessionKey: "test:user1", ReplyCtx: "ctx"}
|
|
session := e.sessions.GetOrCreateActive(msg.SessionKey)
|
|
session.SetAgentSessionID("session-xyz", "test")
|
|
|
|
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], "(untitled)") {
|
|
t.Fatalf("current text = %q, want '(untitled)' fallback", p.sent[0])
|
|
}
|
|
}
|
|
|
|
func TestCmdCurrent_CustomNameOverridesSummary(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "plain"}
|
|
agent := &stubListAgent{sessions: []AgentSessionInfo{
|
|
{ID: "session-override", Summary: "Agent summary", MessageCount: 3},
|
|
}}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
msg := &Message{SessionKey: "test:user1", ReplyCtx: "ctx"}
|
|
session := e.sessions.GetOrCreateActive(msg.SessionKey)
|
|
session.SetAgentSessionID("session-override", "test")
|
|
e.sessions.SetSessionName("session-override", "MyCustomName")
|
|
|
|
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], "MyCustomName") {
|
|
t.Fatalf("current text = %q, want custom name 'MyCustomName'", p.sent[0])
|
|
}
|
|
if strings.Contains(p.sent[0], "Agent summary") {
|
|
t.Fatalf("current text = %q, should not contain agent summary when custom name set", p.sent[0])
|
|
}
|
|
}
|
|
|
|
func TestCmdCurrent_NotStartedSessionShowsUntitled(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "plain"}
|
|
e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish)
|
|
msg := &Message{SessionKey: "test:user1", ReplyCtx: "ctx"}
|
|
|
|
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], "(untitled)") {
|
|
t.Fatalf("current text = %q, want '(untitled)' for not-started session", p.sent[0])
|
|
}
|
|
}
|
|
|
|
type stubTitleAgent struct {
|
|
stubAgent
|
|
titles map[string]string
|
|
}
|
|
|
|
func (a *stubTitleAgent) GetSessionTitle(sessionID string) string {
|
|
if a.titles == nil {
|
|
return ""
|
|
}
|
|
return a.titles[sessionID]
|
|
}
|
|
|
|
func TestCmdCurrent_SessionTitleProviderFallback(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "plain"}
|
|
agent := &stubTitleAgent{
|
|
titles: map[string]string{
|
|
"session-not-in-list": "Title from DB",
|
|
},
|
|
}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
msg := &Message{SessionKey: "test:user1", ReplyCtx: "ctx"}
|
|
session := e.sessions.GetOrCreateActive(msg.SessionKey)
|
|
session.SetAgentSessionID("session-not-in-list", "test")
|
|
|
|
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], "Title from DB") {
|
|
t.Fatalf("current text = %q, want 'Title from DB' from SessionTitleProvider", p.sent[0])
|
|
}
|
|
}
|
|
|
|
func TestCmdCurrent_SessionTitleProviderNotUsedWhenListMatches(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "plain"}
|
|
agent := &stubListAgentWithTitle{
|
|
stubTitleAgent: stubTitleAgent{
|
|
titles: map[string]string{
|
|
"session-abc": "DB Title (should not appear)",
|
|
},
|
|
},
|
|
sessions: []AgentSessionInfo{
|
|
{ID: "session-abc", Summary: "List Summary", MessageCount: 5},
|
|
},
|
|
}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
msg := &Message{SessionKey: "test:user1", ReplyCtx: "ctx"}
|
|
session := e.sessions.GetOrCreateActive(msg.SessionKey)
|
|
session.SetAgentSessionID("session-abc", "test")
|
|
|
|
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], "List Summary") {
|
|
t.Fatalf("current text = %q, want 'List Summary' from ListSessions", p.sent[0])
|
|
}
|
|
}
|
|
|
|
type stubListAgentWithTitle struct {
|
|
stubTitleAgent
|
|
sessions []AgentSessionInfo
|
|
}
|
|
|
|
func (a *stubListAgentWithTitle) ListSessions(_ context.Context) ([]AgentSessionInfo, error) {
|
|
return a.sessions, nil
|
|
}
|
|
|
|
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_SyncsLocalSessionSnapshot(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "plain"}
|
|
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: "test:user1", ReplyCtx: "ctx"}
|
|
|
|
victim := e.sessions.NewSession("test:user2", "victim")
|
|
victim.SetAgentSessionID("session-1", "stub")
|
|
keep := e.sessions.NewSession("test:user3", "keep")
|
|
keep.SetAgentSessionID("session-2", "stub")
|
|
|
|
e.cmdDelete(p, msg, []string{"1"})
|
|
|
|
if got, want := strings.Join(agent.deleted, ","), "session-1"; got != want {
|
|
t.Fatalf("deleted = %q, want %q", got, want)
|
|
}
|
|
if got := e.sessions.FindByID(victim.ID); got != nil {
|
|
t.Fatalf("victim session should be removed, got %+v", got)
|
|
}
|
|
if got := e.sessions.FindByID(keep.ID); got == nil {
|
|
t.Fatal("keep session should remain")
|
|
}
|
|
}
|
|
|
|
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 deleting card after submit")
|
|
}
|
|
// Submit is now async; the returned card is a "deleting" indicator.
|
|
// Wait for the background goroutine to complete and push the result card.
|
|
waitDeleteModePhase(t, e, msg.SessionKey, "result")
|
|
if got, want := strings.Join(agent.deleted, ","), "session-1,session-3"; got != want {
|
|
t.Fatalf("deleted = %q, want %q", got, want)
|
|
}
|
|
refreshed := p.getRefreshedCards()
|
|
if len(refreshed) == 0 {
|
|
t.Fatal("expected refreshed result card via RefreshCard")
|
|
}
|
|
pushedCard := refreshed[len(refreshed)-1]
|
|
if !strings.Contains(pushedCard.RenderText(), "Session deleted: One") {
|
|
t.Fatalf("result text = %q, want delete result", pushedCard.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 deleting card after submit")
|
|
}
|
|
// Wait for async deletion to complete.
|
|
waitDeleteModePhase(t, e, msg.SessionKey, "result")
|
|
refreshed := p.getRefreshedCards()
|
|
if len(refreshed) == 0 {
|
|
t.Fatal("expected refreshed result card via RefreshCard")
|
|
}
|
|
pushedCard := refreshed[len(refreshed)-1]
|
|
resultText := pushedCard.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 deleting card")
|
|
}
|
|
// Wait for async deletion to complete.
|
|
waitDeleteModePhase(t, e, msg.SessionKey, "result")
|
|
if len(agent.deleted) != 0 {
|
|
t.Fatalf("deleted = %v, want none", agent.deleted)
|
|
}
|
|
if len(p.getRefreshedCards()) == 0 {
|
|
t.Fatal("expected refreshed result card via RefreshCard")
|
|
}
|
|
pushedCard := p.getRefreshedCards()[len(p.getRefreshedCards())-1]
|
|
if !strings.Contains(pushedCard.RenderText(), "Cannot delete the currently active session") {
|
|
t.Fatalf("result text = %q, want active-session warning", pushedCard.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"}
|
|
// Register both sessions so they pass the owned-session filter.
|
|
s1 := e.sessions.GetOrCreateActive(msg.SessionKey)
|
|
s1.SetAgentSessionID("session-1", "test")
|
|
s2 := e.sessions.NewSession(msg.SessionKey, "two")
|
|
s2.SetAgentSessionID("session-2", "test")
|
|
// Switch back to s1 as the active session.
|
|
e.sessions.SwitchSession(msg.SessionKey, s1.ID)
|
|
|
|
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 deleting card after submit")
|
|
}
|
|
// Wait for async deletion to complete.
|
|
waitDeleteModePhase(t, e, msg.SessionKey, "result")
|
|
if got, want := strings.Join(agent.deleted, ","), "session-1,session-3"; got != want {
|
|
t.Fatalf("deleted = %q, want %q", got, want)
|
|
}
|
|
refreshed := p.getRefreshedCards()
|
|
if len(refreshed) == 0 {
|
|
t.Fatal("expected pushed result card via RefreshCard")
|
|
}
|
|
pushedCard := refreshed[len(refreshed)-1]
|
|
if !strings.Contains(pushedCard.RenderText(), "Session deleted: One") {
|
|
t.Fatalf("result text = %q, want delete result", pushedCard.RenderText())
|
|
}
|
|
}
|
|
|
|
func TestExecuteCardActionStop_RemovesInteractiveState(t *testing.T) {
|
|
e := newTestEngine()
|
|
e.interactiveMu.Lock()
|
|
e.interactiveStates["test:user1"] = &interactiveState{}
|
|
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 be removed")
|
|
}
|
|
}
|
|
|
|
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 switch 1" {
|
|
t.Fatalf("first /model button = %q, want %q", got, "cmd:/model switch 1")
|
|
}
|
|
}
|
|
|
|
func TestCmdModel_UpdatesActiveProviderModel(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "plain"}
|
|
agent := &stubModelModeAgent{
|
|
model: "gpt-4.1-mini",
|
|
providers: []ProviderConfig{
|
|
{
|
|
Name: "openai",
|
|
Model: "gpt-4.1-mini",
|
|
Models: []ModelOption{{Name: "gpt-4.1", Alias: "gpt"}, {Name: "gpt-4.1-mini", Alias: "mini"}},
|
|
},
|
|
},
|
|
active: "openai",
|
|
}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
var savedProvider, savedModel string
|
|
e.SetProviderModelSaveFunc(func(providerName, model string) error {
|
|
savedProvider = providerName
|
|
savedModel = model
|
|
return nil
|
|
})
|
|
msg := &Message{SessionKey: "test:user1", ReplyCtx: "ctx"}
|
|
|
|
s := e.sessions.GetOrCreateActive(msg.SessionKey)
|
|
s.SetAgentSessionID("existing-session", "test")
|
|
|
|
e.cmdModel(p, msg, []string{"switch", "gpt"})
|
|
|
|
if agent.model != "gpt-4.1" {
|
|
t.Fatalf("agent model = %q, want gpt-4.1", agent.model)
|
|
}
|
|
if got := agent.GetActiveProvider(); got == nil || got.Model != "gpt-4.1" {
|
|
t.Fatalf("active provider model = %#v, want gpt-4.1", got)
|
|
}
|
|
if got := agent.GetModel(); got != "gpt-4.1" {
|
|
t.Fatalf("GetModel() = %q, want gpt-4.1", got)
|
|
}
|
|
if savedProvider != "openai" || savedModel != "gpt-4.1" {
|
|
t.Fatalf("saved provider/model = %q/%q, want openai/gpt-4.1", savedProvider, savedModel)
|
|
}
|
|
if active := e.sessions.GetOrCreateActive(msg.SessionKey); active.AgentSessionID != "existing-session" {
|
|
t.Fatalf("session id = %q, want preserved after model switch", active.AgentSessionID)
|
|
}
|
|
}
|
|
|
|
func TestCmdModel_DirectNameDoesNotNeedModelListMatch(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "plain"}
|
|
agent := &stubStrictModelAgent{}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
msg := &Message{SessionKey: "test:user1", ReplyCtx: "ctx"}
|
|
|
|
e.cmdModel(p, msg, []string{"switch", "custom/provider-model"})
|
|
|
|
if agent.model != "custom/provider-model" {
|
|
t.Fatalf("agent model = %q, want custom/provider-model", agent.model)
|
|
}
|
|
if agent.calls != 0 {
|
|
t.Fatalf("AvailableModels calls = %d, want 0 for direct name switch", agent.calls)
|
|
}
|
|
}
|
|
|
|
func TestCmdModel_AliasWithPunctuationStillResolves(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "plain"}
|
|
agent := &stubStrictModelAgent{models: []ModelOption{{Name: "openai/gpt-4.1", Alias: "gpt-4.1"}}}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
msg := &Message{SessionKey: "test:user1", ReplyCtx: "ctx"}
|
|
|
|
e.cmdModel(p, msg, []string{"switch", "gpt-4.1"})
|
|
|
|
if agent.model != "openai/gpt-4.1" {
|
|
t.Fatalf("agent model = %q, want openai/gpt-4.1", agent.model)
|
|
}
|
|
if agent.calls != 1 {
|
|
t.Fatalf("AvailableModels calls = %d, want 1 for punctuated alias lookup", agent.calls)
|
|
}
|
|
}
|
|
|
|
func TestCmdModel_AliasStillResolvesOnColdStart(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "plain"}
|
|
agent := &stubStrictModelAgent{models: []ModelOption{{Name: "gpt-4.1", Alias: "gpt"}}}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
msg := &Message{SessionKey: "test:user1", ReplyCtx: "ctx"}
|
|
|
|
e.cmdModel(p, msg, []string{"switch", "gpt"})
|
|
|
|
if agent.model != "gpt-4.1" {
|
|
t.Fatalf("agent model = %q, want gpt-4.1", agent.model)
|
|
}
|
|
}
|
|
|
|
func TestCmdModel_LegacySyntaxStillWorks(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "plain"}
|
|
agent := &stubModelModeAgent{}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
msg := &Message{SessionKey: "test:user1", ReplyCtx: "ctx"}
|
|
|
|
e.cmdModel(p, msg, []string{"gpt"})
|
|
|
|
if agent.model != "gpt-4.1" {
|
|
t.Fatalf("agent model = %q, want gpt-4.1", agent.model)
|
|
}
|
|
}
|
|
|
|
func TestCmdModel_SavesModelWhenNoActiveProvider(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "plain"}
|
|
agent := &stubModelModeAgent{
|
|
model: "gpt-4.1-mini",
|
|
providers: []ProviderConfig{
|
|
{
|
|
Name: "openai",
|
|
Model: "gpt-4.1-mini",
|
|
Models: []ModelOption{{Name: "gpt-4.1", Alias: "gpt"}, {Name: "gpt-4.1-mini", Alias: "mini"}},
|
|
},
|
|
},
|
|
}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
|
|
var savedModel string
|
|
e.SetModelSaveFunc(func(model string) error {
|
|
savedModel = model
|
|
return nil
|
|
})
|
|
|
|
msg := &Message{SessionKey: "test:user1", ReplyCtx: "ctx"}
|
|
e.cmdModel(p, msg, []string{"switch", "gpt"})
|
|
|
|
if agent.model != "gpt-4.1" {
|
|
t.Fatalf("agent model = %q, want gpt-4.1", agent.model)
|
|
}
|
|
if savedModel != "gpt-4.1" {
|
|
t.Fatalf("saved model = %q, want gpt-4.1", savedModel)
|
|
}
|
|
}
|
|
|
|
func TestCmdModel_DoesNotClaimSuccessWhenModelSaveFails(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "plain"}
|
|
agent := &stubModelModeAgent{
|
|
model: "gpt-4.1-mini",
|
|
providers: []ProviderConfig{
|
|
{
|
|
Name: "openai",
|
|
Model: "gpt-4.1-mini",
|
|
Models: []ModelOption{{Name: "gpt-4.1", Alias: "gpt"}, {Name: "gpt-4.1-mini", Alias: "mini"}},
|
|
},
|
|
},
|
|
}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
e.SetModelSaveFunc(func(model string) error {
|
|
return errors.New("disk full")
|
|
})
|
|
|
|
msg := &Message{SessionKey: "test:user1", ReplyCtx: "ctx"}
|
|
s := e.sessions.GetOrCreateActive(msg.SessionKey)
|
|
s.SetAgentSessionID("existing-session", "test")
|
|
s.AddHistory("user", "keep me")
|
|
|
|
e.cmdModel(p, msg, []string{"switch", "gpt"})
|
|
|
|
if agent.model != "gpt-4.1-mini" {
|
|
t.Fatalf("agent model = %q, want unchanged gpt-4.1-mini", agent.model)
|
|
}
|
|
if active := e.sessions.GetOrCreateActive(msg.SessionKey); active.AgentSessionID != "existing-session" {
|
|
t.Fatalf("session id = %q, want existing-session after failure", active.AgentSessionID)
|
|
}
|
|
if active := e.sessions.GetOrCreateActive(msg.SessionKey); len(active.History) != 1 {
|
|
t.Fatalf("history length = %d, want 1 after failure", len(active.History))
|
|
}
|
|
sent := p.getSent()
|
|
if len(sent) != 1 {
|
|
t.Fatalf("sent messages = %d, want 1", len(sent))
|
|
}
|
|
if !strings.Contains(sent[0], "Failed to change model") {
|
|
t.Fatalf("reply = %q, want model change failure message", sent[0])
|
|
}
|
|
}
|
|
|
|
func TestCmdModel_MultiWorkspaceUsesWorkspaceAgentAndSessions(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "plain"}
|
|
globalAgent := &stubModelModeAgent{model: "gpt-4.1-mini"}
|
|
e := NewEngine("test", globalAgent, []Platform{p}, "", LangEnglish)
|
|
|
|
baseDir := t.TempDir()
|
|
bindingPath := filepath.Join(t.TempDir(), "bindings.json")
|
|
e.SetMultiWorkspace(baseDir, bindingPath)
|
|
|
|
wsDir := normalizeWorkspacePath(t.TempDir())
|
|
channelID := "C-model"
|
|
e.workspaceBindings.Bind("project:test", channelID, "chan", wsDir)
|
|
|
|
ws := e.workspacePool.GetOrCreate(wsDir)
|
|
wsAgent := &stubModelModeAgent{model: "gpt-4.1-mini"}
|
|
ws.agent = wsAgent
|
|
ws.sessions = NewSessionManager("")
|
|
|
|
msg := &Message{SessionKey: "feishu:" + channelID + ":u1", ReplyCtx: "ctx"}
|
|
|
|
globalSession := e.sessions.GetOrCreateActive(msg.SessionKey)
|
|
globalSession.SetAgentSessionID("global-session", "test")
|
|
wsSession := ws.sessions.GetOrCreateActive(msg.SessionKey)
|
|
wsSession.SetAgentSessionID("workspace-session", "test")
|
|
|
|
e.cmdModel(p, msg, []string{"switch", "gpt"})
|
|
|
|
if wsAgent.model != "gpt-4.1" {
|
|
t.Fatalf("workspace agent model = %q, want gpt-4.1", wsAgent.model)
|
|
}
|
|
if globalAgent.model != "gpt-4.1-mini" {
|
|
t.Fatalf("global agent model = %q, want unchanged", globalAgent.model)
|
|
}
|
|
if got := ws.sessions.GetOrCreateActive(msg.SessionKey).AgentSessionID; got != "workspace-session" {
|
|
t.Fatalf("workspace session id = %q, want preserved", got)
|
|
}
|
|
if got := e.sessions.GetOrCreateActive(msg.SessionKey).AgentSessionID; got != "global-session" {
|
|
t.Fatalf("global session id = %q, want untouched", got)
|
|
}
|
|
}
|
|
|
|
func TestCmdModel_MultiWorkspaceSwitchDoesNotMutateProviderModel(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "plain"}
|
|
globalAgent := &stubModelModeAgent{model: "gpt-4.1-mini"}
|
|
e := NewEngine("test", globalAgent, []Platform{p}, "", LangEnglish)
|
|
|
|
baseDir := t.TempDir()
|
|
bindingPath := filepath.Join(t.TempDir(), "bindings.json")
|
|
e.SetMultiWorkspace(baseDir, bindingPath)
|
|
|
|
wsDir := normalizeWorkspacePath(t.TempDir())
|
|
channelID := "C-model-provider"
|
|
e.workspaceBindings.Bind("project:test", channelID, "chan", wsDir)
|
|
|
|
ws := e.workspacePool.GetOrCreate(wsDir)
|
|
wsAgent := &stubModelModeAgent{
|
|
model: "gpt-4.1-mini",
|
|
providers: []ProviderConfig{{
|
|
Name: "openai",
|
|
Model: "gpt-4.1-mini",
|
|
Models: []ModelOption{{Name: "gpt-4.1", Alias: "gpt"}, {Name: "gpt-4.1-mini", Alias: "mini"}},
|
|
}},
|
|
active: "openai",
|
|
}
|
|
ws.agent = wsAgent
|
|
ws.sessions = NewSessionManager("")
|
|
|
|
msg := &Message{SessionKey: "feishu:" + channelID + ":u1", ReplyCtx: "ctx"}
|
|
|
|
e.cmdModel(p, msg, []string{"switch", "gpt"})
|
|
|
|
if wsAgent.model != "gpt-4.1" {
|
|
t.Fatalf("workspace agent model = %q, want gpt-4.1", wsAgent.model)
|
|
}
|
|
if got := wsAgent.GetActiveProvider(); got == nil || got.Model != "gpt-4.1-mini" {
|
|
t.Fatalf("workspace active provider = %#v, want unchanged model gpt-4.1-mini", got)
|
|
}
|
|
}
|
|
|
|
func TestCmdModel_KeepHistoryPreservesSessionID(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "plain"}
|
|
agent := &stubModelModeAgent{
|
|
model: "gpt-4.1-mini",
|
|
providers: []ProviderConfig{
|
|
{
|
|
Name: "openai",
|
|
Model: "gpt-4.1-mini",
|
|
Models: []ModelOption{{Name: "gpt-4.1", Alias: "gpt"}, {Name: "gpt-4.1-mini", Alias: "mini"}},
|
|
},
|
|
},
|
|
}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
|
|
msg := &Message{SessionKey: "test:user1", ReplyCtx: "ctx"}
|
|
s := e.sessions.GetOrCreateActive(msg.SessionKey)
|
|
s.SetAgentSessionID("existing-session-id", "test")
|
|
s.AddHistory("user", "hello")
|
|
|
|
e.cmdModel(p, msg, []string{"switch", "gpt"})
|
|
|
|
if got := s.GetAgentSessionID(); got != "existing-session-id" {
|
|
t.Fatalf("session id = %q, want existing-session-id (should be preserved)", got)
|
|
}
|
|
if got := len(s.GetHistory(0)); got != 1 {
|
|
t.Fatalf("history len = %d, want 1 (original entry preserved)", got)
|
|
}
|
|
}
|
|
|
|
func TestGetOrCreateWorkspaceAgent_InheritsActiveProvider(t *testing.T) {
|
|
agentName := "test-workspace-provider-inherit"
|
|
RegisterAgent(agentName, func(opts map[string]any) (Agent, error) {
|
|
agent := &namedStubModelModeAgent{name: agentName}
|
|
if model, ok := opts["model"].(string); ok {
|
|
agent.model = model
|
|
}
|
|
if mode, ok := opts["mode"].(string); ok {
|
|
agent.mode = mode
|
|
}
|
|
return agent, nil
|
|
})
|
|
|
|
globalAgent := &namedStubModelModeAgent{
|
|
name: agentName,
|
|
stubModelModeAgent: stubModelModeAgent{
|
|
model: "gpt-4.1-mini",
|
|
mode: "default",
|
|
providers: []ProviderConfig{
|
|
{Name: "openai", Model: "gpt-4.1-mini"},
|
|
{Name: "azure", Model: "gpt-4.1"},
|
|
},
|
|
active: "azure",
|
|
},
|
|
}
|
|
e := NewEngine("test", globalAgent, []Platform{&stubPlatformEngine{n: "plain"}}, "", LangEnglish)
|
|
e.SetMultiWorkspace(t.TempDir(), filepath.Join(t.TempDir(), "bindings.json"))
|
|
|
|
wsAgentRaw, _, err := e.getOrCreateWorkspaceAgent(normalizeWorkspacePath(t.TempDir()))
|
|
if err != nil {
|
|
t.Fatalf("getOrCreateWorkspaceAgent returned error: %v", err)
|
|
}
|
|
|
|
wsAgent, ok := wsAgentRaw.(*namedStubModelModeAgent)
|
|
if !ok {
|
|
t.Fatalf("workspace agent type = %T, want *namedStubModelModeAgent", wsAgentRaw)
|
|
}
|
|
if wsAgent.model != "gpt-4.1-mini" {
|
|
t.Fatalf("workspace model = %q, want inherited global model", wsAgent.model)
|
|
}
|
|
if got := wsAgent.GetActiveProvider(); got == nil || got.Name != "azure" {
|
|
t.Fatalf("workspace active provider = %#v, want azure", got)
|
|
}
|
|
}
|
|
|
|
func TestGetOrCreateWorkspaceAgent_InheritsSnapshotOptions(t *testing.T) {
|
|
agentName := "test-workspace-option-snapshot"
|
|
RegisterAgent(agentName, func(opts map[string]any) (Agent, error) {
|
|
snapshot := make(map[string]any, len(opts))
|
|
for k, v := range opts {
|
|
snapshot[k] = v
|
|
}
|
|
return &namedStubWorkspaceOptionAgent{
|
|
namedStubModelModeAgent: namedStubModelModeAgent{
|
|
name: agentName,
|
|
stubModelModeAgent: stubModelModeAgent{
|
|
model: "gpt-5.4",
|
|
mode: "yolo",
|
|
reasoningEffort: "high",
|
|
},
|
|
},
|
|
opts: snapshot,
|
|
}, nil
|
|
})
|
|
|
|
globalAgent := &namedStubWorkspaceOptionAgent{
|
|
namedStubModelModeAgent: namedStubModelModeAgent{
|
|
name: agentName,
|
|
stubModelModeAgent: stubModelModeAgent{
|
|
model: "gpt-5.4",
|
|
mode: "yolo",
|
|
reasoningEffort: "high",
|
|
},
|
|
},
|
|
opts: map[string]any{
|
|
"backend": "app_server",
|
|
"app_server_url": "ws://127.0.0.1:3846",
|
|
"codex_home": "/tmp/codex-home",
|
|
"reasoning_effort": "high",
|
|
"mode": "yolo",
|
|
"model": "gpt-5.4",
|
|
"run_as_user": "workspace-snapshot-user",
|
|
"run_as_env": []string{"SNAPSHOT_ONLY"},
|
|
},
|
|
runAsUser: "fallback-user",
|
|
runAsEnv: []string{"FALLBACK_ONLY"},
|
|
}
|
|
e := NewEngine("test", globalAgent, []Platform{&stubPlatformEngine{n: "plain"}}, "", LangEnglish)
|
|
e.SetMultiWorkspace(t.TempDir(), filepath.Join(t.TempDir(), "bindings.json"))
|
|
|
|
workspace := normalizeWorkspacePath(t.TempDir())
|
|
wsAgentRaw, _, err := e.getOrCreateWorkspaceAgent(workspace)
|
|
if err != nil {
|
|
t.Fatalf("getOrCreateWorkspaceAgent returned error: %v", err)
|
|
}
|
|
|
|
wsAgent, ok := wsAgentRaw.(*namedStubWorkspaceOptionAgent)
|
|
if !ok {
|
|
t.Fatalf("workspace agent type = %T, want *namedStubWorkspaceOptionAgent", wsAgentRaw)
|
|
}
|
|
if got := wsAgent.opts["backend"]; got != "app_server" {
|
|
t.Fatalf("workspace backend = %#v, want app_server", got)
|
|
}
|
|
if got := wsAgent.opts["app_server_url"]; got != "ws://127.0.0.1:3846" {
|
|
t.Fatalf("workspace app_server_url = %#v, want ws://127.0.0.1:3846", got)
|
|
}
|
|
if got := wsAgent.opts["codex_home"]; got != "/tmp/codex-home" {
|
|
t.Fatalf("workspace codex_home = %#v, want /tmp/codex-home", got)
|
|
}
|
|
if got := wsAgent.opts["reasoning_effort"]; got != "high" {
|
|
t.Fatalf("workspace reasoning_effort = %#v, want high", got)
|
|
}
|
|
if got := wsAgent.opts["work_dir"]; got != workspace {
|
|
t.Fatalf("workspace work_dir = %#v, want %q", got, workspace)
|
|
}
|
|
if got := wsAgent.opts["run_as_user"]; got != "workspace-snapshot-user" {
|
|
t.Fatalf("workspace run_as_user = %#v, want snapshot value", got)
|
|
}
|
|
gotRunAsEnv, _ := wsAgent.opts["run_as_env"].([]string)
|
|
if len(gotRunAsEnv) != 1 || gotRunAsEnv[0] != "SNAPSHOT_ONLY" {
|
|
t.Fatalf("workspace run_as_env = %#v, want snapshot value", wsAgent.opts["run_as_env"])
|
|
}
|
|
}
|
|
|
|
func TestWorkspaceContext_PerChannelIndependence(t *testing.T) {
|
|
agentName := "test-workspace-context-dir-override"
|
|
RegisterAgent(agentName, func(opts map[string]any) (Agent, error) {
|
|
agent := &namedStubWorkDirAgent{name: agentName}
|
|
if workDir, ok := opts["work_dir"].(string); ok {
|
|
agent.workDir = workDir
|
|
}
|
|
return agent, nil
|
|
})
|
|
|
|
workspace := normalizeWorkspacePath(t.TempDir())
|
|
dirA := filepath.Join(workspace, "channelA")
|
|
dirB := filepath.Join(workspace, "channelB")
|
|
if err := os.MkdirAll(dirA, 0o755); err != nil {
|
|
t.Fatalf("mkdir: %v", err)
|
|
}
|
|
if err := os.MkdirAll(dirB, 0o755); err != nil {
|
|
t.Fatalf("mkdir: %v", err)
|
|
}
|
|
|
|
store := NewProjectStateStore(filepath.Join(t.TempDir(), "projects", "test.state.json"))
|
|
keyA := workspace + ":feishu:oc_aaa:ou_111"
|
|
keyB := workspace + ":feishu:oc_bbb:ou_222"
|
|
store.SetWorkspaceDirOverride(keyA, dirA)
|
|
store.SetWorkspaceDirOverride(keyB, dirB)
|
|
store.Save()
|
|
|
|
e := NewEngine("test", &namedStubWorkDirAgent{name: agentName, stubWorkDirAgent: stubWorkDirAgent{workDir: workspace}}, []Platform{&stubPlatformEngine{n: "plain"}}, "", LangEnglish)
|
|
e.SetMultiWorkspace(workspace, filepath.Join(t.TempDir(), "bindings.json"))
|
|
e.SetProjectStateStore(store)
|
|
|
|
agentA, sessionsA, interactiveKeyA, effectiveDirA, err := e.workspaceContext(workspace, "feishu:oc_aaa:ou_111")
|
|
if err != nil {
|
|
t.Fatalf("workspaceContext A error: %v", err)
|
|
}
|
|
agentB, sessionsB, interactiveKeyB, effectiveDirB, err := e.workspaceContext(workspace, "feishu:oc_bbb:ou_222")
|
|
if err != nil {
|
|
t.Fatalf("workspaceContext B error: %v", err)
|
|
}
|
|
|
|
if interactiveKeyA != keyA {
|
|
t.Fatalf("interactiveKeyA = %q, want %q", interactiveKeyA, keyA)
|
|
}
|
|
if interactiveKeyB != keyB {
|
|
t.Fatalf("interactiveKeyB = %q, want %q", interactiveKeyB, keyB)
|
|
}
|
|
if effectiveDirA != dirA {
|
|
t.Fatalf("effectiveDirA = %q, want %q", effectiveDirA, dirA)
|
|
}
|
|
if effectiveDirB != dirB {
|
|
t.Fatalf("effectiveDirB = %q, want %q", effectiveDirB, dirB)
|
|
}
|
|
if agentA == agentB {
|
|
t.Fatal("workspaceContext returned same agent for different effective dirs")
|
|
}
|
|
if sessionsA == sessionsB {
|
|
t.Fatal("workspaceContext returned same session manager for different effective dirs")
|
|
}
|
|
if got := agentA.(interface{ GetWorkDir() string }).GetWorkDir(); got != dirA {
|
|
t.Fatalf("agentA workDir = %q, want %q", got, dirA)
|
|
}
|
|
if got := agentB.(interface{ GetWorkDir() string }).GetWorkDir(); got != dirB {
|
|
t.Fatalf("agentB workDir = %q, want %q", got, dirB)
|
|
}
|
|
}
|
|
|
|
func TestCmdDir_ShowsCurrentDirectory(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "plain"}
|
|
agent := &stubWorkDirAgent{workDir: "/tmp/project-a"}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
|
|
e.cmdDir(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], "/tmp/project-a") {
|
|
t.Fatalf("sent = %q, want current work dir", p.sent[0])
|
|
}
|
|
}
|
|
|
|
func TestCmdDir_SwitchesDirectoryAndResetsSession(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "plain"}
|
|
tempDir := t.TempDir()
|
|
nextDir := filepath.Join(tempDir, "next")
|
|
if err := os.Mkdir(nextDir, 0o755); err != nil {
|
|
t.Fatalf("mkdir next dir: %v", err)
|
|
}
|
|
|
|
agent := &stubWorkDirAgent{workDir: tempDir}
|
|
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.cmdDir(p, msg, []string{"next"})
|
|
|
|
if agent.workDir != nextDir {
|
|
t.Fatalf("workDir = %q, want %q", agent.workDir, nextDir)
|
|
}
|
|
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], nextDir) {
|
|
t.Fatalf("sent = %v, want directory changed message", p.sent)
|
|
}
|
|
}
|
|
|
|
func TestCmdDir_RejectsMissingDirectory(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "plain"}
|
|
tempDir := t.TempDir()
|
|
missingDir := filepath.Join(tempDir, "missing")
|
|
agent := &stubWorkDirAgent{workDir: tempDir}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
|
|
e.cmdDir(p, &Message{SessionKey: "test:user1", ReplyCtx: "ctx"}, []string{"missing"})
|
|
|
|
if agent.workDir != tempDir {
|
|
t.Fatalf("workDir = %q, want unchanged %q", agent.workDir, tempDir)
|
|
}
|
|
if len(p.sent) != 1 || !strings.Contains(p.sent[0], missingDir) {
|
|
t.Fatalf("sent = %v, want invalid path message", p.sent)
|
|
}
|
|
}
|
|
|
|
func TestCmdDir_AliasCdStillWorks(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "plain"}
|
|
tempDir := t.TempDir()
|
|
nextDir := filepath.Join(tempDir, "next")
|
|
if err := os.Mkdir(nextDir, 0o755); err != nil {
|
|
t.Fatalf("mkdir next dir: %v", err)
|
|
}
|
|
agent := &stubWorkDirAgent{workDir: tempDir}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
e.SetAdminFrom("admin1")
|
|
|
|
e.handleCommand(p, &Message{SessionKey: "test:user1", UserID: "admin1", ReplyCtx: "ctx"}, "/cd next")
|
|
|
|
if agent.workDir != nextDir {
|
|
t.Fatalf("workDir = %q, want %q", agent.workDir, nextDir)
|
|
}
|
|
}
|
|
|
|
func TestCmdDir_HelpShowsUsage(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "plain"}
|
|
agent := &stubWorkDirAgent{workDir: "/tmp/project-a"}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
|
|
e.cmdDir(p, &Message{SessionKey: "test:user1", ReplyCtx: "ctx"}, []string{"help"})
|
|
|
|
if len(p.sent) != 1 {
|
|
t.Fatalf("sent messages = %d, want 1", len(p.sent))
|
|
}
|
|
if !strings.Contains(p.sent[0], "/dir <path>") {
|
|
t.Fatalf("sent = %q, want /dir usage", p.sent[0])
|
|
}
|
|
}
|
|
|
|
func TestCmdDir_PersistsAbsoluteOverride(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "plain"}
|
|
baseDir := t.TempDir()
|
|
nextDir := filepath.Join(baseDir, "next")
|
|
if err := os.Mkdir(nextDir, 0o755); err != nil {
|
|
t.Fatalf("mkdir next dir: %v", err)
|
|
}
|
|
statePath := filepath.Join(t.TempDir(), "projects", "test.state.json")
|
|
store := NewProjectStateStore(statePath)
|
|
|
|
agent := &stubWorkDirAgent{workDir: baseDir}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
e.SetBaseWorkDir(baseDir)
|
|
e.SetProjectStateStore(store)
|
|
|
|
e.cmdDir(p, &Message{SessionKey: "test:user1", ReplyCtx: "ctx"}, []string{"next"})
|
|
|
|
reloaded := NewProjectStateStore(statePath)
|
|
if got := reloaded.WorkDirOverride(); got != nextDir {
|
|
t.Fatalf("WorkDirOverride() = %q, want %q", got, nextDir)
|
|
}
|
|
}
|
|
|
|
func TestDirApply_MultiWorkspacePersistsWorkspaceSpecificOverride(t *testing.T) {
|
|
baseDir := t.TempDir()
|
|
workspace := normalizeWorkspacePath(t.TempDir())
|
|
nextDir := filepath.Join(workspace, "next")
|
|
if err := os.MkdirAll(nextDir, 0o755); err != nil {
|
|
t.Fatalf("mkdir next dir: %v", err)
|
|
}
|
|
|
|
statePath := filepath.Join(t.TempDir(), "projects", "test.state.json")
|
|
store := NewProjectStateStore(statePath)
|
|
agent := &stubWorkDirAgent{workDir: workspace}
|
|
e := NewEngine("test", agent, []Platform{&stubPlatformEngine{n: "plain"}}, "", LangEnglish)
|
|
e.SetMultiWorkspace(baseDir, filepath.Join(t.TempDir(), "bindings.json"))
|
|
e.SetProjectStateStore(store)
|
|
|
|
sessions := NewSessionManager("")
|
|
interactiveKey := workspace + ":feishu:oc_xxx:ou_yyy"
|
|
|
|
errMsg, successMsg := e.dirApply(agent, sessions, interactiveKey, "feishu:oc_xxx:ou_yyy", []string{"next"})
|
|
if errMsg != "" {
|
|
t.Fatalf("dirApply errMsg = %q, want empty", errMsg)
|
|
}
|
|
if !strings.Contains(successMsg, nextDir) {
|
|
t.Fatalf("successMsg = %q, want path %q", successMsg, nextDir)
|
|
}
|
|
|
|
reloaded := NewProjectStateStore(statePath)
|
|
if got := reloaded.WorkspaceDirOverride(interactiveKey); got != nextDir {
|
|
t.Fatalf("WorkspaceDirOverride(%q) = %q, want %q", interactiveKey, got, nextDir)
|
|
}
|
|
if got := reloaded.WorkDirOverride(); got != "" {
|
|
t.Fatalf("WorkDirOverride() = %q, want empty in multi-workspace mode", got)
|
|
}
|
|
}
|
|
|
|
func TestDirApply_MultiWorkspaceResetClearsWorkspaceSpecificOverride(t *testing.T) {
|
|
baseDir := t.TempDir()
|
|
workspace := normalizeWorkspacePath(t.TempDir())
|
|
overrideDir := filepath.Join(workspace, "override")
|
|
if err := os.MkdirAll(overrideDir, 0o755); err != nil {
|
|
t.Fatalf("mkdir override dir: %v", err)
|
|
}
|
|
|
|
statePath := filepath.Join(t.TempDir(), "projects", "test.state.json")
|
|
store := NewProjectStateStore(statePath)
|
|
interactiveKey := workspace + ":feishu:oc_xxx:ou_yyy"
|
|
store.SetWorkspaceDirOverride(interactiveKey, overrideDir)
|
|
store.Save()
|
|
|
|
agent := &stubWorkDirAgent{workDir: overrideDir}
|
|
e := NewEngine("test", agent, []Platform{&stubPlatformEngine{n: "plain"}}, "", LangEnglish)
|
|
e.SetBaseWorkDir(baseDir)
|
|
e.SetMultiWorkspace(baseDir, filepath.Join(t.TempDir(), "bindings.json"))
|
|
e.SetProjectStateStore(store)
|
|
|
|
sessions := NewSessionManager("")
|
|
|
|
errMsg, _ := e.dirApply(agent, sessions, interactiveKey, "feishu:oc_xxx:ou_yyy", []string{"reset"})
|
|
if errMsg != "" {
|
|
t.Fatalf("dirApply errMsg = %q, want empty", errMsg)
|
|
}
|
|
|
|
reloaded := NewProjectStateStore(statePath)
|
|
if got := reloaded.WorkspaceDirOverride(interactiveKey); got != "" {
|
|
t.Fatalf("WorkspaceDirOverride(%q) after reset = %q, want empty", interactiveKey, got)
|
|
}
|
|
}
|
|
|
|
func TestCmdDir_ResetRestoresBaseWorkDirAndClearsState(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "plain"}
|
|
baseDir := t.TempDir()
|
|
overrideDir := filepath.Join(baseDir, "override")
|
|
if err := os.Mkdir(overrideDir, 0o755); err != nil {
|
|
t.Fatalf("mkdir override dir: %v", err)
|
|
}
|
|
statePath := filepath.Join(t.TempDir(), "projects", "test.state.json")
|
|
store := NewProjectStateStore(statePath)
|
|
store.SetWorkDirOverride(overrideDir)
|
|
store.Save()
|
|
|
|
agent := &stubWorkDirAgent{workDir: overrideDir}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
e.SetBaseWorkDir(baseDir)
|
|
e.SetProjectStateStore(store)
|
|
msg := &Message{SessionKey: "test:user1", ReplyCtx: "ctx"}
|
|
|
|
s := e.sessions.GetOrCreateActive(msg.SessionKey)
|
|
s.SetAgentSessionID("existing-session", "test")
|
|
s.Name = "old"
|
|
s.AddHistory("user", "hello")
|
|
|
|
e.cmdDir(p, msg, []string{"reset"})
|
|
|
|
if agent.workDir != baseDir {
|
|
t.Fatalf("workDir = %q, want %q", agent.workDir, baseDir)
|
|
}
|
|
reloaded := NewProjectStateStore(statePath)
|
|
if got := reloaded.WorkDirOverride(); got != "" {
|
|
t.Fatalf("WorkDirOverride() = %q, want empty", got)
|
|
}
|
|
if s.GetAgentSessionID() != "" {
|
|
t.Fatalf("AgentSessionID = %q, want cleared", s.GetAgentSessionID())
|
|
}
|
|
if s.Name != "old" {
|
|
t.Fatalf("Name = %q, want unchanged", s.Name)
|
|
}
|
|
if len(s.History) != 0 {
|
|
t.Fatalf("history length = %d, want 0", len(s.History))
|
|
}
|
|
if len(p.sent) != 1 || !strings.Contains(strings.ToLower(p.sent[0]), "default") {
|
|
t.Fatalf("sent = %v, want reset success message", p.sent)
|
|
}
|
|
}
|
|
|
|
func TestCmdDir_SwitchesByHistoryIndex(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "plain"}
|
|
tempDir := t.TempDir()
|
|
dir1 := filepath.Join(tempDir, "dir1")
|
|
dir2 := filepath.Join(tempDir, "dir2")
|
|
dir3 := filepath.Join(tempDir, "dir3")
|
|
for _, d := range []string{dir1, dir2, dir3} {
|
|
if err := os.Mkdir(d, 0o755); err != nil {
|
|
t.Fatalf("mkdir: %v", err)
|
|
}
|
|
}
|
|
|
|
dataDir := t.TempDir() // separate data dir for history
|
|
agent := &stubWorkDirAgent{workDir: dir1}
|
|
e := NewEngine("test", agent, []Platform{p}, dataDir, LangEnglish)
|
|
e.SetDirHistory(NewDirHistory(dataDir))
|
|
|
|
msg := &Message{SessionKey: "test:user1", ReplyCtx: "ctx"}
|
|
|
|
// Build history: dir1 -> dir2 -> dir3
|
|
e.cmdDir(p, msg, []string{dir2})
|
|
if agent.workDir != dir2 {
|
|
t.Fatalf("after /dir dir2: workDir = %q, want %q", agent.workDir, dir2)
|
|
}
|
|
|
|
e.cmdDir(p, msg, []string{dir3})
|
|
if agent.workDir != dir3 {
|
|
t.Fatalf("after /dir dir3: workDir = %q, want %q", agent.workDir, dir3)
|
|
}
|
|
|
|
// Now history should be: [dir3, dir2, dir1] (dir1 might not be in history since it wasn't added initially)
|
|
// Current dir is dir3
|
|
// Index 2 should be dir2
|
|
|
|
p.sent = nil
|
|
e.cmdDir(p, msg, []string{"2"})
|
|
|
|
// Should have switched to dir2
|
|
if agent.workDir != dir2 {
|
|
t.Fatalf("after /dir 2: workDir = %q, want %q", agent.workDir, dir2)
|
|
}
|
|
|
|
// Check the reply mentions dir2
|
|
if len(p.sent) != 1 {
|
|
t.Fatalf("sent = %d messages, want 1", len(p.sent))
|
|
}
|
|
if !strings.Contains(p.sent[0], dir2) {
|
|
t.Fatalf("sent = %q, want message containing %q", p.sent[0], dir2)
|
|
}
|
|
}
|
|
|
|
func TestCmdDir_DisplaysCorrectIndices(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "plain"}
|
|
tempDir := t.TempDir()
|
|
dir1 := filepath.Join(tempDir, "dir1")
|
|
dir2 := filepath.Join(tempDir, "dir2")
|
|
dir3 := filepath.Join(tempDir, "dir3")
|
|
for _, d := range []string{dir1, dir2, dir3} {
|
|
if err := os.Mkdir(d, 0o755); err != nil {
|
|
t.Fatalf("mkdir: %v", err)
|
|
}
|
|
}
|
|
|
|
dataDir := t.TempDir()
|
|
agent := &stubWorkDirAgent{workDir: dir1}
|
|
e := NewEngine("test", agent, []Platform{p}, dataDir, LangEnglish)
|
|
e.SetDirHistory(NewDirHistory(dataDir))
|
|
|
|
msg := &Message{SessionKey: "test:user1", ReplyCtx: "ctx"}
|
|
|
|
// Build history
|
|
e.cmdDir(p, msg, []string{dir2})
|
|
e.cmdDir(p, msg, []string{dir3})
|
|
|
|
// Now current is dir3, history is [dir3, dir2]
|
|
p.sent = nil
|
|
e.cmdDir(p, msg, nil) // show current + history
|
|
|
|
if len(p.sent) != 1 {
|
|
t.Fatalf("sent = %d messages, want 1", len(p.sent))
|
|
}
|
|
|
|
// Verify the display shows:
|
|
// - dir3 with ▶ marker (current)
|
|
// - dir2 with ◻ marker at index 2
|
|
output := p.sent[0]
|
|
|
|
// Check that dir3 is marked as current
|
|
if !strings.Contains(output, "▶ 1. "+dir3) {
|
|
t.Fatalf("output should contain '▶ 1. %s', got: %s", dir3, output)
|
|
}
|
|
|
|
// Check that dir2 is at index 2
|
|
if !strings.Contains(output, "◻ 2. "+dir2) {
|
|
t.Fatalf("output should contain '◻ 2. %s', got: %s", dir2, output)
|
|
}
|
|
}
|
|
|
|
func TestCmdDir_ExpandsTilde(t *testing.T) {
|
|
homeDir, err := os.UserHomeDir()
|
|
if err != nil {
|
|
t.Skip("cannot determine home dir:", err)
|
|
}
|
|
|
|
p := &stubPlatformEngine{n: "plain"}
|
|
agent := &stubWorkDirAgent{workDir: homeDir}
|
|
e := NewEngine("test", agent, []Platform{p}, t.TempDir(), LangEnglish)
|
|
msg := &Message{SessionKey: "test:user1", ReplyCtx: "ctx"}
|
|
|
|
tests := []struct {
|
|
input string
|
|
wantDir string
|
|
}{
|
|
{"~", homeDir},
|
|
{"~/", homeDir},
|
|
{"~/Documents", filepath.Join(homeDir, "Documents")},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
agent.workDir = homeDir
|
|
// Ensure the target directory exists before switching
|
|
if err := os.MkdirAll(tc.wantDir, 0o755); err != nil {
|
|
t.Fatalf("MkdirAll %q: %v", tc.wantDir, err)
|
|
}
|
|
e.cmdDir(p, msg, []string{tc.input})
|
|
if agent.workDir != tc.wantDir {
|
|
t.Errorf("input %q: workDir = %q, want %q", tc.input, agent.workDir, tc.wantDir)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestEngine_AdminFrom_GatesDir(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "test"}
|
|
tempDir := t.TempDir()
|
|
agent := &stubWorkDirAgent{workDir: tempDir}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
|
|
msg := &Message{SessionKey: "test:u1", UserID: "user1", ReplyCtx: "ctx"}
|
|
e.handleCommand(p, msg, "/dir .")
|
|
|
|
if len(p.sent) != 1 {
|
|
t.Fatalf("expected 1 reply, got %d", len(p.sent))
|
|
}
|
|
if !strings.Contains(strings.ToLower(p.sent[0]), "admin") {
|
|
t.Fatalf("expected admin required message, got: %s", p.sent[0])
|
|
}
|
|
if agent.workDir != tempDir {
|
|
t.Fatalf("workDir = %q, want unchanged %q", agent.workDir, tempDir)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
// TestCmdReasoning_MultiWorkspaceSavesToWorkspaceSessions is a regression test
|
|
// for the bug where cmdReasoning called e.sessions.Save() (global) instead of
|
|
// sessions.Save() (workspace-resolved), leaving workspace session state unsaved.
|
|
func TestCmdReasoning_MultiWorkspaceSavesToWorkspaceSessions(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "plain"}
|
|
globalAgent := &stubModelModeAgent{}
|
|
e := NewEngine("test", globalAgent, []Platform{p}, "", LangEnglish)
|
|
|
|
baseDir := t.TempDir()
|
|
bindingPath := filepath.Join(t.TempDir(), "bindings.json")
|
|
e.SetMultiWorkspace(baseDir, bindingPath)
|
|
|
|
wsDir := normalizeWorkspacePath(t.TempDir())
|
|
channelID := "C-reasoning-ws"
|
|
e.workspaceBindings.Bind("project:test", channelID, "chan", wsDir)
|
|
|
|
ws := e.workspacePool.GetOrCreate(wsDir)
|
|
wsAgent := &stubModelModeAgent{}
|
|
ws.agent = wsAgent
|
|
ws.sessions = NewSessionManager("")
|
|
|
|
msg := &Message{SessionKey: "feishu:" + channelID + ":u1", ReplyCtx: "ctx"}
|
|
|
|
wsSession := ws.sessions.GetOrCreateActive(msg.SessionKey)
|
|
wsSession.SetAgentSessionID("ws-session-id", "test")
|
|
wsSession.AddHistory("user", "hello")
|
|
|
|
globalSession := e.sessions.GetOrCreateActive(msg.SessionKey)
|
|
globalSession.SetAgentSessionID("global-session-id", "test")
|
|
|
|
e.cmdReasoning(p, msg, []string{"3"}) // selects "high"
|
|
|
|
if wsAgent.reasoningEffort != "high" {
|
|
t.Fatalf("workspace agent reasoning effort = %q, want high", wsAgent.reasoningEffort)
|
|
}
|
|
if got := wsSession.GetAgentSessionID(); got != "" {
|
|
t.Fatalf("workspace session id = %q, want cleared", got)
|
|
}
|
|
if got := globalSession.GetAgentSessionID(); got != "global-session-id" {
|
|
t.Fatalf("global session id = %q, want untouched", got)
|
|
}
|
|
}
|
|
|
|
// TestCmdProvider_ClearMultiWorkspaceUsesWorkspaceSessions is a regression test
|
|
// for the bug where cmdProvider "clear" used e.sessions (global) instead of
|
|
// the workspace-resolved sessions, and called providerSaveFunc in workspace mode.
|
|
func TestCmdProvider_ClearMultiWorkspaceUsesWorkspaceSessions(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "plain"}
|
|
globalAgent := &stubProviderAgent{
|
|
providers: []ProviderConfig{{Name: "openai"}},
|
|
active: "openai",
|
|
}
|
|
e := NewEngine("test", globalAgent, []Platform{p}, "", LangEnglish)
|
|
|
|
var savedProvider string
|
|
e.SetProviderSaveFunc(func(name string) error {
|
|
savedProvider = name
|
|
return nil
|
|
})
|
|
|
|
baseDir := t.TempDir()
|
|
bindingPath := filepath.Join(t.TempDir(), "bindings.json")
|
|
e.SetMultiWorkspace(baseDir, bindingPath)
|
|
|
|
wsDir := normalizeWorkspacePath(t.TempDir())
|
|
channelID := "C-provider-clear-ws"
|
|
e.workspaceBindings.Bind("project:test", channelID, "chan", wsDir)
|
|
|
|
ws := e.workspacePool.GetOrCreate(wsDir)
|
|
wsAgent := &stubProviderAgent{
|
|
providers: []ProviderConfig{{Name: "openai"}},
|
|
active: "openai",
|
|
}
|
|
ws.agent = wsAgent
|
|
ws.sessions = NewSessionManager("")
|
|
|
|
msg := &Message{SessionKey: "feishu:" + channelID + ":u1", ReplyCtx: "ctx"}
|
|
|
|
wsSession := ws.sessions.GetOrCreateActive(msg.SessionKey)
|
|
wsSession.SetAgentSessionID("ws-session-id", "test")
|
|
|
|
globalSession := e.sessions.GetOrCreateActive(msg.SessionKey)
|
|
globalSession.SetAgentSessionID("global-session-id", "test")
|
|
|
|
e.cmdProvider(p, msg, []string{"clear"})
|
|
|
|
if got := wsSession.GetAgentSessionID(); got != "" {
|
|
t.Fatalf("workspace session id = %q, want cleared", got)
|
|
}
|
|
if got := globalSession.GetAgentSessionID(); got != "global-session-id" {
|
|
t.Fatalf("global session id = %q, want untouched", got)
|
|
}
|
|
// providerSaveFunc must not be called when operating on a workspace agent.
|
|
if savedProvider != "" {
|
|
t.Fatalf("providerSaveFunc was called with %q in workspace mode, want no call", savedProvider)
|
|
}
|
|
}
|
|
|
|
// TestSwitchProvider_MultiWorkspaceUsesWorkspaceSessions is a regression test
|
|
// for the bug where switchProvider used e.sessions (global) instead of the
|
|
// workspace-resolved sessions, and called providerSaveFunc in workspace mode.
|
|
func TestSwitchProvider_MultiWorkspaceUsesWorkspaceSessions(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "plain"}
|
|
globalAgent := &stubProviderAgent{
|
|
providers: []ProviderConfig{{Name: "openai"}, {Name: "azure"}},
|
|
active: "openai",
|
|
}
|
|
e := NewEngine("test", globalAgent, []Platform{p}, "", LangEnglish)
|
|
|
|
var savedProvider string
|
|
e.SetProviderSaveFunc(func(name string) error {
|
|
savedProvider = name
|
|
return nil
|
|
})
|
|
|
|
baseDir := t.TempDir()
|
|
bindingPath := filepath.Join(t.TempDir(), "bindings.json")
|
|
e.SetMultiWorkspace(baseDir, bindingPath)
|
|
|
|
wsDir := normalizeWorkspacePath(t.TempDir())
|
|
channelID := "C-provider-switch-ws"
|
|
e.workspaceBindings.Bind("project:test", channelID, "chan", wsDir)
|
|
|
|
ws := e.workspacePool.GetOrCreate(wsDir)
|
|
wsAgent := &stubProviderAgent{
|
|
providers: []ProviderConfig{{Name: "openai"}, {Name: "azure"}},
|
|
active: "openai",
|
|
}
|
|
ws.agent = wsAgent
|
|
ws.sessions = NewSessionManager("")
|
|
|
|
msg := &Message{SessionKey: "feishu:" + channelID + ":u1", ReplyCtx: "ctx"}
|
|
|
|
wsSession := ws.sessions.GetOrCreateActive(msg.SessionKey)
|
|
wsSession.SetAgentSessionID("ws-session-id", "test")
|
|
|
|
globalSession := e.sessions.GetOrCreateActive(msg.SessionKey)
|
|
globalSession.SetAgentSessionID("global-session-id", "test")
|
|
|
|
e.cmdProvider(p, msg, []string{"switch", "azure"})
|
|
|
|
if wsAgent.active != "azure" {
|
|
t.Fatalf("workspace agent active provider = %q, want azure", wsAgent.active)
|
|
}
|
|
if got := wsSession.GetAgentSessionID(); got != "" {
|
|
t.Fatalf("workspace session id = %q, want cleared", got)
|
|
}
|
|
if got := globalSession.GetAgentSessionID(); got != "global-session-id" {
|
|
t.Fatalf("global session id = %q, want untouched", got)
|
|
}
|
|
// providerSaveFunc must not be called when operating on a workspace agent.
|
|
if savedProvider != "" {
|
|
t.Fatalf("providerSaveFunc was called with %q in workspace mode, want no call", savedProvider)
|
|
}
|
|
}
|
|
|
|
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")
|
|
}
|
|
if !strings.Contains(p.buttonContent, "Available: `default` / `yolo`") {
|
|
t.Fatalf("button content = %q, want dynamic mode list", p.buttonContent)
|
|
}
|
|
if strings.Contains(p.buttonContent, "`edit`") {
|
|
t.Fatalf("button content = %q, want no hardcoded mode list", p.buttonContent)
|
|
}
|
|
}
|
|
|
|
func TestCmdMode_AppliesLiveModeWithoutReset(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "plain"}
|
|
agent := &stubModelModeAgent{}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
|
|
key := "test:user1"
|
|
live := &stubLiveModeSession{}
|
|
state := &interactiveState{agentSession: live, platform: p, replyCtx: "ctx"}
|
|
e.interactiveMu.Lock()
|
|
e.interactiveStates[key] = state
|
|
e.interactiveMu.Unlock()
|
|
|
|
session := e.sessions.GetOrCreateActive(key)
|
|
session.SetAgentSessionID("existing-session", "stub")
|
|
session.AddHistory("user", "hello")
|
|
|
|
e.cmdMode(p, &Message{SessionKey: key, ReplyCtx: "ctx"}, []string{"yolo"})
|
|
|
|
if len(live.modes) != 1 || live.modes[0] != "yolo" {
|
|
t.Fatalf("live modes = %v, want [yolo]", live.modes)
|
|
}
|
|
if session.GetAgentSessionID() != "existing-session" {
|
|
t.Fatalf("agent session id = %q, want existing-session", session.GetAgentSessionID())
|
|
}
|
|
if len(session.GetHistory(0)) != 1 {
|
|
t.Fatalf("history len = %d, want 1", len(session.GetHistory(0)))
|
|
}
|
|
if len(p.sent) != 1 || !strings.Contains(p.sent[0], "Current session updated immediately.") {
|
|
t.Fatalf("sent = %v, want live mode update reply", p.sent)
|
|
}
|
|
if got := agent.GetMode(); got != "yolo" {
|
|
t.Fatalf("agent mode = %q, want yolo", got)
|
|
}
|
|
}
|
|
|
|
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 TestCmdQuiet_TogglesDisplay(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "test"}
|
|
e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish)
|
|
e.SetDisplayConfig(DisplayCfg{Mode: "full", ThinkingMessages: true, ToolMessages: true, ThinkingMaxLen: 300, ToolMaxLen: 500})
|
|
msg := &Message{SessionKey: "test:user1", ReplyCtx: "ctx"}
|
|
|
|
// 1st /quiet: full → quiet
|
|
e.cmdQuiet(p, msg, nil)
|
|
if e.display.Mode != "quiet" || e.display.ThinkingMessages || e.display.ToolMessages {
|
|
t.Fatalf("after 1st /quiet: Mode=%q, TM=%v, Tool=%v, want quiet/false/false",
|
|
e.display.Mode, e.display.ThinkingMessages, e.display.ToolMessages)
|
|
}
|
|
if len(p.sent) != 1 || !strings.Contains(p.sent[0], "Quiet mode ON") {
|
|
t.Fatalf("sent = %q, want quiet ON message", p.sent)
|
|
}
|
|
|
|
// 2nd /quiet: quiet → compact
|
|
p.sent = nil
|
|
e.cmdQuiet(p, msg, nil)
|
|
if e.display.Mode != "compact" || e.display.ThinkingMessages || e.display.ToolMessages {
|
|
t.Fatalf("after 2nd /quiet: Mode=%q, TM=%v, Tool=%v, want compact/false/false",
|
|
e.display.Mode, e.display.ThinkingMessages, e.display.ToolMessages)
|
|
}
|
|
if len(p.sent) != 1 || !strings.Contains(p.sent[0], "Compact mode") {
|
|
t.Fatalf("sent = %q, want compact mode message", p.sent)
|
|
}
|
|
|
|
// 3rd /quiet: compact → full
|
|
p.sent = nil
|
|
e.cmdQuiet(p, msg, nil)
|
|
if e.display.Mode != "full" || !e.display.ThinkingMessages || !e.display.ToolMessages {
|
|
t.Fatalf("after 3rd /quiet: Mode=%q, TM=%v, Tool=%v, want full/true/true",
|
|
e.display.Mode, e.display.ThinkingMessages, e.display.ToolMessages)
|
|
}
|
|
if len(p.sent) != 1 || !strings.Contains(p.sent[0], "Quiet mode OFF") {
|
|
t.Fatalf("sent = %q, want quiet OFF message", p.sent)
|
|
}
|
|
|
|
// /quiet with explicit argument
|
|
p.sent = nil
|
|
e.cmdQuiet(p, msg, []string{"compact"})
|
|
if e.display.Mode != "compact" {
|
|
t.Fatalf("after /quiet compact: Mode=%q, want compact", e.display.Mode)
|
|
}
|
|
}
|
|
|
|
func TestHandleMessage_ExtraContentPreservedThroughAlias(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "test"}
|
|
agent := &stubAgent{}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
|
|
e.aliasMu.Lock()
|
|
e.aliases["hi"] = "hello world"
|
|
e.aliasMu.Unlock()
|
|
|
|
msg := &Message{
|
|
SessionKey: "test:user1",
|
|
ReplyCtx: "ctx",
|
|
Content: "hi",
|
|
ExtraContent: "> quoted reply context",
|
|
Platform: "test",
|
|
UserID: "user1",
|
|
}
|
|
|
|
e.handleMessage(p, msg)
|
|
|
|
if !strings.Contains(msg.Content, "> quoted reply context") {
|
|
t.Fatalf("ExtraContent lost after alias resolution: msg.Content = %q", msg.Content)
|
|
}
|
|
if !strings.Contains(msg.Content, "hello world") {
|
|
t.Fatalf("alias not resolved: msg.Content = %q", msg.Content)
|
|
}
|
|
}
|
|
|
|
func TestHandleMessage_ExtraContentOnlyIsProcessed(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "test"}
|
|
agent := &stubAgent{}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
|
|
msg := &Message{
|
|
SessionKey: "test:user1",
|
|
ReplyCtx: "ctx",
|
|
Content: "",
|
|
ExtraContent: "> quoted reply context",
|
|
Platform: "test",
|
|
UserID: "user1",
|
|
MessageID: "m-extra-only",
|
|
}
|
|
|
|
e.handleMessage(p, msg)
|
|
|
|
if msg.Content != "> quoted reply context" {
|
|
t.Fatalf("Content = %q, want ExtraContent to become message content", msg.Content)
|
|
}
|
|
}
|
|
|
|
func TestCmdDiff_RejectsDashTarget(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "test"}
|
|
e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish)
|
|
msg := &Message{SessionKey: "test:user1", ReplyCtx: "ctx", UserID: "admin1"}
|
|
e.SetAdminFrom("admin1")
|
|
|
|
e.handleCommand(p, msg, "/diff --output=/tmp/evil")
|
|
|
|
if len(p.sent) == 0 {
|
|
t.Fatal("expected error reply for dash target")
|
|
}
|
|
if !strings.Contains(p.sent[0], "must not start with '-'") {
|
|
t.Fatalf("sent = %q, want rejection of dash target", 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 TestCmdSkills_UsesTelegramSafeNamesOnTelegramPlatform(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "telegram"}
|
|
e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish)
|
|
temp := t.TempDir()
|
|
skillDir := temp + "/telegram-codex-bot"
|
|
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: "telegram:user1", ReplyCtx: "ctx"})
|
|
|
|
if len(p.sent) != 1 {
|
|
t.Fatalf("sent messages = %d, want 1", len(p.sent))
|
|
}
|
|
if !strings.Contains(p.sent[0], "/telegram_codex_bot") {
|
|
t.Fatalf("skills text = %q, want Telegram-safe skill command", p.sent[0])
|
|
}
|
|
if strings.Contains(p.sent[0], "/telegram-codex-bot") {
|
|
t.Fatalf("skills text = %q, should not show raw hyphenated command", p.sent[0])
|
|
}
|
|
}
|
|
|
|
func TestMenuCommandsForPlatform_TelegramOmitsAllSkillsWhenMenuWouldOverflow(t *testing.T) {
|
|
e := NewEngine("test", &stubAgent{}, nil, "", LangEnglish)
|
|
temp := t.TempDir()
|
|
for i := 0; i < 80; i++ {
|
|
name := fmt.Sprintf("skill-%02d", i)
|
|
skillDir := filepath.Join(temp, name)
|
|
if err := os.MkdirAll(skillDir, 0o755); err != nil {
|
|
t.Fatalf("mkdir skill dir: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(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})
|
|
|
|
commands, skillsOmitted := e.menuCommandsForPlatform("telegram")
|
|
|
|
if !skillsOmitted {
|
|
t.Fatalf("expected Telegram menu planner to omit skill commands when command menu overflows")
|
|
}
|
|
for _, cmd := range commands {
|
|
if cmd.IsSkill {
|
|
t.Fatalf("menu commands should omit skills when overflowed, got %+v", cmd)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCmdSkills_TelegramShowsManualInvocationHintWhenSkillsAreOmittedFromMenu(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "telegram"}
|
|
e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish)
|
|
temp := t.TempDir()
|
|
for i := 0; i < 80; i++ {
|
|
name := fmt.Sprintf("skill-%02d", i)
|
|
skillDir := filepath.Join(temp, name)
|
|
if err := os.MkdirAll(skillDir, 0o755); err != nil {
|
|
t.Fatalf("mkdir skill dir: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(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: "telegram:user1", ReplyCtx: "ctx"})
|
|
|
|
if len(p.sent) != 1 {
|
|
t.Fatalf("sent messages = %d, want 1", len(p.sent))
|
|
}
|
|
if !strings.Contains(p.sent[0], "command menu is full") {
|
|
t.Fatalf("skills text = %q, want Telegram overflow hint", 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)
|
|
// Register all agent sessions with the session manager so they pass the
|
|
// owned-session filter (simulates cc-connect having created each session).
|
|
var internalIDs []string
|
|
for i, s := range sessions {
|
|
sess := e.sessions.NewSession("test:user1", "session-"+string(rune('A'+i)))
|
|
sess.SetAgentSessionID(s.ID, "test")
|
|
internalIDs = append(internalIDs, sess.ID)
|
|
}
|
|
// Switch active to the session mapped to sessions[5] (agent-session-F).
|
|
e.sessions.SwitchSession("test:user1", internalIDs[5])
|
|
|
|
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 TestRenderDirCard_HistoryRowsUseSelectActions(t *testing.T) {
|
|
tempDir := t.TempDir()
|
|
dir1 := filepath.Join(tempDir, "dir1")
|
|
dir2 := filepath.Join(tempDir, "dir2")
|
|
for _, d := range []string{dir1, dir2} {
|
|
if err := os.Mkdir(d, 0o755); err != nil {
|
|
t.Fatalf("mkdir: %v", err)
|
|
}
|
|
}
|
|
dataDir := t.TempDir()
|
|
agent := &stubWorkDirAgent{workDir: dir2}
|
|
e := NewEngine("test", agent, []Platform{&stubPlatformEngine{n: "test"}}, dataDir, LangEnglish)
|
|
e.SetDirHistory(NewDirHistory(dataDir))
|
|
e.dirHistory.Add("test", dir1)
|
|
e.dirHistory.Add("test", dir2)
|
|
|
|
card, err := e.renderDirCard("test:user1", 1)
|
|
if err != nil {
|
|
t.Fatalf("renderDirCard: %v", err)
|
|
}
|
|
if got := countCardActionValues(card, "act:/dir select "); got != 2 {
|
|
t.Fatalf("dir select actions = %d, want 2", got)
|
|
}
|
|
}
|
|
|
|
func TestHandleCardNav_DirSelectSwitchesWorkDir(t *testing.T) {
|
|
temp := t.TempDir()
|
|
d1 := filepath.Join(temp, "a")
|
|
d2 := filepath.Join(temp, "b")
|
|
d3 := filepath.Join(temp, "c")
|
|
for _, d := range []string{d1, d2, d3} {
|
|
if err := os.Mkdir(d, 0o755); err != nil {
|
|
t.Fatalf("mkdir: %v", err)
|
|
}
|
|
}
|
|
dataDir := t.TempDir()
|
|
agent := &stubWorkDirAgent{workDir: d3}
|
|
e := NewEngine("test", agent, []Platform{&stubPlatformEngine{n: "test"}}, dataDir, LangEnglish)
|
|
e.SetDirHistory(NewDirHistory(dataDir))
|
|
e.dirHistory.Add("test", d1)
|
|
e.dirHistory.Add("test", d2)
|
|
e.dirHistory.Add("test", d3)
|
|
|
|
sk := "test:user1"
|
|
_ = e.handleCardNav("act:/dir select 2", sk)
|
|
if agent.workDir != d2 {
|
|
t.Fatalf("workDir = %q, want %q", agent.workDir, d2)
|
|
}
|
|
card := e.handleCardNav("nav:/dir 1", sk)
|
|
if card == nil {
|
|
t.Fatal("expected dir card after nav")
|
|
}
|
|
}
|
|
|
|
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, testMultiQuestions(), collected)
|
|
answers, ok := result["answers"].(map[string]any)
|
|
if !ok {
|
|
t.Fatal("expected answers map")
|
|
}
|
|
if answers["Which database?"] != "PostgreSQL" {
|
|
t.Errorf("expected answer[Which database?]=PostgreSQL, got %v", answers["Which database?"])
|
|
}
|
|
if answers["Which framework?"] != "Gin" {
|
|
t.Errorf("expected answer[Which framework?]=Gin, got %v", answers["Which framework?"])
|
|
}
|
|
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["Which database?"] != "SQLite" {
|
|
t.Errorf("expected answer=SQLite, got %v", answers["Which database?"])
|
|
}
|
|
|
|
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["Which database?"] != "PostgreSQL" {
|
|
t.Errorf("expected answer[Which database?]=PostgreSQL, got %v", answers["Which database?"])
|
|
}
|
|
if answers["Which framework?"] != "Echo" {
|
|
t.Errorf("expected answer[Which framework?]=Echo, got %v", answers["Which framework?"])
|
|
}
|
|
|
|
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["Which database?"] != "allow" {
|
|
t.Errorf("expected free text 'allow' as answer, got %v", answers["Which database?"])
|
|
}
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────
|
|
// 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
|
|
model string
|
|
reasoningEffort string
|
|
workDir string
|
|
report *UsageReport
|
|
contextUsage *ContextUsage
|
|
usageErr error
|
|
}
|
|
|
|
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) GetModel() string { return s.model }
|
|
func (s *controllableAgentSession) GetReasoningEffort() string { return s.reasoningEffort }
|
|
func (s *controllableAgentSession) GetWorkDir() string { return s.workDir }
|
|
func (s *controllableAgentSession) GetUsage(_ context.Context) (*UsageReport, error) {
|
|
if s.report == nil && s.usageErr == nil {
|
|
return nil, fmt.Errorf("usage unavailable")
|
|
}
|
|
return s.report, s.usageErr
|
|
}
|
|
func (s *controllableAgentSession) GetContextUsage() *ContextUsage { return s.contextUsage }
|
|
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
|
|
listFn func() ([]AgentSessionInfo, error)
|
|
startSessionFn func(ctx context.Context, sessionID string) (AgentSession, error)
|
|
}
|
|
|
|
func (a *controllableAgent) Name() string { return "controllable" }
|
|
func (a *controllableAgent) StartSession(ctx context.Context, sessionID string) (AgentSession, error) {
|
|
if a.startSessionFn != nil {
|
|
return a.startSessionFn(ctx, sessionID)
|
|
}
|
|
if a.nextSession != nil {
|
|
return a.nextSession, nil
|
|
}
|
|
return newControllableSession("default"), nil
|
|
}
|
|
func (a *controllableAgent) ListSessions(_ context.Context) ([]AgentSessionInfo, error) {
|
|
if a.listFn != nil {
|
|
return a.listFn()
|
|
}
|
|
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")
|
|
}
|
|
}
|
|
|
|
// TestCleanupCAS_ConcurrentUnconditionalCloseOnce verifies that two concurrent
|
|
// unconditional cleanups for the same key only Close() the agent session once.
|
|
func TestCleanupCAS_ConcurrentUnconditionalCloseOnce(t *testing.T) {
|
|
e := newTestEngine()
|
|
key := "test:user1"
|
|
|
|
var closeCount atomic.Int32
|
|
sess := newControllableSession("s1")
|
|
origClose := sess.Close
|
|
_ = origClose
|
|
state := &interactiveState{agentSession: sess}
|
|
|
|
e.interactiveMu.Lock()
|
|
e.interactiveStates[key] = state
|
|
e.interactiveMu.Unlock()
|
|
|
|
var wg sync.WaitGroup
|
|
wg.Add(2)
|
|
for i := 0; i < 2; i++ {
|
|
go func() {
|
|
defer wg.Done()
|
|
e.cleanupInteractiveState(key)
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
|
|
// The session's Close() should have been called at most once because
|
|
// the first cleanup nil's out state.agentSession under the lock.
|
|
select {
|
|
case <-sess.closed:
|
|
closeCount.Add(1)
|
|
default:
|
|
}
|
|
if closeCount.Load() > 1 {
|
|
t.Fatalf("expected at most 1 close, got %d", closeCount.Load())
|
|
}
|
|
|
|
e.interactiveMu.Lock()
|
|
if e.interactiveStates[key] != nil {
|
|
t.Fatal("expected state to be deleted after cleanup")
|
|
}
|
|
e.interactiveMu.Unlock()
|
|
}
|
|
|
|
// 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, e.sessions, 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")
|
|
}
|
|
}
|
|
|
|
// TestSessionClearedAfterNew_RecyclesAliveAgent verifies issue #238: after /new the
|
|
// Session's AgentSessionID is empty but an older Claude process may still be alive;
|
|
// it must be recycled instead of reused (which would keep prior --resume context).
|
|
func TestSessionClearedAfterNew_RecyclesAliveAgent(t *testing.T) {
|
|
newSess := newControllableSession("fresh-id")
|
|
agent := &controllableAgent{nextSession: newSess}
|
|
p := &stubPlatformEngine{n: "test"}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
key := "test:user1"
|
|
oldSess := newControllableSession("prior-claude-session")
|
|
e.interactiveMu.Lock()
|
|
e.interactiveStates[key] = &interactiveState{
|
|
agentSession: oldSess,
|
|
platform: p,
|
|
replyCtx: "ctx",
|
|
}
|
|
e.interactiveMu.Unlock()
|
|
|
|
session := &Session{AgentSessionID: ""}
|
|
|
|
state := e.getOrCreateInteractiveStateWith(key, p, "ctx", session, e.sessions, nil, "")
|
|
if state.agentSession == oldSess {
|
|
t.Fatal("expected stale agent to be recycled when AgentSessionID was cleared")
|
|
}
|
|
if state.agentSession != newSess {
|
|
t.Fatal("expected new agent session from StartSession")
|
|
}
|
|
select {
|
|
case <-oldSess.closed:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("old agent session was not closed after /new-style clear")
|
|
}
|
|
}
|
|
|
|
// 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, e.sessions, 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, e.sessions, nil, "")
|
|
|
|
got := session.GetAgentSessionID()
|
|
|
|
if got != "agent-uuid-123" {
|
|
t.Fatalf("AgentSessionID = %q, want %q — immediate writeback not working", got, "agent-uuid-123")
|
|
}
|
|
}
|
|
|
|
// TestSessionIDWriteback_MapsSessionName verifies that when startOrResumeSession
|
|
// sets the AgentSessionID, it also maps the session's pending name via
|
|
// SetSessionName so that /list displays the custom name from /new.
|
|
func TestSessionIDWriteback_MapsSessionName(t *testing.T) {
|
|
sess := newControllableSession("agent-uuid-456")
|
|
agent := &controllableAgent{nextSession: sess}
|
|
p := &stubPlatformEngine{n: "test"}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
|
|
key := "test:user1"
|
|
session := e.sessions.NewSession(key, "我的自定义会话")
|
|
|
|
e.getOrCreateInteractiveStateWith(key, p, "ctx", session, e.sessions, nil, "")
|
|
|
|
got := e.sessions.GetSessionName("agent-uuid-456")
|
|
if got != "我的自定义会话" {
|
|
t.Fatalf("GetSessionName = %q, want %q — name not mapped during startOrResumeSession", got, "我的自定义会话")
|
|
}
|
|
}
|
|
|
|
// 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, e.sessions, nil, "")
|
|
|
|
got := session.GetAgentSessionID()
|
|
|
|
if got != "existing-uuid" {
|
|
t.Fatalf("AgentSessionID = %q, want %q — writeback should not overwrite", got, "existing-uuid")
|
|
}
|
|
}
|
|
|
|
// TestCmdStop_ClearsAgentSessionID verifies that /stop clears the stale
|
|
// AgentSessionID so the next message starts a fresh agent instead of trying
|
|
// to resume the killed session (issue #830).
|
|
func TestCmdStop_ClearsAgentSessionID(t *testing.T) {
|
|
sess := newControllableSession("agent-1")
|
|
agent := &controllableAgent{nextSession: sess}
|
|
p := &stubPlatformEngine{n: "test"}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
|
|
key := "test:user1"
|
|
|
|
// Seed a live interactive state.
|
|
e.interactiveMu.Lock()
|
|
e.interactiveStates[key] = &interactiveState{
|
|
agentSession: sess,
|
|
platform: p,
|
|
replyCtx: "ctx",
|
|
}
|
|
e.interactiveMu.Unlock()
|
|
|
|
// Set the Session's AgentSessionID to match (simulates a normal turn).
|
|
active := e.sessions.GetOrCreateActive(key)
|
|
active.SetAgentSessionID("agent-1", "controllable")
|
|
e.sessions.Save()
|
|
|
|
// Simulate /stop: it uses interactiveKeyForSessionKey internally.
|
|
msg := &Message{SessionKey: key, ReplyCtx: "ctx"}
|
|
e.cmdStop(p, msg)
|
|
|
|
// After /stop, AgentSessionID must be cleared.
|
|
got := active.GetAgentSessionID()
|
|
if got != "" {
|
|
t.Fatalf("AgentSessionID = %q, want empty after /stop", got)
|
|
}
|
|
}
|
|
|
|
// TestResumeFallback_ClearsStaleSessionID verifies that when agent.StartSession
|
|
// fails with a stale session ID and falls back to a fresh session, the stale
|
|
// AgentSessionID is cleared so CompareAndSetAgentSessionID can write the new ID
|
|
// (issue #830, matching the relay fallback at engine.go:12640).
|
|
func TestResumeFallback_ClearsStaleSessionID(t *testing.T) {
|
|
freshSess := newControllableSession("fresh-id")
|
|
agent := &controllableAgent{
|
|
startSessionFn: func(_ context.Context, sessionID string) (AgentSession, error) {
|
|
if sessionID != "" {
|
|
return nil, errors.New("session not found")
|
|
}
|
|
return freshSess, nil
|
|
},
|
|
}
|
|
p := &stubPlatformEngine{n: "test"}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
|
|
key := "test:user1"
|
|
|
|
// Session has a stale AgentSessionID from a previously killed agent.
|
|
session := &Session{AgentSessionID: "stale-id", AgentType: "controllable"}
|
|
|
|
state := e.getOrCreateInteractiveStateWith(key, p, "ctx", session, e.sessions, nil, "")
|
|
|
|
// The new agent session should be the fresh one.
|
|
if state.agentSession != freshSess {
|
|
t.Fatal("expected fresh agent session from fallback")
|
|
}
|
|
|
|
// The stale ID should have been replaced with the new ID.
|
|
got := session.GetAgentSessionID()
|
|
if got != "fresh-id" {
|
|
t.Fatalf("AgentSessionID = %q, want %q — stale ID should be replaced", got, "fresh-id")
|
|
}
|
|
}
|
|
|
|
// 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, e.sessions, 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_RefreshesLegacyInstructions(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
memFile := filepath.Join(tmpDir, "AGENTS.md")
|
|
legacy := "\n" + ccConnectInstructionMarker + "\nlegacy instructions\n"
|
|
if err := os.WriteFile(memFile, []byte(legacy), 0o644); err != nil {
|
|
t.Fatalf("write legacy mem file: %v", err)
|
|
}
|
|
|
|
p := &stubPlatformEngine{n: "plain"}
|
|
agent := &stubMemoryAgent{memFile: memFile}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
|
|
result, _, err := e.setupMemoryFile()
|
|
if result != setupOK {
|
|
t.Fatalf("result = %d, want setupOK; err = %v", result, err)
|
|
}
|
|
|
|
content, _ := os.ReadFile(memFile)
|
|
if strings.Contains(string(content), "legacy instructions") {
|
|
t.Fatalf("legacy instructions should be refreshed, got %q", string(content))
|
|
}
|
|
if !strings.Contains(string(content), "cc-connect send --image") {
|
|
t.Fatalf("expected refreshed attachment instructions, got %q", string(content))
|
|
}
|
|
}
|
|
|
|
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], "attachment send-back") {
|
|
t.Errorf("reply = %q, want unified cc-connect setup 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")
|
|
}
|
|
}
|
|
|
|
// --- session resilience tests ---
|
|
|
|
// stubStartSessionAgent records StartSession calls and can fail on specific session IDs.
|
|
type stubStartSessionAgent struct {
|
|
calls []string
|
|
failIDs map[string]error // session IDs that should fail
|
|
mu sync.Mutex
|
|
}
|
|
|
|
func (a *stubStartSessionAgent) Name() string { return "stub" }
|
|
func (a *stubStartSessionAgent) StartSession(_ context.Context, sessionID string) (AgentSession, error) {
|
|
a.mu.Lock()
|
|
a.calls = append(a.calls, sessionID)
|
|
a.mu.Unlock()
|
|
|
|
if err, ok := a.failIDs[sessionID]; ok {
|
|
return nil, err
|
|
}
|
|
return &stubAgentSession{}, nil
|
|
}
|
|
func (a *stubStartSessionAgent) ListSessions(_ context.Context) ([]AgentSessionInfo, error) {
|
|
return nil, nil
|
|
}
|
|
func (a *stubStartSessionAgent) Stop() error { return nil }
|
|
|
|
func TestResumeFailureFallbackToFreshSession(t *testing.T) {
|
|
agent := &stubStartSessionAgent{
|
|
failIDs: map[string]error{
|
|
"old-session-id": fmt.Errorf("Prompt is too long"),
|
|
},
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
e := &Engine{
|
|
agent: agent,
|
|
sessions: NewSessionManager(""),
|
|
ctx: ctx,
|
|
i18n: NewI18n("en"),
|
|
interactiveStates: make(map[string]*interactiveState),
|
|
display: DisplayCfg{},
|
|
}
|
|
|
|
session := e.sessions.GetOrCreateActive("test:user1")
|
|
session.SetAgentSessionID("old-session-id", "stub")
|
|
|
|
p := &stubPlatformEngine{n: "test"}
|
|
state := e.getOrCreateInteractiveStateWith("test:user1", p, "ctx", session, e.sessions, nil, "")
|
|
|
|
if state.agentSession == nil {
|
|
t.Fatal("expected agentSession to be non-nil after fallback")
|
|
}
|
|
|
|
agent.mu.Lock()
|
|
calls := append([]string{}, agent.calls...)
|
|
agent.mu.Unlock()
|
|
|
|
if len(calls) != 2 {
|
|
t.Fatalf("expected 2 StartSession calls, got %d: %v", len(calls), calls)
|
|
}
|
|
if calls[0] != "old-session-id" {
|
|
t.Fatalf("first StartSession call = %q, want saved session id", calls[0])
|
|
}
|
|
if calls[1] != "" {
|
|
t.Fatalf("second StartSession call = %q, want empty string", calls[1])
|
|
}
|
|
}
|
|
|
|
func TestFreshSessionWithoutSavedSessionIDStartsFresh(t *testing.T) {
|
|
agent := &stubStartSessionAgent{}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
e := &Engine{
|
|
agent: agent,
|
|
sessions: NewSessionManager(""),
|
|
ctx: ctx,
|
|
i18n: NewI18n("en"),
|
|
interactiveStates: make(map[string]*interactiveState),
|
|
display: DisplayCfg{},
|
|
}
|
|
session := e.sessions.GetOrCreateActive("test:user2")
|
|
|
|
p := &stubPlatformEngine{n: "test"}
|
|
state := e.getOrCreateInteractiveStateWith("test:user2", p, "ctx", session, e.sessions, nil, "")
|
|
|
|
if state.agentSession == nil {
|
|
t.Fatal("expected agentSession to be non-nil")
|
|
}
|
|
|
|
agent.mu.Lock()
|
|
calls := append([]string{}, agent.calls...)
|
|
agent.mu.Unlock()
|
|
|
|
if len(calls) != 1 {
|
|
t.Fatalf("expected 1 StartSession call, got %d: %v", len(calls), calls)
|
|
}
|
|
if calls[0] != "" {
|
|
t.Fatalf("StartSession call = %q, want empty string (fresh session)", calls[0])
|
|
}
|
|
}
|
|
|
|
func TestWorkspaceReconnectWithSavedSessionIDUsesExactResume(t *testing.T) {
|
|
agent := &stubStartSessionAgent{}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
e := &Engine{
|
|
agent: agent,
|
|
sessions: NewSessionManager(""),
|
|
ctx: ctx,
|
|
i18n: NewI18n("en"),
|
|
interactiveStates: make(map[string]*interactiveState),
|
|
display: DisplayCfg{},
|
|
}
|
|
|
|
session := e.sessions.GetOrCreateActive("test:user3")
|
|
session.SetAgentSessionID("saved-session-id", "stub")
|
|
|
|
p := &stubPlatformEngine{n: "test"}
|
|
state := e.getOrCreateInteractiveStateWith("test:user3", p, "ctx", session, e.sessions, nil, "")
|
|
|
|
if state.agentSession == nil {
|
|
t.Fatal("expected agentSession to be non-nil")
|
|
}
|
|
|
|
agent.mu.Lock()
|
|
calls := append([]string{}, agent.calls...)
|
|
agent.mu.Unlock()
|
|
|
|
if len(calls) != 1 {
|
|
t.Fatalf("expected 1 StartSession call, got %d: %v", len(calls), calls)
|
|
}
|
|
if calls[0] != "saved-session-id" {
|
|
t.Fatalf("StartSession call = %q, want saved session id", calls[0])
|
|
}
|
|
}
|
|
|
|
func TestParseSelfReportedCtx(t *testing.T) {
|
|
tests := []struct {
|
|
input string
|
|
want int
|
|
}{
|
|
{"here is my response\n[ctx: ~42%]", 42},
|
|
{"no context here", 0},
|
|
{"response\n[ctx: ~100%]", 100},
|
|
{"response\n[ctx: ~5%]", 5},
|
|
{"", 0},
|
|
}
|
|
for _, tt := range tests {
|
|
got := parseSelfReportedCtx(tt.input)
|
|
if got != tt.want {
|
|
t.Errorf("parseSelfReportedCtx(%q) = %d, want %d", tt.input, got, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
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:
|
|
}
|
|
}
|
|
|
|
// --- Message queuing tests ---
|
|
|
|
// queuingAgentSession records Send calls and emits events via a controllable channel.
|
|
type queuingAgentSession struct {
|
|
controllableAgentSession
|
|
sendCalls []string
|
|
sendMu sync.Mutex
|
|
}
|
|
|
|
func newQueuingSession(id string) *queuingAgentSession {
|
|
return &queuingAgentSession{
|
|
controllableAgentSession: controllableAgentSession{
|
|
sessionID: id,
|
|
alive: true,
|
|
events: make(chan Event, 16),
|
|
closed: make(chan struct{}),
|
|
},
|
|
}
|
|
}
|
|
|
|
func (s *queuingAgentSession) Send(prompt string, _ []ImageAttachment, _ []FileAttachment) error {
|
|
s.sendMu.Lock()
|
|
s.sendCalls = append(s.sendCalls, prompt)
|
|
s.sendMu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
// blockingSendAgentSession blocks in Send until unblock is closed, mimicking agents
|
|
// whose Send does not return until the prompt turn completes (e.g. ACP session/prompt).
|
|
type blockingSendAgentSession struct {
|
|
controllableAgentSession
|
|
sendStarted chan struct{} // sent to when Send begins waiting on unblock
|
|
unblock chan struct{} // close to let Send return
|
|
}
|
|
|
|
func newBlockingSendSession(id string) *blockingSendAgentSession {
|
|
return &blockingSendAgentSession{
|
|
controllableAgentSession: controllableAgentSession{
|
|
sessionID: id,
|
|
alive: true,
|
|
events: make(chan Event, 16),
|
|
closed: make(chan struct{}),
|
|
},
|
|
sendStarted: make(chan struct{}, 1),
|
|
unblock: make(chan struct{}),
|
|
}
|
|
}
|
|
|
|
func (s *blockingSendAgentSession) Send(_ string, _ []ImageAttachment, _ []FileAttachment) error {
|
|
s.sendStarted <- struct{}{}
|
|
<-s.unblock
|
|
return nil
|
|
}
|
|
|
|
// blockingCloseAgentSession blocks in Close until releaseClose is closed.
|
|
// It is used to verify that /stop detaches the session and stops forwarding
|
|
// events before the underlying agent process has fully exited.
|
|
type blockingCloseAgentSession struct {
|
|
controllableAgentSession
|
|
closeStarted chan struct{}
|
|
releaseClose chan struct{}
|
|
}
|
|
|
|
func newBlockingCloseSession(id string) *blockingCloseAgentSession {
|
|
return &blockingCloseAgentSession{
|
|
controllableAgentSession: controllableAgentSession{
|
|
sessionID: id,
|
|
alive: true,
|
|
events: make(chan Event, 16),
|
|
closed: make(chan struct{}),
|
|
},
|
|
closeStarted: make(chan struct{}, 1),
|
|
releaseClose: make(chan struct{}),
|
|
}
|
|
}
|
|
|
|
func (s *blockingCloseAgentSession) Close() error {
|
|
s.alive = false
|
|
select {
|
|
case s.closeStarted <- struct{}{}:
|
|
default:
|
|
}
|
|
<-s.releaseClose
|
|
close(s.events)
|
|
select {
|
|
case <-s.closed:
|
|
default:
|
|
close(s.closed)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// permSignalInlinePlatform wraps stubInlineButtonPlatform and signals when a
|
|
// SendWithButtons call includes perm:allow, so tests do not read buttonRows
|
|
// from another goroutine (race with the engine under -race).
|
|
type permSignalInlinePlatform struct {
|
|
stubInlineButtonPlatform
|
|
permAllowSent chan<- struct{}
|
|
}
|
|
|
|
func (p *permSignalInlinePlatform) SendWithButtons(ctx context.Context, replyCtx any, content string, buttons [][]ButtonOption) error {
|
|
if err := p.stubInlineButtonPlatform.SendWithButtons(ctx, replyCtx, content, buttons); err != nil {
|
|
return err
|
|
}
|
|
for _, row := range buttons {
|
|
for _, b := range row {
|
|
if b.Data == "perm:allow" {
|
|
select {
|
|
case p.permAllowSent <- struct{}{}:
|
|
default:
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Regression: permission events must be handled while Send is still blocked.
|
|
// If the engine called Send synchronously before reading Events(), this would deadlock
|
|
// and never call sendPermissionPrompt.
|
|
func TestProcessInteractiveEvents_PermissionWhileSendBlocked(t *testing.T) {
|
|
permAllowSent := make(chan struct{}, 1)
|
|
p := &permSignalInlinePlatform{
|
|
stubInlineButtonPlatform: stubInlineButtonPlatform{stubPlatformEngine: stubPlatformEngine{n: "telegram"}},
|
|
permAllowSent: permAllowSent,
|
|
}
|
|
sess := newBlockingSendSession("blk-perm")
|
|
e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish)
|
|
|
|
key := "test:user1"
|
|
session := e.sessions.GetOrCreateActive(key)
|
|
state := &interactiveState{
|
|
agentSession: sess,
|
|
platform: p,
|
|
replyCtx: "ctx",
|
|
}
|
|
e.interactiveMu.Lock()
|
|
e.interactiveStates[key] = state
|
|
e.interactiveMu.Unlock()
|
|
|
|
sendDone := make(chan error, 1)
|
|
go func() {
|
|
sendDone <- sess.Send("prompt", nil, nil)
|
|
}()
|
|
|
|
done := make(chan struct{})
|
|
go func() {
|
|
e.processInteractiveEvents(state, session, e.sessions, key, "m1", time.Now(), nil, sendDone, nil)
|
|
close(done)
|
|
}()
|
|
|
|
select {
|
|
case <-sess.sendStarted:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("Send did not reach blocking wait")
|
|
}
|
|
|
|
sess.events <- Event{
|
|
Type: EventPermissionRequest,
|
|
RequestID: "req-blocked-send",
|
|
ToolName: "write_file",
|
|
ToolInput: "/tmp/x",
|
|
ToolInputRaw: map[string]any{"path": "/tmp/x"},
|
|
}
|
|
|
|
select {
|
|
case <-permAllowSent:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("permission inline buttons not sent while Send blocked")
|
|
}
|
|
|
|
if !e.handlePendingPermission(p, &Message{SessionKey: key, ReplyCtx: "ctx"}, "allow", "") {
|
|
t.Fatal("expected handlePendingPermission to resolve pending request")
|
|
}
|
|
close(sess.unblock)
|
|
|
|
sess.events <- Event{Type: EventResult, Content: "ok", Done: true}
|
|
|
|
select {
|
|
case <-done:
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("processInteractiveEvents did not complete")
|
|
}
|
|
}
|
|
|
|
func TestReapIdleWorkspaces_SkipsWorkspaceWithActiveTurn(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "test"}
|
|
sess := newBlockingSendSession("busy-turn")
|
|
e := NewEngine("test", &controllableAgent{nextSession: sess}, []Platform{p}, "", LangEnglish)
|
|
e.workspacePool = newWorkspacePool(50 * time.Millisecond)
|
|
|
|
workspaceDir := normalizeWorkspacePath(t.TempDir())
|
|
sessionKey := "test:user1"
|
|
session := e.sessions.GetOrCreateActive(sessionKey)
|
|
if !session.TryLock() {
|
|
t.Fatal("expected session lock")
|
|
}
|
|
|
|
done := make(chan struct{})
|
|
go func() {
|
|
e.processInteractiveMessageWith(p, &Message{
|
|
SessionKey: sessionKey,
|
|
UserID: "user1",
|
|
Content: "long running task",
|
|
ReplyCtx: "ctx",
|
|
}, session, e.agent, e.sessions, sessionKey, workspaceDir, sessionKey)
|
|
close(done)
|
|
}()
|
|
|
|
select {
|
|
case <-sess.sendStarted:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("Send did not reach blocking wait")
|
|
}
|
|
|
|
time.Sleep(100 * time.Millisecond)
|
|
e.reapIdleWorkspaces()
|
|
|
|
if !sess.Alive() {
|
|
t.Fatal("idle reaper closed a session with an active turn")
|
|
}
|
|
e.interactiveMu.Lock()
|
|
_, exists := e.interactiveStates[sessionKey]
|
|
e.interactiveMu.Unlock()
|
|
if !exists {
|
|
t.Fatal("idle reaper removed interactive state for an active turn")
|
|
}
|
|
|
|
close(sess.unblock)
|
|
sess.events <- Event{Type: EventResult, Content: "done", Done: true}
|
|
|
|
select {
|
|
case <-done:
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("processInteractiveMessageWith did not complete")
|
|
}
|
|
}
|
|
|
|
func TestReapIdleWorkspaces_SkipsWorkspaceWaitingForPermission(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "test"}
|
|
sess := newBlockingSendSession("perm-wait")
|
|
e := NewEngine("test", &controllableAgent{nextSession: sess}, []Platform{p}, "", LangEnglish)
|
|
e.workspacePool = newWorkspacePool(50 * time.Millisecond)
|
|
|
|
workspaceDir := normalizeWorkspacePath(t.TempDir())
|
|
sessionKey := "test:user2"
|
|
session := e.sessions.GetOrCreateActive(sessionKey)
|
|
if !session.TryLock() {
|
|
t.Fatal("expected session lock")
|
|
}
|
|
|
|
done := make(chan struct{})
|
|
go func() {
|
|
e.processInteractiveMessageWith(p, &Message{
|
|
SessionKey: sessionKey,
|
|
UserID: "user2",
|
|
Content: "needs approval",
|
|
ReplyCtx: "ctx",
|
|
}, session, e.agent, e.sessions, sessionKey, workspaceDir, sessionKey)
|
|
close(done)
|
|
}()
|
|
|
|
select {
|
|
case <-sess.sendStarted:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("Send did not reach blocking wait")
|
|
}
|
|
|
|
sess.events <- Event{
|
|
Type: EventPermissionRequest,
|
|
RequestID: "req-1",
|
|
ToolName: "write_file",
|
|
ToolInput: "/tmp/x",
|
|
ToolInputRaw: map[string]any{"path": "/tmp/x"},
|
|
}
|
|
|
|
var pending *pendingPermission
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
e.interactiveMu.Lock()
|
|
state := e.interactiveStates[sessionKey]
|
|
e.interactiveMu.Unlock()
|
|
if state != nil {
|
|
state.mu.Lock()
|
|
pending = state.pending
|
|
state.mu.Unlock()
|
|
if pending != nil {
|
|
break
|
|
}
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
if pending == nil {
|
|
t.Fatal("expected pending permission while turn is waiting")
|
|
}
|
|
|
|
time.Sleep(100 * time.Millisecond)
|
|
e.reapIdleWorkspaces()
|
|
|
|
if !sess.Alive() {
|
|
t.Fatal("idle reaper closed a session waiting for permission")
|
|
}
|
|
e.interactiveMu.Lock()
|
|
_, exists := e.interactiveStates[sessionKey]
|
|
e.interactiveMu.Unlock()
|
|
if !exists {
|
|
t.Fatal("idle reaper removed interactive state while waiting for permission")
|
|
}
|
|
|
|
if !e.handlePendingPermission(p, &Message{
|
|
SessionKey: sessionKey,
|
|
UserID: "user2",
|
|
Content: "allow",
|
|
ReplyCtx: "ctx",
|
|
}, "allow", "") {
|
|
t.Fatal("expected pending permission to be handled")
|
|
}
|
|
close(sess.unblock)
|
|
sess.events <- Event{Type: EventResult, Content: "done", Done: true}
|
|
|
|
select {
|
|
case <-done:
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("processInteractiveMessageWith did not complete after permission")
|
|
}
|
|
}
|
|
|
|
func TestQueueMessageForBusySession_FIFODequeue(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "test"}
|
|
sess := newQueuingSession("qs1")
|
|
agent := &controllableAgent{nextSession: sess}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
|
|
key := "test:user1"
|
|
|
|
// Set up an interactive state as if a turn is in progress.
|
|
state := &interactiveState{
|
|
agentSession: sess,
|
|
platform: p,
|
|
replyCtx: "ctx1",
|
|
}
|
|
e.interactiveMu.Lock()
|
|
e.interactiveStates[key] = state
|
|
e.interactiveMu.Unlock()
|
|
|
|
// Queue two messages while the session is "busy".
|
|
msg1 := &Message{SessionKey: key, Content: "msg1", ReplyCtx: "ctx-msg1"}
|
|
msg2 := &Message{SessionKey: key, Content: "msg2", ReplyCtx: "ctx-msg2"}
|
|
|
|
ok1 := e.queueMessageForBusySession(p, msg1, key)
|
|
ok2 := e.queueMessageForBusySession(p, msg2, key)
|
|
|
|
if !ok1 || !ok2 {
|
|
t.Fatal("expected both messages to be queued successfully")
|
|
}
|
|
|
|
// Since deferred-send, messages are NOT sent to agent stdin at queue
|
|
// time — only metadata is stored. Verify no Send calls occurred.
|
|
sess.sendMu.Lock()
|
|
if len(sess.sendCalls) != 0 {
|
|
t.Fatalf("sendCalls = %v, want [] (deferred send)", sess.sendCalls)
|
|
}
|
|
sess.sendMu.Unlock()
|
|
|
|
// Verify pending messages queue has correct FIFO order.
|
|
state.mu.Lock()
|
|
if len(state.pendingMessages) != 2 {
|
|
t.Fatalf("pendingMessages len = %d, want 2", len(state.pendingMessages))
|
|
}
|
|
if state.pendingMessages[0].content != "msg1" || state.pendingMessages[1].content != "msg2" {
|
|
t.Fatalf("pendingMessages = [%s, %s], want [msg1, msg2]",
|
|
state.pendingMessages[0].content, state.pendingMessages[1].content)
|
|
}
|
|
state.mu.Unlock()
|
|
}
|
|
|
|
func TestProcessInteractiveEvents_DrainsQueuedMessages(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "test"}
|
|
sess := newQueuingSession("qs2")
|
|
agent := &controllableAgent{nextSession: sess}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
|
|
key := "test:user1"
|
|
session := e.sessions.GetOrCreateActive(key)
|
|
|
|
// Pre-populate the interactive state with one queued message.
|
|
state := &interactiveState{
|
|
agentSession: sess,
|
|
platform: p,
|
|
replyCtx: "ctx-turn1",
|
|
pendingMessages: []queuedMessage{
|
|
{platform: p, replyCtx: "ctx-turn2", content: "queued-msg"},
|
|
},
|
|
}
|
|
e.interactiveMu.Lock()
|
|
e.interactiveStates[key] = state
|
|
e.interactiveMu.Unlock()
|
|
|
|
// Simulate the agent completing turn 1 then turn 2.
|
|
// Turn 2 events are pushed only after Send() is called for the queued
|
|
// message, matching real-world timing where the agent doesn't produce
|
|
// events for a turn until it receives the prompt on stdin.
|
|
go func() {
|
|
// Turn 1 result
|
|
sess.events <- Event{Type: EventText, Content: "response1"}
|
|
sess.events <- Event{Type: EventResult, Content: "response1", Done: true}
|
|
// Wait for the queued message's Send() call before pushing turn 2 events.
|
|
sess.sendMu.Lock()
|
|
for len(sess.sendCalls) == 0 {
|
|
sess.sendMu.Unlock()
|
|
time.Sleep(5 * time.Millisecond)
|
|
sess.sendMu.Lock()
|
|
}
|
|
sess.sendMu.Unlock()
|
|
// Turn 2 result (for the queued message)
|
|
sess.events <- Event{Type: EventText, Content: "response2"}
|
|
sess.events <- Event{Type: EventResult, Content: "response2", Done: true}
|
|
}()
|
|
|
|
session.AddHistory("user", "initial-msg")
|
|
|
|
sendDone := make(chan error, 1)
|
|
sendDone <- nil
|
|
|
|
// processInteractiveEvents should handle both turns.
|
|
done := make(chan struct{})
|
|
go func() {
|
|
e.processInteractiveEvents(state, session, e.sessions, key, "msg1", time.Now(), nil, sendDone, nil)
|
|
close(done)
|
|
}()
|
|
|
|
select {
|
|
case <-done:
|
|
// ok
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("processInteractiveEvents did not complete in time")
|
|
}
|
|
|
|
// Verify queue is empty after processing.
|
|
state.mu.Lock()
|
|
remaining := len(state.pendingMessages)
|
|
state.mu.Unlock()
|
|
if remaining != 0 {
|
|
t.Fatalf("pendingMessages after processing = %d, want 0", remaining)
|
|
}
|
|
|
|
// Verify both turns recorded in session history.
|
|
history := session.GetHistory(100)
|
|
var assistantMsgs []string
|
|
for _, h := range history {
|
|
if h.Role == "assistant" {
|
|
assistantMsgs = append(assistantMsgs, h.Content)
|
|
}
|
|
}
|
|
if len(assistantMsgs) != 2 {
|
|
t.Fatalf("assistant history entries = %d, want 2", len(assistantMsgs))
|
|
}
|
|
|
|
// Verify the queued message was also added to history.
|
|
var userMsgs []string
|
|
for _, h := range history {
|
|
if h.Role == "user" {
|
|
userMsgs = append(userMsgs, h.Content)
|
|
}
|
|
}
|
|
if len(userMsgs) < 2 {
|
|
t.Fatalf("user history entries = %d, want >= 2", len(userMsgs))
|
|
}
|
|
}
|
|
|
|
// replyCtxRecordingPlatform records (replyCtx, content) for each Send/Reply
|
|
// so tests can assert which trigger context was used for which message.
|
|
type replyCtxRecordingPlatform struct {
|
|
stubPlatformEngine
|
|
mu sync.Mutex
|
|
events []replyCtxCall
|
|
}
|
|
|
|
type replyCtxCall struct {
|
|
op string
|
|
replyCtx any
|
|
content string
|
|
}
|
|
|
|
func (p *replyCtxRecordingPlatform) Reply(_ context.Context, replyCtx any, content string) error {
|
|
p.mu.Lock()
|
|
p.events = append(p.events, replyCtxCall{op: "reply", replyCtx: replyCtx, content: content})
|
|
p.mu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
func (p *replyCtxRecordingPlatform) Send(_ context.Context, replyCtx any, content string) error {
|
|
p.mu.Lock()
|
|
p.events = append(p.events, replyCtxCall{op: "send", replyCtx: replyCtx, content: content})
|
|
p.mu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
func (p *replyCtxRecordingPlatform) recordedEvents() []replyCtxCall {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
out := make([]replyCtxCall, len(p.events))
|
|
copy(out, p.events)
|
|
return out
|
|
}
|
|
|
|
// TestProcessInteractiveEvents_QueuedMessageUsesItsOwnReplyCtx verifies that
|
|
// when a queued message is dequeued mid-loop, subsequent Send/Reply calls use
|
|
// the queued message's reply context (not the original turn's). Without this,
|
|
// platforms that derive the parent message_id from replyCtx (e.g. feishu Reply
|
|
// API for the reply quote) would quote the wrong message.
|
|
func TestProcessInteractiveEvents_QueuedMessageUsesItsOwnReplyCtx(t *testing.T) {
|
|
p := &replyCtxRecordingPlatform{stubPlatformEngine: stubPlatformEngine{n: "test"}}
|
|
sess := newQueuingSession("qs-replyctx")
|
|
agent := &controllableAgent{nextSession: sess}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
|
|
key := "test:user1"
|
|
session := e.sessions.GetOrCreateActive(key)
|
|
|
|
state := &interactiveState{
|
|
agentSession: sess,
|
|
platform: p,
|
|
replyCtx: "ctx-turn1",
|
|
pendingMessages: []queuedMessage{
|
|
{platform: p, replyCtx: "ctx-turn2", content: "queued-msg"},
|
|
},
|
|
}
|
|
e.interactiveMu.Lock()
|
|
e.interactiveStates[key] = state
|
|
e.interactiveMu.Unlock()
|
|
|
|
go func() {
|
|
// Turn 1 result — final reply should use ctx-turn1.
|
|
sess.events <- Event{Type: EventResult, Content: "response1", Done: true}
|
|
// Wait for the queued message's Send() before pushing turn 2.
|
|
sess.sendMu.Lock()
|
|
for len(sess.sendCalls) == 0 {
|
|
sess.sendMu.Unlock()
|
|
time.Sleep(5 * time.Millisecond)
|
|
sess.sendMu.Lock()
|
|
}
|
|
sess.sendMu.Unlock()
|
|
// Turn 2 result — final reply should use ctx-turn2.
|
|
sess.events <- Event{Type: EventResult, Content: "response2", Done: true}
|
|
}()
|
|
|
|
session.AddHistory("user", "initial-msg")
|
|
sendDone := make(chan error, 1)
|
|
sendDone <- nil
|
|
|
|
done := make(chan struct{})
|
|
go func() {
|
|
e.processInteractiveEvents(state, session, e.sessions, key, "msg1", time.Now(), nil, sendDone, "ctx-turn1")
|
|
close(done)
|
|
}()
|
|
|
|
select {
|
|
case <-done:
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("processInteractiveEvents did not complete in time")
|
|
}
|
|
|
|
// Map each recorded send to the responsible turn by content match.
|
|
for _, ev := range p.recordedEvents() {
|
|
switch ev.content {
|
|
case "response1":
|
|
if ev.replyCtx != "ctx-turn1" {
|
|
t.Errorf("turn1 reply used replyCtx=%v, want ctx-turn1", ev.replyCtx)
|
|
}
|
|
case "response2":
|
|
if ev.replyCtx != "ctx-turn2" {
|
|
t.Errorf("turn2 reply used replyCtx=%v, want ctx-turn2 (regression: msg2's reply quoted msg1)", ev.replyCtx)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestDrainOrphanedQueue_UsesWorkspaceSessionManager verifies that
|
|
// drainOrphanedQueue saves session history through the passed sessions
|
|
// manager (workspace-specific) rather than e.sessions (global).
|
|
func TestDrainOrphanedQueue_UsesWorkspaceSessionManager(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "test"}
|
|
sess := newQueuingSession("qs-orphan")
|
|
agent := &controllableAgent{nextSession: sess}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
|
|
// Create a separate "workspace" session manager that drainOrphanedQueue should use.
|
|
wsSessionsPath := filepath.Join(t.TempDir(), "ws_sessions.json")
|
|
wsSessions := NewSessionManager(wsSessionsPath)
|
|
|
|
key := "ws1:test:user1"
|
|
session := wsSessions.GetOrCreateActive("test:user1")
|
|
if !session.TryLock() {
|
|
t.Fatal("expected TryLock to succeed")
|
|
}
|
|
|
|
// Set up interactive state with a queued message.
|
|
state := &interactiveState{
|
|
agentSession: sess,
|
|
platform: p,
|
|
replyCtx: "ctx",
|
|
pendingMessages: []queuedMessage{
|
|
{platform: p, replyCtx: "ctx-q", content: "queued-orphan"},
|
|
},
|
|
}
|
|
e.interactiveMu.Lock()
|
|
e.interactiveStates[key] = state
|
|
e.interactiveMu.Unlock()
|
|
|
|
// Push events so the drain completes.
|
|
go func() {
|
|
sess.sendMu.Lock()
|
|
for len(sess.sendCalls) == 0 {
|
|
sess.sendMu.Unlock()
|
|
time.Sleep(5 * time.Millisecond)
|
|
sess.sendMu.Lock()
|
|
}
|
|
sess.sendMu.Unlock()
|
|
sess.events <- Event{Type: EventResult, Content: "orphan-response", Done: true}
|
|
}()
|
|
|
|
done := make(chan struct{})
|
|
go func() {
|
|
e.drainOrphanedQueue(session, wsSessions, key, agent, "")
|
|
close(done)
|
|
}()
|
|
|
|
select {
|
|
case <-done:
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("drainOrphanedQueue did not complete in time")
|
|
}
|
|
|
|
// The assistant response should be saved in the workspace session manager,
|
|
// NOT in e.sessions (global).
|
|
wsHistory := wsSessions.GetOrCreateActive("test:user1").GetHistory(0)
|
|
var wsAssistant []string
|
|
for _, h := range wsHistory {
|
|
if h.Role == "assistant" {
|
|
wsAssistant = append(wsAssistant, h.Content)
|
|
}
|
|
}
|
|
if len(wsAssistant) == 0 {
|
|
t.Fatal("expected assistant history in workspace session manager, got none")
|
|
}
|
|
|
|
// Verify e.sessions (global) does NOT have this history.
|
|
globalSession := e.sessions.GetOrCreateActive("test:user1")
|
|
globalHistory := globalSession.GetHistory(0)
|
|
for _, h := range globalHistory {
|
|
if h.Role == "assistant" && h.Content == "orphan-response" {
|
|
t.Fatal("orphan response was saved to global e.sessions instead of workspace sessions")
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── executeCardAction interactiveKey tests ───────────────────
|
|
|
|
func TestHandleCardNav_ModelSwitchesAndRefreshesCard(t *testing.T) {
|
|
p := &stubCardPlatform{stubPlatformEngine: stubPlatformEngine{n: "feishu"}}
|
|
agent := &stubModelModeAgent{model: "old"}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
|
|
sessionKey := "feishu:channel1:user1"
|
|
card := e.handleCardNav("act:/model new-model", sessionKey)
|
|
if card == nil {
|
|
t.Fatal("expected immediate result card")
|
|
}
|
|
if text := card.RenderText(); !strings.Contains(text, "Model switched to `new-model`.") {
|
|
t.Fatalf("result card = %q", text)
|
|
}
|
|
if agent.model != "new-model" {
|
|
t.Fatalf("model = %q, want new-model", agent.model)
|
|
}
|
|
if refreshed := p.getRefreshedCards(); len(refreshed) != 0 {
|
|
t.Fatalf("unexpected async refreshed cards: %d", len(refreshed))
|
|
}
|
|
}
|
|
|
|
func TestHandleCardNav_ModelUsesWorkspaceContext(t *testing.T) {
|
|
p := &stubCardPlatform{stubPlatformEngine: stubPlatformEngine{n: "feishu"}}
|
|
globalAgent := &stubModelModeAgent{model: "global-old"}
|
|
e := NewEngine("test", globalAgent, []Platform{p}, "", LangEnglish)
|
|
|
|
baseDir := t.TempDir()
|
|
bindingPath := filepath.Join(t.TempDir(), "bindings.json")
|
|
e.SetMultiWorkspace(baseDir, bindingPath)
|
|
|
|
wsDir := normalizeWorkspacePath(t.TempDir())
|
|
channelID := "channel1"
|
|
sessionKey := "feishu:" + channelID + ":user1"
|
|
e.workspaceBindings.Bind("project:test", channelID, "chan", wsDir)
|
|
|
|
ws := e.workspacePool.GetOrCreate(wsDir)
|
|
wsAgent := &stubModelModeAgent{model: "workspace-old"}
|
|
ws.agent = wsAgent
|
|
ws.sessions = NewSessionManager("")
|
|
|
|
interactiveKey := e.interactiveKeyForSessionKey(sessionKey)
|
|
e.interactiveMu.Lock()
|
|
e.interactiveStates[interactiveKey] = &interactiveState{}
|
|
e.interactiveMu.Unlock()
|
|
|
|
globalSession := e.sessions.GetOrCreateActive(sessionKey)
|
|
globalSession.SetAgentSessionID("global-session", "test")
|
|
wsSession := ws.sessions.GetOrCreateActive(sessionKey)
|
|
wsSession.SetAgentSessionID("workspace-session", "test")
|
|
|
|
card := e.handleCardNav("act:/model switch 1", sessionKey)
|
|
if card == nil {
|
|
t.Fatal("expected immediate result card")
|
|
}
|
|
if text := card.RenderText(); !strings.Contains(text, "gpt-4.1") {
|
|
t.Fatalf("result card = %q, want switched workspace model", text)
|
|
}
|
|
|
|
if wsAgent.model != "gpt-4.1" {
|
|
t.Fatalf("workspace agent model = %q, want gpt-4.1", wsAgent.model)
|
|
}
|
|
if globalAgent.model != "global-old" {
|
|
t.Fatalf("global agent model = %q, want unchanged", globalAgent.model)
|
|
}
|
|
if got := ws.sessions.GetOrCreateActive(sessionKey).AgentSessionID; got != "workspace-session" {
|
|
t.Fatalf("workspace session id = %q, want preserved", got)
|
|
}
|
|
if got := e.sessions.GetOrCreateActive(sessionKey).AgentSessionID; got != "global-session" {
|
|
t.Fatalf("global session id = %q, want untouched", got)
|
|
}
|
|
if refreshed := p.getRefreshedCards(); len(refreshed) != 0 {
|
|
t.Fatalf("unexpected async refreshed cards: %d", len(refreshed))
|
|
}
|
|
}
|
|
|
|
func TestHandleCardNav_ModelSwitchFailureRefreshesCard(t *testing.T) {
|
|
p := &stubCardPlatform{stubPlatformEngine: stubPlatformEngine{n: "feishu"}}
|
|
agent := &stubModelModeAgent{model: "old"}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
e.modelSaveFunc = func(string) error { return errors.New("save failed") }
|
|
|
|
sessionKey := "feishu:channel1:user1"
|
|
card := e.handleCardNav("act:/model broken-model", sessionKey)
|
|
if card == nil {
|
|
t.Fatal("expected immediate failure card")
|
|
}
|
|
if text := card.RenderText(); !strings.Contains(text, "Failed to switch model: save model: save failed") {
|
|
t.Fatalf("failure card = %q", text)
|
|
}
|
|
if refreshed := p.getRefreshedCards(); len(refreshed) != 0 {
|
|
t.Fatalf("unexpected async refreshed cards: %d", len(refreshed))
|
|
}
|
|
}
|
|
|
|
func TestHandleCardNav_ModelResultBackReturnsModelCard(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "plain"}
|
|
agent := &stubModelModeAgent{model: "gpt-5.4"}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
|
|
sessionKey := "feishu:channel1:user1"
|
|
result := e.renderModelSwitchResultCard("gpt-5.4", nil)
|
|
buttons := result.CollectButtons()
|
|
if len(buttons) != 1 || len(buttons[0]) != 1 {
|
|
t.Fatalf("result buttons = %#v, want single back button", buttons)
|
|
}
|
|
if buttons[0][0].Data != "nav:/model" {
|
|
t.Fatalf("back button value = %q, want nav:/model", buttons[0][0].Data)
|
|
}
|
|
|
|
card := e.handleCardNav(buttons[0][0].Data, sessionKey)
|
|
if card == nil {
|
|
t.Fatal("expected /model card")
|
|
}
|
|
text := card.RenderText()
|
|
if !strings.Contains(text, "Current model: gpt-5.4") {
|
|
t.Fatalf("model card text = %q", text)
|
|
}
|
|
}
|
|
|
|
func TestHandleCardNav_ModelCardUsesWorkspaceAgent(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "plain"}
|
|
globalAgent := &stubModelModeAgent{model: "global-model"}
|
|
e := NewEngine("test", globalAgent, []Platform{p}, "", LangEnglish)
|
|
|
|
baseDir := t.TempDir()
|
|
bindingPath := filepath.Join(t.TempDir(), "bindings.json")
|
|
e.SetMultiWorkspace(baseDir, bindingPath)
|
|
|
|
wsDir := normalizeWorkspacePath(t.TempDir())
|
|
channelID := "channel-nav"
|
|
sessionKey := "feishu:" + channelID + ":user1"
|
|
e.workspaceBindings.Bind("project:test", channelID, "chan", wsDir)
|
|
|
|
ws := e.workspacePool.GetOrCreate(wsDir)
|
|
ws.agent = &stubModelModeAgent{model: "workspace-model"}
|
|
ws.sessions = NewSessionManager("")
|
|
|
|
card := e.handleCardNav("nav:/model", sessionKey)
|
|
if card == nil {
|
|
t.Fatal("expected /model card")
|
|
}
|
|
text := card.RenderText()
|
|
if !strings.Contains(text, "workspace-model") {
|
|
t.Fatalf("model card text = %q, want workspace model", text)
|
|
}
|
|
if strings.Contains(text, "global-model") {
|
|
t.Fatalf("model card text = %q, should not use global model", text)
|
|
}
|
|
}
|
|
|
|
func TestExecuteCardAction_ModeCleansUpWithInteractiveKey(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "plain"}
|
|
agent := &stubModelModeAgent{mode: "default"}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
|
|
sessionKey := "feishu:channel1:user1"
|
|
|
|
e.interactiveMu.Lock()
|
|
e.interactiveStates[sessionKey] = &interactiveState{}
|
|
e.interactiveMu.Unlock()
|
|
|
|
e.executeCardAction("/mode", "yolo", sessionKey)
|
|
|
|
e.interactiveMu.Lock()
|
|
_, exists := e.interactiveStates[sessionKey]
|
|
e.interactiveMu.Unlock()
|
|
if exists {
|
|
t.Error("expected interactive state to be cleaned up after /mode")
|
|
}
|
|
}
|
|
|
|
// ===========================================================================
|
|
// P0 Beta release tests
|
|
// ===========================================================================
|
|
|
|
// --- 1. Message queue overflow ---
|
|
|
|
func TestQueueMessageOverflow_DropsOldestAndReturnsfalse(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "test"}
|
|
sess := newQueuingSession("qs-overflow")
|
|
agent := &controllableAgent{nextSession: sess}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
|
|
key := "test:overflow-user"
|
|
|
|
state := &interactiveState{
|
|
agentSession: sess,
|
|
platform: p,
|
|
replyCtx: "ctx",
|
|
}
|
|
e.interactiveMu.Lock()
|
|
e.interactiveStates[key] = state
|
|
e.interactiveMu.Unlock()
|
|
|
|
// Fill the queue to defaultMaxQueuedMessages (5).
|
|
for i := 0; i < defaultMaxQueuedMessages; i++ {
|
|
msg := &Message{SessionKey: key, Content: fmt.Sprintf("msg-%d", i), ReplyCtx: fmt.Sprintf("ctx-%d", i)}
|
|
ok := e.queueMessageForBusySession(p, msg, key)
|
|
if !ok {
|
|
t.Fatalf("expected msg-%d to be queued, got false", i)
|
|
}
|
|
}
|
|
|
|
state.mu.Lock()
|
|
if len(state.pendingMessages) != defaultMaxQueuedMessages {
|
|
t.Fatalf("queue depth = %d, want %d", len(state.pendingMessages), defaultMaxQueuedMessages)
|
|
}
|
|
state.mu.Unlock()
|
|
|
|
// The 6th message should be handled (returns true) but not queued — MsgQueueFull sent.
|
|
overflow := &Message{SessionKey: key, Content: "msg-overflow", ReplyCtx: "ctx-overflow"}
|
|
ok := e.queueMessageForBusySession(p, overflow, key)
|
|
if !ok {
|
|
t.Fatal("expected 6th message to be handled (queue-full reply), got false")
|
|
}
|
|
|
|
// Queue should still have exactly defaultMaxQueuedMessages items (the original 5).
|
|
state.mu.Lock()
|
|
if len(state.pendingMessages) != defaultMaxQueuedMessages {
|
|
t.Fatalf("queue depth after overflow = %d, want %d", len(state.pendingMessages), defaultMaxQueuedMessages)
|
|
}
|
|
// First message should still be msg-0 (FIFO preserved, no silent drop).
|
|
if state.pendingMessages[0].content != "msg-0" {
|
|
t.Fatalf("first queued = %q, want msg-0", state.pendingMessages[0].content)
|
|
}
|
|
state.mu.Unlock()
|
|
|
|
// Platform should have received MsgMessageQueued for 5 accepted + MsgQueueFull for the overflow.
|
|
sent := p.getSent()
|
|
if len(sent) != defaultMaxQueuedMessages+1 {
|
|
t.Fatalf("platform replies = %d, want %d (queued + queue-full)", len(sent), defaultMaxQueuedMessages+1)
|
|
}
|
|
}
|
|
|
|
func TestQueueMessage_NoState_ReturnsFalse(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "test"}
|
|
e := newTestEngine()
|
|
|
|
msg := &Message{SessionKey: "nonexistent:key", Content: "hello"}
|
|
ok := e.queueMessageForBusySession(p, msg, "nonexistent:key")
|
|
if ok {
|
|
t.Fatal("expected false when no interactive state exists")
|
|
}
|
|
}
|
|
|
|
func TestQueueMessage_DeadSession_ReturnsFalse(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "test"}
|
|
sess := newQueuingSession("dead")
|
|
sess.alive = false
|
|
agent := &controllableAgent{nextSession: sess}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
|
|
key := "test:dead-session"
|
|
state := &interactiveState{
|
|
agentSession: sess,
|
|
platform: p,
|
|
}
|
|
e.interactiveMu.Lock()
|
|
e.interactiveStates[key] = state
|
|
e.interactiveMu.Unlock()
|
|
|
|
msg := &Message{SessionKey: key, Content: "hello"}
|
|
ok := e.queueMessageForBusySession(p, msg, key)
|
|
if ok {
|
|
t.Fatal("expected false for dead session")
|
|
}
|
|
}
|
|
|
|
// TestQueueMessage_NilAgentSession_DuringStartup verifies that messages can be
|
|
// queued when the interactiveState exists but agentSession is nil (session is
|
|
// still starting up). This is the fix for issue #565.
|
|
func TestQueueMessage_NilAgentSession_DuringStartup(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "test"}
|
|
e := newTestEngine()
|
|
|
|
key := "test:starting-session"
|
|
// Simulate the placeholder state created by ensureInteractiveStateForQueueing
|
|
state := &interactiveState{
|
|
platform: p,
|
|
replyCtx: "ctx",
|
|
// agentSession is nil — session is starting up
|
|
}
|
|
e.interactiveMu.Lock()
|
|
e.interactiveStates[key] = state
|
|
e.interactiveMu.Unlock()
|
|
|
|
msg := &Message{SessionKey: key, Content: "queued during startup", ReplyCtx: "ctx-startup"}
|
|
ok := e.queueMessageForBusySession(p, msg, key)
|
|
if !ok {
|
|
t.Fatal("expected true: messages should be queueable during session startup")
|
|
}
|
|
|
|
state.mu.Lock()
|
|
if len(state.pendingMessages) != 1 {
|
|
t.Fatalf("pendingMessages len = %d, want 1", len(state.pendingMessages))
|
|
}
|
|
if state.pendingMessages[0].content != "queued during startup" {
|
|
t.Fatalf("queued content = %q, want %q", state.pendingMessages[0].content, "queued during startup")
|
|
}
|
|
state.mu.Unlock()
|
|
}
|
|
|
|
// --- 2. /compress flow ---
|
|
|
|
type stubCompressorAgent struct {
|
|
stubAgent
|
|
cmd string
|
|
}
|
|
|
|
func (a *stubCompressorAgent) CompressCommand() string { return a.cmd }
|
|
|
|
func TestCmdCompress_NoCompressor_RepliesNotSupported(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "test"}
|
|
e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish)
|
|
|
|
msg := &Message{SessionKey: "test:user1", Content: "/compress", ReplyCtx: "ctx"}
|
|
e.cmdCompress(p, msg)
|
|
|
|
sent := p.getSent()
|
|
if len(sent) == 0 {
|
|
t.Fatal("expected a reply")
|
|
}
|
|
if !strings.Contains(sent[0], e.i18n.T(MsgCompressNotSupported)) {
|
|
t.Fatalf("expected MsgCompressNotSupported, got %q", sent[0])
|
|
}
|
|
}
|
|
|
|
func TestCmdCompress_NoSession_RepliesNoSession(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "test"}
|
|
agent := &stubCompressorAgent{cmd: "/compact"}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
|
|
msg := &Message{SessionKey: "test:user1", Content: "/compress", ReplyCtx: "ctx"}
|
|
e.cmdCompress(p, msg)
|
|
|
|
sent := p.getSent()
|
|
if len(sent) == 0 {
|
|
t.Fatal("expected a reply")
|
|
}
|
|
if !strings.Contains(sent[0], e.i18n.T(MsgCompressNoSession)) {
|
|
t.Fatalf("expected MsgCompressNoSession, got %q", sent[0])
|
|
}
|
|
}
|
|
|
|
func TestAutoCompress_TriggerAfterResult(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "test"}
|
|
sess := newQueuingSession("auto-compress")
|
|
agent := &stubCompressorAgent{cmd: "/compact"}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
e.SetAutoCompressConfig(true, 4, 0) // tiny threshold
|
|
|
|
key := "test:user1"
|
|
state := &interactiveState{
|
|
agentSession: sess,
|
|
platform: p,
|
|
replyCtx: "ctx",
|
|
}
|
|
e.interactiveMu.Lock()
|
|
e.interactiveStates[key] = state
|
|
e.interactiveMu.Unlock()
|
|
|
|
// Seed history so estimate crosses threshold after assistant response.
|
|
session := e.sessions.GetOrCreateActive(key)
|
|
session.AddHistory("user", "hello world")
|
|
|
|
// Simulate a full turn.
|
|
go e.processInteractiveEvents(state, session, e.sessions, key, "msg1", time.Now(), func() {}, nil, nil)
|
|
|
|
sess.events <- Event{Type: EventResult, Content: "response", Done: true}
|
|
|
|
// The auto-compress should send /compact to the agent session.
|
|
deadline := time.After(2 * time.Second)
|
|
for {
|
|
sess.sendMu.Lock()
|
|
n := len(sess.sendCalls)
|
|
sess.sendMu.Unlock()
|
|
if n > 0 {
|
|
break
|
|
}
|
|
select {
|
|
case <-deadline:
|
|
t.Fatal("timed out waiting for auto-compress send")
|
|
default:
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
}
|
|
|
|
sess.sendMu.Lock()
|
|
last := sess.sendCalls[len(sess.sendCalls)-1]
|
|
sess.sendMu.Unlock()
|
|
if last != "/compact" {
|
|
t.Fatalf("expected /compact auto-compress, got %q", last)
|
|
}
|
|
}
|
|
|
|
func TestCmdCompress_SessionBusy_RepliesPreviousProcessing(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "test"}
|
|
sess := newQueuingSession("compress-busy")
|
|
agent := &stubCompressorAgent{cmd: "/compact"}
|
|
agent.stubAgent = stubAgent{}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
|
|
key := "test:user1"
|
|
state := &interactiveState{
|
|
agentSession: sess,
|
|
platform: p,
|
|
}
|
|
e.interactiveMu.Lock()
|
|
e.interactiveStates[key] = state
|
|
e.interactiveMu.Unlock()
|
|
|
|
// Lock the session to simulate busy.
|
|
session := e.sessions.GetOrCreateActive(key)
|
|
if !session.TryLock() {
|
|
t.Fatal("expected TryLock to succeed")
|
|
}
|
|
|
|
msg := &Message{SessionKey: key, Content: "/compress", ReplyCtx: "ctx"}
|
|
e.cmdCompress(p, msg)
|
|
|
|
sent := p.getSent()
|
|
found := false
|
|
for _, s := range sent {
|
|
if strings.Contains(s, e.i18n.T(MsgPreviousProcessing)) {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatalf("expected MsgPreviousProcessing reply, got %v", sent)
|
|
}
|
|
session.Unlock()
|
|
}
|
|
|
|
func TestCmdCompress_Success_SendsCompressDone(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "test"}
|
|
sess := newQueuingSession("compress-ok")
|
|
agent := &stubCompressorAgent{cmd: "/compact"}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
|
|
key := "test:user1"
|
|
state := &interactiveState{
|
|
agentSession: sess,
|
|
platform: p,
|
|
replyCtx: "ctx",
|
|
}
|
|
e.interactiveMu.Lock()
|
|
e.interactiveStates[key] = state
|
|
e.interactiveMu.Unlock()
|
|
|
|
msg := &Message{SessionKey: key, Content: "/compress", ReplyCtx: "ctx"}
|
|
e.cmdCompress(p, msg)
|
|
|
|
// Wait for Send to be called (happens after drainEvents), then inject the result event.
|
|
deadline := time.After(3 * time.Second)
|
|
for {
|
|
sess.sendMu.Lock()
|
|
n := len(sess.sendCalls)
|
|
sess.sendMu.Unlock()
|
|
if n > 0 {
|
|
break
|
|
}
|
|
select {
|
|
case <-deadline:
|
|
t.Fatal("timed out waiting for compress Send call")
|
|
default:
|
|
time.Sleep(5 * time.Millisecond)
|
|
}
|
|
}
|
|
|
|
sess.events <- Event{Type: EventResult, Content: "", Done: true}
|
|
|
|
for {
|
|
sent := p.getSent()
|
|
foundDone := false
|
|
for _, s := range sent {
|
|
if strings.Contains(s, e.i18n.T(MsgCompressDone)) {
|
|
foundDone = true
|
|
}
|
|
}
|
|
if foundDone {
|
|
break
|
|
}
|
|
select {
|
|
case <-deadline:
|
|
t.Fatalf("timed out waiting for MsgCompressDone, sent = %v", p.getSent())
|
|
default:
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCmdCompress_WithText_SendsResult(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "test"}
|
|
sess := newQueuingSession("compress-text")
|
|
agent := &stubCompressorAgent{cmd: "/compact"}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
|
|
key := "test:user1"
|
|
state := &interactiveState{
|
|
agentSession: sess,
|
|
platform: p,
|
|
replyCtx: "ctx",
|
|
}
|
|
e.interactiveMu.Lock()
|
|
e.interactiveStates[key] = state
|
|
e.interactiveMu.Unlock()
|
|
|
|
msg := &Message{SessionKey: key, Content: "/compress", ReplyCtx: "ctx"}
|
|
e.cmdCompress(p, msg)
|
|
|
|
// Wait for Send to be called (happens after drainEvents).
|
|
deadline := time.After(3 * time.Second)
|
|
for {
|
|
sess.sendMu.Lock()
|
|
n := len(sess.sendCalls)
|
|
sess.sendMu.Unlock()
|
|
if n > 0 {
|
|
break
|
|
}
|
|
select {
|
|
case <-deadline:
|
|
t.Fatal("timed out waiting for compress Send call")
|
|
default:
|
|
time.Sleep(5 * time.Millisecond)
|
|
}
|
|
}
|
|
|
|
sess.events <- Event{Type: EventText, Content: "Compressed to 50%"}
|
|
sess.events <- Event{Type: EventResult, Content: "Compression complete", Done: true}
|
|
|
|
for {
|
|
sent := p.getSent()
|
|
foundResult := false
|
|
for _, s := range sent {
|
|
if strings.Contains(s, "Compression complete") {
|
|
foundResult = true
|
|
}
|
|
}
|
|
if foundResult {
|
|
break
|
|
}
|
|
select {
|
|
case <-deadline:
|
|
t.Fatalf("timed out waiting for compress result, sent = %v", p.getSent())
|
|
default:
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCmdCompress_DrainsQueueAfterSuccess(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "test"}
|
|
sess := newQueuingSession("compress-drain")
|
|
agent := &stubCompressorAgent{cmd: "/compact"}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
|
|
key := "test:user1"
|
|
state := &interactiveState{
|
|
agentSession: sess,
|
|
platform: p,
|
|
replyCtx: "ctx",
|
|
pendingMessages: []queuedMessage{
|
|
{platform: p, replyCtx: "ctx-q1", content: "queued-after-compress"},
|
|
},
|
|
}
|
|
e.interactiveMu.Lock()
|
|
e.interactiveStates[key] = state
|
|
e.interactiveMu.Unlock()
|
|
|
|
msg := &Message{SessionKey: key, Content: "/compress", ReplyCtx: "ctx"}
|
|
e.cmdCompress(p, msg)
|
|
|
|
// Complete compress.
|
|
sess.events <- Event{Type: EventResult, Content: "", Done: true}
|
|
|
|
// Wait for Send to be called (drain of queued message).
|
|
deadline := time.After(3 * time.Second)
|
|
for {
|
|
sess.sendMu.Lock()
|
|
n := len(sess.sendCalls)
|
|
sess.sendMu.Unlock()
|
|
if n > 0 {
|
|
break
|
|
}
|
|
select {
|
|
case <-deadline:
|
|
t.Fatal("timed out waiting for queued message to be sent after compress")
|
|
default:
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
}
|
|
|
|
// Provide events for the drained turn so processInteractiveEvents completes.
|
|
sess.events <- Event{Type: EventResult, Content: "drain-done", Done: true}
|
|
|
|
// Verify the queued message was actually sent.
|
|
time.Sleep(100 * time.Millisecond)
|
|
sess.sendMu.Lock()
|
|
calls := make([]string, len(sess.sendCalls))
|
|
copy(calls, sess.sendCalls)
|
|
sess.sendMu.Unlock()
|
|
|
|
if len(calls) == 0 {
|
|
t.Fatal("expected at least one Send call for the queued message")
|
|
}
|
|
found := false
|
|
for _, c := range calls {
|
|
if strings.Contains(c, "queued-after-compress") {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatalf("queued message not found in send calls: %v", calls)
|
|
}
|
|
}
|
|
|
|
// --- cmdPs ---
|
|
|
|
func TestCmdPs_EmptyArgs_RepliesUsage(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "test"}
|
|
e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish)
|
|
|
|
msg := &Message{SessionKey: "test:user1", Content: "/ps", ReplyCtx: "ctx"}
|
|
e.cmdPs(p, msg, nil)
|
|
|
|
sent := p.getSent()
|
|
if len(sent) == 0 || !strings.Contains(sent[0], e.i18n.T(MsgPsEmpty)) {
|
|
t.Fatalf("expected MsgPsEmpty, got %v", sent)
|
|
}
|
|
}
|
|
|
|
func TestCmdPs_NoAgentSession_RepliesNoSession(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "test"}
|
|
e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish)
|
|
|
|
msg := &Message{SessionKey: "test:user1", Content: "/ps hello", ReplyCtx: "ctx"}
|
|
e.cmdPs(p, msg, []string{"hello"})
|
|
|
|
sent := p.getSent()
|
|
if len(sent) == 0 || !strings.Contains(sent[0], e.i18n.T(MsgPsNoSession)) {
|
|
t.Fatalf("expected MsgPsNoSession, got %v", sent)
|
|
}
|
|
}
|
|
|
|
func TestCmdPs_IdleSession_RepliesNoSession(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "test"}
|
|
sess := newQueuingSession("ps-idle")
|
|
e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish)
|
|
|
|
key := "test:user1"
|
|
state := &interactiveState{agentSession: sess, platform: p}
|
|
e.interactiveMu.Lock()
|
|
e.interactiveStates[key] = state
|
|
e.interactiveMu.Unlock()
|
|
|
|
// Session is alive but idle (not locked by an in-flight turn).
|
|
msg := &Message{SessionKey: key, Content: "/ps hello", ReplyCtx: "ctx"}
|
|
e.cmdPs(p, msg, []string{"hello"})
|
|
|
|
sent := p.getSent()
|
|
if len(sent) == 0 || !strings.Contains(sent[0], e.i18n.T(MsgPsNoSession)) {
|
|
t.Fatalf("expected MsgPsNoSession on idle session, got %v", sent)
|
|
}
|
|
|
|
sess.sendMu.Lock()
|
|
n := len(sess.sendCalls)
|
|
sess.sendMu.Unlock()
|
|
if n != 0 {
|
|
t.Fatalf("expected no Send on idle session, got %d call(s)", n)
|
|
}
|
|
}
|
|
|
|
func TestCmdPs_BusySession_InjectsToAgent(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "test"}
|
|
sess := newQueuingSession("ps-busy")
|
|
e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish)
|
|
|
|
key := "test:user1"
|
|
state := &interactiveState{agentSession: sess, platform: p}
|
|
e.interactiveMu.Lock()
|
|
e.interactiveStates[key] = state
|
|
e.interactiveMu.Unlock()
|
|
|
|
// Simulate a turn in flight.
|
|
session := e.sessions.GetOrCreateActive(key)
|
|
if !session.TryLock() {
|
|
t.Fatal("expected TryLock to succeed")
|
|
}
|
|
defer session.Unlock()
|
|
|
|
msg := &Message{SessionKey: key, Content: "/ps add unit tests", ReplyCtx: "ctx"}
|
|
e.cmdPs(p, msg, []string{"add", "unit", "tests"})
|
|
|
|
sess.sendMu.Lock()
|
|
calls := append([]string(nil), sess.sendCalls...)
|
|
sess.sendMu.Unlock()
|
|
if len(calls) != 1 || calls[0] != "add unit tests" {
|
|
t.Fatalf("expected Send(\"add unit tests\"), got %v", calls)
|
|
}
|
|
|
|
sent := p.getSent()
|
|
found := false
|
|
for _, s := range sent {
|
|
if strings.Contains(s, e.i18n.T(MsgPsSent)) {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatalf("expected MsgPsSent reply, got %v", sent)
|
|
}
|
|
}
|
|
|
|
// --- 3. executeCardAction routing ---
|
|
|
|
func TestExecuteCardAction_CronEnable(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "test"}
|
|
e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish)
|
|
|
|
store, err := NewCronStore(t.TempDir())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_ = store.Add(&CronJob{ID: "job1", CronExpr: "0 9 * * *", Enabled: false})
|
|
scheduler := NewCronScheduler(store)
|
|
e.cronScheduler = scheduler
|
|
|
|
e.executeCardAction("/cron", "enable job1", "test:user1")
|
|
|
|
job := store.Get("job1")
|
|
if job == nil {
|
|
t.Fatal("job not found")
|
|
}
|
|
if !job.Enabled {
|
|
t.Error("expected job to be enabled after card action")
|
|
}
|
|
}
|
|
|
|
func TestExecuteCardAction_CronDisable(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "test"}
|
|
e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish)
|
|
|
|
store, err := NewCronStore(t.TempDir())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_ = store.Add(&CronJob{ID: "job1", CronExpr: "0 9 * * *", Enabled: true})
|
|
scheduler := NewCronScheduler(store)
|
|
e.cronScheduler = scheduler
|
|
|
|
e.executeCardAction("/cron", "disable job1", "test:user1")
|
|
|
|
job := store.Get("job1")
|
|
if job == nil {
|
|
t.Fatal("job not found")
|
|
}
|
|
if job.Enabled {
|
|
t.Error("expected job to be disabled after card action")
|
|
}
|
|
}
|
|
|
|
func TestExecuteCardAction_CronDelete(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "test"}
|
|
e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish)
|
|
|
|
store, err := NewCronStore(t.TempDir())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_ = store.Add(&CronJob{ID: "del-job", CronExpr: "0 9 * * *", Enabled: true})
|
|
scheduler := NewCronScheduler(store)
|
|
e.cronScheduler = scheduler
|
|
|
|
e.executeCardAction("/cron", "delete del-job", "test:user1")
|
|
|
|
job := store.Get("del-job")
|
|
if job != nil {
|
|
t.Error("expected job to be deleted after card action")
|
|
}
|
|
}
|
|
|
|
func TestExecuteCardAction_CronMuteUnmute(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "test"}
|
|
e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish)
|
|
|
|
store, err := NewCronStore(t.TempDir())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_ = store.Add(&CronJob{ID: "mute-job", CronExpr: "0 9 * * *", Enabled: true})
|
|
scheduler := NewCronScheduler(store)
|
|
e.cronScheduler = scheduler
|
|
|
|
e.executeCardAction("/cron", "mute mute-job", "test:user1")
|
|
job := store.Get("mute-job")
|
|
if job == nil || !job.Mute {
|
|
t.Error("expected job to be muted")
|
|
}
|
|
|
|
e.executeCardAction("/cron", "unmute mute-job", "test:user1")
|
|
job = store.Get("mute-job")
|
|
if job == nil || job.Mute {
|
|
t.Error("expected job to be unmuted")
|
|
}
|
|
}
|
|
|
|
func TestExecuteCardAction_CronNoScheduler_NoPanic(t *testing.T) {
|
|
e := newTestEngine()
|
|
// cronScheduler is nil — should not panic.
|
|
e.executeCardAction("/cron", "enable job1", "test:user1")
|
|
}
|
|
|
|
func TestExecuteCardAction_CronBadArgs_NoPanic(t *testing.T) {
|
|
store, _ := NewCronStore(t.TempDir())
|
|
scheduler := NewCronScheduler(store)
|
|
e := newTestEngine()
|
|
e.cronScheduler = scheduler
|
|
|
|
// Missing ID.
|
|
e.executeCardAction("/cron", "enable", "test:user1")
|
|
// Empty args.
|
|
e.executeCardAction("/cron", "", "test:user1")
|
|
}
|
|
|
|
func TestExecuteCardAction_StopCleansUp(t *testing.T) {
|
|
sess := newControllableSession("stop-test")
|
|
e := newTestEngine()
|
|
key := "test:user1"
|
|
|
|
e.interactiveMu.Lock()
|
|
e.interactiveStates[key] = &interactiveState{agentSession: sess}
|
|
e.interactiveMu.Unlock()
|
|
|
|
e.executeCardAction("/stop", "", key)
|
|
|
|
e.interactiveMu.Lock()
|
|
_, exists := e.interactiveStates[key]
|
|
e.interactiveMu.Unlock()
|
|
|
|
if exists {
|
|
t.Error("expected interactive state to be removed after /stop")
|
|
}
|
|
}
|
|
|
|
func TestExecuteCardAction_StopClearsInteractiveState(t *testing.T) {
|
|
sess := newControllableSession("stop-quiet")
|
|
e := newTestEngine()
|
|
key := "test:user1"
|
|
|
|
e.interactiveMu.Lock()
|
|
e.interactiveStates[key] = &interactiveState{agentSession: sess}
|
|
e.interactiveMu.Unlock()
|
|
|
|
e.executeCardAction("/stop", "", key)
|
|
|
|
e.interactiveMu.Lock()
|
|
state, exists := e.interactiveStates[key]
|
|
e.interactiveMu.Unlock()
|
|
|
|
if exists || state != nil {
|
|
t.Fatal("expected interactive state to be removed after /stop")
|
|
}
|
|
}
|
|
|
|
func TestCmdStop_ReturnsWhileCloseBlockedAndStopsEventLoop(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "test"}
|
|
sess := newBlockingCloseSession("stop-blocked")
|
|
e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish)
|
|
key := "test:user1"
|
|
session := e.sessions.GetOrCreateActive(key)
|
|
|
|
state := &interactiveState{
|
|
agentSession: sess,
|
|
platform: p,
|
|
replyCtx: "ctx",
|
|
}
|
|
e.interactiveMu.Lock()
|
|
e.interactiveStates[key] = state
|
|
e.interactiveMu.Unlock()
|
|
|
|
done := make(chan struct{})
|
|
go func() {
|
|
e.processInteractiveEvents(state, session, e.sessions, key, "msg-1", time.Now(), nil, nil, "ctx")
|
|
close(done)
|
|
}()
|
|
|
|
stopDone := make(chan struct{})
|
|
go func() {
|
|
e.cmdStop(p, &Message{SessionKey: key, ReplyCtx: "ctx"})
|
|
close(stopDone)
|
|
}()
|
|
|
|
select {
|
|
case <-sess.closeStarted:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("expected Close to start after /stop")
|
|
}
|
|
|
|
select {
|
|
case <-stopDone:
|
|
case <-time.After(500 * time.Millisecond):
|
|
t.Fatal("cmdStop blocked on Close")
|
|
}
|
|
|
|
select {
|
|
case <-done:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("event loop did not stop after /stop")
|
|
}
|
|
|
|
e.interactiveMu.Lock()
|
|
_, exists := e.interactiveStates[key]
|
|
e.interactiveMu.Unlock()
|
|
if exists {
|
|
t.Fatal("expected interactive state to be removed after /stop")
|
|
}
|
|
|
|
sess.events <- Event{Type: EventText, Content: "stale output"}
|
|
sess.events <- Event{Type: EventResult, Content: "stale result", Done: true}
|
|
time.Sleep(50 * time.Millisecond)
|
|
|
|
sent := p.getSent()
|
|
if len(sent) != 1 || sent[0] != e.i18n.T(MsgExecutionStopped) {
|
|
t.Fatalf("sent messages = %v, want only execution stopped", sent)
|
|
}
|
|
|
|
close(sess.releaseClose)
|
|
select {
|
|
case <-sess.closed:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("Close did not finish after release")
|
|
}
|
|
}
|
|
|
|
func TestHandleMessageRecallStopsCurrentMessageSilently(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "test"}
|
|
sess := newBlockingCloseSession("recall-active")
|
|
defer close(sess.releaseClose)
|
|
|
|
e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish)
|
|
key := "test:user1"
|
|
e.interactiveMu.Lock()
|
|
e.interactiveStates[key] = &interactiveState{
|
|
agentSession: sess,
|
|
platform: p,
|
|
replyCtx: "ctx-active",
|
|
currentMessageID: "msg-active",
|
|
pendingMessages: []queuedMessage{
|
|
{messageID: "msg-queued", platform: p, replyCtx: "ctx-queued", content: "queued"},
|
|
},
|
|
}
|
|
e.interactiveMu.Unlock()
|
|
|
|
e.ReceiveMessage(p, &Message{
|
|
Platform: "test",
|
|
MessageID: "msg-active",
|
|
Recalled: true,
|
|
})
|
|
|
|
select {
|
|
case <-sess.closeStarted:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("expected Close to start after recalling the active message")
|
|
}
|
|
|
|
e.interactiveMu.Lock()
|
|
_, exists := e.interactiveStates[key]
|
|
e.interactiveMu.Unlock()
|
|
if exists {
|
|
t.Fatal("expected interactive state to be removed after active message recall")
|
|
}
|
|
|
|
if sent := p.getSent(); len(sent) != 0 {
|
|
t.Fatalf("sent messages = %v, want no user-visible stop reply for recall", sent)
|
|
}
|
|
}
|
|
|
|
func TestHandleMessageRecallRemovesQueuedMessageSilently(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "test"}
|
|
e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish)
|
|
key := "test:user1"
|
|
state := &interactiveState{
|
|
agentSession: newControllableSession("recall-queued"),
|
|
platform: p,
|
|
replyCtx: "ctx-active",
|
|
pendingMessages: []queuedMessage{
|
|
{messageID: "msg-1", platform: p, replyCtx: "ctx-1", content: "first"},
|
|
{messageID: "msg-2", platform: p, replyCtx: "ctx-2", content: "second"},
|
|
{messageID: "msg-3", platform: p, replyCtx: "ctx-3", content: "third"},
|
|
},
|
|
}
|
|
e.interactiveMu.Lock()
|
|
e.interactiveStates[key] = state
|
|
e.interactiveMu.Unlock()
|
|
|
|
e.ReceiveMessage(p, &Message{
|
|
Platform: "test",
|
|
MessageID: "msg-2",
|
|
Recalled: true,
|
|
})
|
|
|
|
state.mu.Lock()
|
|
got := make([]string, len(state.pendingMessages))
|
|
for i, queued := range state.pendingMessages {
|
|
got[i] = queued.messageID
|
|
}
|
|
state.mu.Unlock()
|
|
|
|
want := []string{"msg-1", "msg-3"}
|
|
if len(got) != len(want) {
|
|
t.Fatalf("pending message IDs = %v, want %v", got, want)
|
|
}
|
|
for i := range want {
|
|
if got[i] != want[i] {
|
|
t.Fatalf("pending message IDs = %v, want %v", got, want)
|
|
}
|
|
}
|
|
|
|
if sent := p.getSent(); len(sent) != 0 {
|
|
t.Fatalf("sent messages = %v, want no user-visible queue removal reply for recall", sent)
|
|
}
|
|
}
|
|
|
|
func TestHandleMessageBusyRecalledCurrentStopsAndProcessesNewMessage(t *testing.T) {
|
|
p := &recallCheckingPlatform{
|
|
stubPlatformEngine: stubPlatformEngine{n: "test"},
|
|
recalled: true,
|
|
}
|
|
newAgentSession := newResultAgentSession("new message processed")
|
|
e := NewEngine("test", &resultAgent{session: newAgentSession}, []Platform{p}, "", LangEnglish)
|
|
key := "test:user1"
|
|
session := e.sessions.GetOrCreateActive(key)
|
|
if !session.TryLock() {
|
|
t.Fatal("expected to lock session for busy setup")
|
|
}
|
|
|
|
oldState := &interactiveState{
|
|
agentSession: newControllableSession("old-current"),
|
|
platform: p,
|
|
replyCtx: "old-reply-ctx",
|
|
currentMessageID: "old-msg",
|
|
}
|
|
e.interactiveMu.Lock()
|
|
e.interactiveStates[key] = oldState
|
|
e.interactiveMu.Unlock()
|
|
|
|
oldStopped := oldState.stopSignal()
|
|
go func() {
|
|
<-oldStopped
|
|
session.Unlock()
|
|
}()
|
|
|
|
e.ReceiveMessage(p, &Message{
|
|
SessionKey: key,
|
|
Platform: "test",
|
|
MessageID: "new-msg",
|
|
Content: "please handle this",
|
|
ReplyCtx: "new-reply-ctx",
|
|
})
|
|
|
|
sent := waitForPlatformSend(&p.stubPlatformEngine, 1, 3*time.Second)
|
|
if len(sent) == 0 || sent[0] != "new message processed" {
|
|
t.Fatalf("sent = %v, want new message processed", sent)
|
|
}
|
|
for _, line := range sent {
|
|
if strings.Contains(line, e.i18n.T(MsgMessageQueued)) {
|
|
t.Fatalf("unexpected queued reply after recalled active message: %v", sent)
|
|
}
|
|
}
|
|
checked := p.checkedReplyCtxs()
|
|
if len(checked) == 0 || checked[0] != "old-reply-ctx" {
|
|
t.Fatalf("checked reply contexts = %v, want old-reply-ctx first", checked)
|
|
}
|
|
if len(newAgentSession.sentPrompts) != 1 || !strings.Contains(newAgentSession.sentPrompts[0], "please handle this") {
|
|
t.Fatalf("new session prompts = %#v, want new message prompt", newAgentSession.sentPrompts)
|
|
}
|
|
}
|
|
|
|
func TestExecuteCardAction_NewCleansUpAndCreatesSession(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "test"}
|
|
e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish)
|
|
key := "test:user1"
|
|
|
|
e.interactiveMu.Lock()
|
|
e.interactiveStates[key] = &interactiveState{agentSession: newControllableSession("old")}
|
|
e.interactiveMu.Unlock()
|
|
|
|
e.executeCardAction("/new", "", key)
|
|
|
|
e.interactiveMu.Lock()
|
|
_, exists := e.interactiveStates[key]
|
|
e.interactiveMu.Unlock()
|
|
|
|
if exists {
|
|
t.Error("expected old interactive state to be cleaned up after /new")
|
|
}
|
|
}
|
|
|
|
func TestExecuteCardAction_LangSwitch(t *testing.T) {
|
|
e := newTestEngine()
|
|
|
|
e.executeCardAction("/lang", "zh", "test:user1")
|
|
if e.i18n.CurrentLang() != LangChinese {
|
|
t.Errorf("expected LangChinese, got %v", e.i18n.CurrentLang())
|
|
}
|
|
|
|
e.executeCardAction("/lang", "en", "test:user1")
|
|
if e.i18n.CurrentLang() != LangEnglish {
|
|
t.Errorf("expected LangEnglish, got %v", e.i18n.CurrentLang())
|
|
}
|
|
|
|
e.executeCardAction("/lang", "ja", "test:user1")
|
|
if e.i18n.CurrentLang() != LangJapanese {
|
|
t.Errorf("expected LangJapanese, got %v", e.i18n.CurrentLang())
|
|
}
|
|
}
|
|
|
|
func TestExecuteCardAction_UnknownCommand_NoPanic(t *testing.T) {
|
|
e := newTestEngine()
|
|
// Should not panic for unrecognized commands.
|
|
e.executeCardAction("/nonexistent", "args", "test:user1")
|
|
e.executeCardAction("", "", "test:user1")
|
|
}
|
|
|
|
// --- 4. Multi-workspace command handlers use interactiveKey ---
|
|
|
|
func TestCmdStatus_UsesInteractiveKeyForMultiWorkspace(t *testing.T) {
|
|
p := &stubCardPlatform{stubPlatformEngine: stubPlatformEngine{n: "card"}}
|
|
agent := &stubModelModeAgent{model: "gpt-4.1", mode: "default"}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
e.SetDisplayConfig(DisplayCfg{
|
|
ThinkingMessages: false,
|
|
ThinkingMaxLen: 300,
|
|
ToolMaxLen: 500,
|
|
ToolMessages: true,
|
|
})
|
|
|
|
msg := &Message{SessionKey: "feishu:ch1:user1", Content: "/status", ReplyCtx: "ctx"}
|
|
e.cmdStatus(p, msg)
|
|
|
|
if len(p.repliedCards) == 0 && len(p.sentCards) == 0 {
|
|
sent := strings.Join(p.getSent(), "\n")
|
|
if !strings.Contains(sent, "Thinking messages: OFF") || !strings.Contains(sent, "Tool progress: ON") {
|
|
t.Fatalf("expected status to reflect display flags, got %q", sent)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCmdStop_UsesInteractiveKeyForMultiWorkspace(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "test"}
|
|
sess := newControllableSession("ws-stop-test")
|
|
e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish)
|
|
|
|
wsDir := t.TempDir()
|
|
rawKey := "feishu:ch1:user1"
|
|
wsKey := wsDir + ":" + rawKey
|
|
|
|
iKey := e.interactiveKeyForSessionKey(wsKey)
|
|
e.interactiveMu.Lock()
|
|
e.interactiveStates[iKey] = &interactiveState{agentSession: sess}
|
|
e.interactiveMu.Unlock()
|
|
|
|
msg := &Message{SessionKey: wsKey, Content: "/stop", ReplyCtx: "ctx"}
|
|
e.cmdStop(p, msg)
|
|
|
|
e.interactiveMu.Lock()
|
|
_, exists := e.interactiveStates[iKey]
|
|
e.interactiveMu.Unlock()
|
|
|
|
if exists {
|
|
t.Error("expected interactive state to be cleaned up by /stop using interactiveKey")
|
|
}
|
|
}
|
|
|
|
// ===========================================================================
|
|
// Beta pre-release tests: inject_sender, idle_timeout, /shell, /workspace,
|
|
// /switch, /memory
|
|
// ===========================================================================
|
|
|
|
// --- 1. inject_sender ---
|
|
|
|
func TestBuildSenderPrompt_Enabled(t *testing.T) {
|
|
e := newTestEngine()
|
|
e.SetInjectSender(true)
|
|
|
|
result := e.buildSenderPrompt("hello world", "user123", "Alice", "feishu", "feishu:channel42:user123", "")
|
|
expected := "[cc-connect sender_id=user123 sender_name=\"Alice\" platform=feishu chat_id=channel42]\nhello world"
|
|
if result != expected {
|
|
t.Fatalf("got %q, want %q", result, expected)
|
|
}
|
|
}
|
|
|
|
func TestBuildSenderPrompt_Disabled(t *testing.T) {
|
|
e := newTestEngine()
|
|
e.SetInjectSender(false)
|
|
|
|
result := e.buildSenderPrompt("hello", "user1", "Alice", "feishu", "feishu:ch:user1", "")
|
|
if result != "hello" {
|
|
t.Fatalf("expected raw content when disabled, got %q", result)
|
|
}
|
|
}
|
|
|
|
func TestBuildSenderPrompt_EmptyUserID(t *testing.T) {
|
|
e := newTestEngine()
|
|
e.SetInjectSender(true)
|
|
|
|
result := e.buildSenderPrompt("hello", "", "Bob", "telegram", "telegram:ch:user1", "")
|
|
if result != "hello" {
|
|
t.Fatalf("expected raw content when userID is empty, got %q", result)
|
|
}
|
|
}
|
|
|
|
func TestBuildSenderPrompt_EmptyUserName(t *testing.T) {
|
|
e := newTestEngine()
|
|
e.SetInjectSender(true)
|
|
|
|
result := e.buildSenderPrompt("hello", "user1", "", "feishu", "feishu:ch:user1", "")
|
|
expected := "[cc-connect sender_id=user1 platform=feishu chat_id=ch]\nhello"
|
|
if result != expected {
|
|
t.Fatalf("got %q, want %q", result, expected)
|
|
}
|
|
}
|
|
|
|
func TestBuildSenderPrompt_NameWithSpaces(t *testing.T) {
|
|
e := newTestEngine()
|
|
e.SetInjectSender(true)
|
|
|
|
result := e.buildSenderPrompt("hi", "U999", "Jim Tang", "slack", "slack:C012:U999", "")
|
|
expected := "[cc-connect sender_id=U999 sender_name=\"Jim Tang\" platform=slack chat_id=C012]\nhi"
|
|
if result != expected {
|
|
t.Fatalf("got %q, want %q", result, expected)
|
|
}
|
|
}
|
|
|
|
func TestExtractChannelID(t *testing.T) {
|
|
tests := []struct {
|
|
key string
|
|
want string
|
|
}{
|
|
{"feishu:channel42:user1", "channel42"},
|
|
{"telegram:group123:user2", "group123"},
|
|
{"plain", ""},
|
|
{"a:b", "b"},
|
|
{"a:bb:c:d", "bb"},
|
|
{"dingtalk:g:cidXXX:staff1", "cidXXX"},
|
|
{"dingtalk:d:cidYYY:staff2", "cidYYY"},
|
|
// 3-segment shared-session keys with single-char type tag — used by
|
|
// dingtalk/qq/qqbot when share_session_in_channel is enabled.
|
|
{"dingtalk:g:cidZZZ", "cidZZZ"},
|
|
{"qq:g:12345", "12345"},
|
|
{"qqbot:g:openid_abc", "openid_abc"},
|
|
}
|
|
for _, tt := range tests {
|
|
got := extractChannelID(tt.key)
|
|
if got != tt.want {
|
|
t.Errorf("extractChannelID(%q) = %q, want %q", tt.key, got, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBuildSenderPrompt_DifferentPlatforms(t *testing.T) {
|
|
e := newTestEngine()
|
|
e.SetInjectSender(true)
|
|
|
|
platforms := []struct {
|
|
platform string
|
|
sessionKey string
|
|
wantChat string
|
|
}{
|
|
{"telegram", "telegram:group99:alice", "group99"},
|
|
{"discord", "discord:server1:bob", "server1"},
|
|
{"slack", "slack:C012345:carol", "C012345"},
|
|
}
|
|
for _, tc := range platforms {
|
|
result := e.buildSenderPrompt("msg", "uid", "TestUser", tc.platform, tc.sessionKey, "")
|
|
if !strings.Contains(result, "platform="+tc.platform) {
|
|
t.Errorf("missing platform=%s in %q", tc.platform, result)
|
|
}
|
|
if !strings.Contains(result, "chat_id="+tc.wantChat) {
|
|
t.Errorf("missing chat_id=%s in %q", tc.wantChat, result)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBuildSenderPrompt_SanitizesSpecialChars(t *testing.T) {
|
|
e := newTestEngine()
|
|
e.SetInjectSender(true)
|
|
|
|
result := e.buildSenderPrompt("hi", "U1", "Evil\"Name\nInject", "slack", "slack:C1:U1", "")
|
|
if strings.Contains(result, `"Name`) || strings.Contains(result, "\n"+`Inject`) {
|
|
t.Fatalf("quotes/newlines should be sanitized, got %q", result)
|
|
}
|
|
if !strings.Contains(result, `sender_name="Evil'Name Inject"`) {
|
|
t.Fatalf("expected sanitized name, got %q", result)
|
|
}
|
|
}
|
|
|
|
func TestBuildSenderPrompt_ChannelKeyOverridesSessionKey(t *testing.T) {
|
|
e := newTestEngine()
|
|
e.SetInjectSender(true)
|
|
|
|
// When channelKey is provided, it should be used as chat_id instead of
|
|
// extracting from sessionKey (which would give "g" for dingtalk).
|
|
result := e.buildSenderPrompt("hello", "staff1", "Alice", "dingtalk", "dingtalk:g:cidXXX:staff1", "cidXXX")
|
|
expected := "[cc-connect sender_id=staff1 sender_name=\"Alice\" platform=dingtalk chat_id=cidXXX]\nhello"
|
|
if result != expected {
|
|
t.Fatalf("got %q, want %q", result, expected)
|
|
}
|
|
}
|
|
|
|
func TestBuildSenderPrompt_FallbackWithoutChannelKey(t *testing.T) {
|
|
e := newTestEngine()
|
|
e.SetInjectSender(true)
|
|
|
|
// When channelKey is empty, extractChannelID heuristic should detect
|
|
// the 4-segment format and extract the correct channel.
|
|
result := e.buildSenderPrompt("hello", "staff1", "Alice", "dingtalk", "dingtalk:g:cidXXX:staff1", "")
|
|
expected := "[cc-connect sender_id=staff1 sender_name=\"Alice\" platform=dingtalk chat_id=cidXXX]\nhello"
|
|
if result != expected {
|
|
t.Fatalf("got %q, want %q", result, expected)
|
|
}
|
|
}
|
|
|
|
func TestResolveLocalDirPath_RejectsTraversal(t *testing.T) {
|
|
base := t.TempDir()
|
|
_, err := resolveLocalDirPath("../../etc", base)
|
|
if err == nil {
|
|
t.Fatal("expected error for path traversal")
|
|
}
|
|
}
|
|
|
|
func TestResolveLocalDirPath_AcceptsSubdir(t *testing.T) {
|
|
base := t.TempDir()
|
|
sub := filepath.Join(base, "project")
|
|
os.MkdirAll(sub, 0755)
|
|
got, err := resolveLocalDirPath("project", base)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if got != sub {
|
|
t.Fatalf("expected %q, got %q", sub, got)
|
|
}
|
|
}
|
|
|
|
func TestResolveLocalDirPath_AbsoluteAllowed(t *testing.T) {
|
|
dir := t.TempDir()
|
|
got, err := resolveLocalDirPath(dir, "/some/base")
|
|
if err != nil {
|
|
t.Fatalf("absolute path should be allowed: %v", err)
|
|
}
|
|
if got == "" {
|
|
t.Fatal("expected non-empty path")
|
|
}
|
|
}
|
|
|
|
// --- 2. idle_timeout ---
|
|
|
|
func TestEventIdleTimeout_CleansUpSession(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "test"}
|
|
sess := newControllableSession("idle-test")
|
|
agent := &controllableAgent{nextSession: sess}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
e.SetEventIdleTimeout(100 * time.Millisecond)
|
|
|
|
key := "test:idle-user"
|
|
state := &interactiveState{
|
|
agentSession: sess,
|
|
platform: p,
|
|
replyCtx: "ctx",
|
|
}
|
|
e.interactiveMu.Lock()
|
|
e.interactiveStates[key] = state
|
|
e.interactiveMu.Unlock()
|
|
|
|
session := e.sessions.GetOrCreateActive(key)
|
|
session.TryLock()
|
|
|
|
done := make(chan struct{})
|
|
go func() {
|
|
e.processInteractiveEvents(state, session, e.sessions, key, "", time.Now(), nil, nil, nil)
|
|
close(done)
|
|
}()
|
|
|
|
select {
|
|
case <-done:
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("processInteractiveEvents did not return after idle timeout")
|
|
}
|
|
|
|
sent := p.getSent()
|
|
foundTimeout := false
|
|
for _, s := range sent {
|
|
if strings.Contains(s, "timed out") {
|
|
foundTimeout = true
|
|
}
|
|
}
|
|
if !foundTimeout {
|
|
t.Fatalf("expected timeout error message, got %v", sent)
|
|
}
|
|
}
|
|
|
|
func TestEventIdleTimeout_ResetOnEvent(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "test"}
|
|
sess := newControllableSession("idle-reset")
|
|
agent := &controllableAgent{nextSession: sess}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
e.SetEventIdleTimeout(200 * time.Millisecond)
|
|
|
|
key := "test:idle-reset"
|
|
state := &interactiveState{
|
|
agentSession: sess,
|
|
platform: p,
|
|
replyCtx: "ctx",
|
|
}
|
|
e.interactiveMu.Lock()
|
|
e.interactiveStates[key] = state
|
|
e.interactiveMu.Unlock()
|
|
|
|
session := e.sessions.GetOrCreateActive(key)
|
|
session.TryLock()
|
|
|
|
done := make(chan struct{})
|
|
go func() {
|
|
e.processInteractiveEvents(state, session, e.sessions, key, "", time.Now(), nil, nil, nil)
|
|
close(done)
|
|
}()
|
|
|
|
// Send a text event at 100ms (before the 200ms timeout), resetting the timer.
|
|
time.Sleep(100 * time.Millisecond)
|
|
sess.events <- Event{Type: EventText, Content: "thinking..."}
|
|
|
|
// Then send the result at 150ms after the text event (within the reset 200ms window).
|
|
time.Sleep(150 * time.Millisecond)
|
|
sess.events <- Event{Type: EventResult, Content: "done", Done: true}
|
|
|
|
select {
|
|
case <-done:
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("processInteractiveEvents did not complete after events")
|
|
}
|
|
|
|
sent := p.getSent()
|
|
foundTimeout := false
|
|
for _, s := range sent {
|
|
if strings.Contains(s, "timed out") {
|
|
foundTimeout = true
|
|
}
|
|
}
|
|
if foundTimeout {
|
|
t.Error("should NOT have timed out — events should have reset the timer")
|
|
}
|
|
}
|
|
|
|
func TestEventIdleTimeout_DisabledWhenZero(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "test"}
|
|
sess := newControllableSession("idle-zero")
|
|
agent := &controllableAgent{nextSession: sess}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
e.SetEventIdleTimeout(0)
|
|
|
|
key := "test:idle-zero"
|
|
state := &interactiveState{
|
|
agentSession: sess,
|
|
platform: p,
|
|
replyCtx: "ctx",
|
|
}
|
|
e.interactiveMu.Lock()
|
|
e.interactiveStates[key] = state
|
|
e.interactiveMu.Unlock()
|
|
|
|
session := e.sessions.GetOrCreateActive(key)
|
|
session.TryLock()
|
|
|
|
done := make(chan struct{})
|
|
go func() {
|
|
e.processInteractiveEvents(state, session, e.sessions, key, "", time.Now(), nil, nil, nil)
|
|
close(done)
|
|
}()
|
|
|
|
// With timeout disabled, it should block until we send a result.
|
|
time.Sleep(50 * time.Millisecond)
|
|
select {
|
|
case <-done:
|
|
t.Fatal("should not have returned yet — timeout is disabled and no events sent")
|
|
default:
|
|
}
|
|
|
|
sess.events <- Event{Type: EventResult, Content: "ok", Done: true}
|
|
|
|
select {
|
|
case <-done:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("did not return after result event")
|
|
}
|
|
}
|
|
|
|
// --- 3. /shell command ---
|
|
|
|
func TestCmdShell_BlockedWithoutAdmin(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "test"}
|
|
e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish)
|
|
|
|
msg := &Message{
|
|
SessionKey: "test:ch:user1",
|
|
Content: "/shell ls -la",
|
|
ReplyCtx: "ctx",
|
|
UserID: "user1",
|
|
Platform: "test",
|
|
}
|
|
e.handleCommand(p, msg, msg.Content)
|
|
|
|
sent := p.getSent()
|
|
foundAdmin := false
|
|
for _, s := range sent {
|
|
if strings.Contains(s, e.i18n.T(MsgAdminRequired)[:10]) || strings.Contains(s, "admin") {
|
|
foundAdmin = true
|
|
}
|
|
}
|
|
if !foundAdmin {
|
|
t.Fatalf("expected admin required reply, got %v", sent)
|
|
}
|
|
}
|
|
|
|
func TestCmdShell_AllowedForAdmin(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "test"}
|
|
e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish)
|
|
e.SetAdminFrom("admin-user")
|
|
|
|
msg := &Message{
|
|
SessionKey: "test:ch:admin-user",
|
|
Content: "/shell echo hello",
|
|
ReplyCtx: "ctx",
|
|
UserID: "admin-user",
|
|
Platform: "test",
|
|
}
|
|
e.handleCommand(p, msg, msg.Content)
|
|
|
|
// Give the async goroutine time to complete.
|
|
time.Sleep(500 * time.Millisecond)
|
|
|
|
sent := p.getSent()
|
|
foundAdmin := false
|
|
for _, s := range sent {
|
|
if strings.Contains(s, "admin") && strings.Contains(s, "privilege") {
|
|
foundAdmin = true
|
|
}
|
|
}
|
|
if foundAdmin {
|
|
t.Fatalf("admin user should not be blocked, got %v", sent)
|
|
}
|
|
}
|
|
|
|
func TestCmdShell_EmptyCommand_ShowsUsage(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "test"}
|
|
e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish)
|
|
e.SetAdminFrom("admin")
|
|
|
|
// Call cmdShell directly with empty command to test usage path.
|
|
msg := &Message{
|
|
SessionKey: "test:ch:admin",
|
|
Content: "/shell",
|
|
ReplyCtx: "ctx",
|
|
UserID: "admin",
|
|
Platform: "test",
|
|
}
|
|
e.cmdShell(p, msg, "/shell ")
|
|
|
|
sent := p.getSent()
|
|
foundUsage := false
|
|
for _, s := range sent {
|
|
if strings.Contains(s, "Usage") || strings.Contains(s, "/shell") {
|
|
foundUsage = true
|
|
}
|
|
}
|
|
if !foundUsage {
|
|
t.Fatalf("expected usage message, got %v", sent)
|
|
}
|
|
}
|
|
|
|
func TestCmdShell_MultiWorkspaceUsesSharedBindingWorkDir(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "test"}
|
|
e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish)
|
|
|
|
baseDir := t.TempDir()
|
|
bindStore := filepath.Join(t.TempDir(), "bindings.json")
|
|
e.SetMultiWorkspace(baseDir, bindStore)
|
|
|
|
wsDir := filepath.Join(baseDir, "shared-shell-workspace")
|
|
if err := os.MkdirAll(wsDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
normalizedWsDir := normalizeWorkspacePath(wsDir)
|
|
e.workspaceBindings.Bind(sharedWorkspaceBindingsKey, "ch1", "shared-shell", normalizedWsDir)
|
|
|
|
msg := &Message{
|
|
SessionKey: "test:ch1:user1",
|
|
Content: "/shell pwd",
|
|
ReplyCtx: "ctx",
|
|
}
|
|
e.cmdShell(p, msg, "/shell pwd")
|
|
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for {
|
|
sent := p.getSent()
|
|
if len(sent) > 0 {
|
|
if !strings.Contains(sent[0], normalizedWsDir) {
|
|
t.Fatalf("expected shell output to contain shared workspace %q, got %q", normalizedWsDir, sent[0])
|
|
}
|
|
return
|
|
}
|
|
if time.Now().After(deadline) {
|
|
t.Fatal("timed out waiting for shell response")
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
}
|
|
|
|
func TestCmdShell_MultiWorkspaceIgnoresMissingSharedBinding(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "test"}
|
|
agent := &stubWorkDirAgent{workDir: t.TempDir()}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
|
|
baseDir := t.TempDir()
|
|
bindStore := filepath.Join(t.TempDir(), "bindings.json")
|
|
e.SetMultiWorkspace(baseDir, bindStore)
|
|
|
|
missingDir := filepath.Join(baseDir, "missing-shared-workspace")
|
|
e.workspaceBindings.Bind(sharedWorkspaceBindingsKey, "ch1", "shared-shell", missingDir)
|
|
|
|
msg := &Message{
|
|
SessionKey: "test:ch1:user1",
|
|
Content: "/shell pwd",
|
|
ReplyCtx: "ctx",
|
|
}
|
|
e.cmdShell(p, msg, "/shell pwd")
|
|
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
// Normalize both the expected and missing paths to handle macOS symlink
|
|
// resolution (e.g. /var/folders/ -> /private/var/folders/). Then check
|
|
// that the shell output contains the resolved expected path and does NOT
|
|
// contain the resolved missing path.
|
|
expectedResolved := normalizeWorkspacePath(agent.workDir)
|
|
missingResolved := normalizeWorkspacePath(missingDir)
|
|
for {
|
|
sent := p.getSent()
|
|
if len(sent) > 0 {
|
|
// With streaming progress, the final result is the last sent message
|
|
output := sent[len(sent)-1]
|
|
if !strings.Contains(output, agent.workDir) && !strings.Contains(output, expectedResolved) {
|
|
t.Fatalf("expected shell output to fall back to agent work dir %q (resolved %q), got %q", agent.workDir, expectedResolved, output)
|
|
}
|
|
if strings.Contains(output, missingDir) || strings.Contains(output, missingResolved) {
|
|
t.Fatalf("expected shell output to ignore missing shared workspace %q, got %q", missingDir, output)
|
|
}
|
|
return
|
|
}
|
|
if time.Now().After(deadline) {
|
|
t.Fatal("timed out waiting for shell response")
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
}
|
|
|
|
// --- truncateRunes tests ---
|
|
|
|
func TestTruncateRunes(t *testing.T) {
|
|
t.Run("short string unchanged", func(t *testing.T) {
|
|
if got := truncateRunes("hello", 10); got != "hello" {
|
|
t.Errorf("got %q", got)
|
|
}
|
|
})
|
|
t.Run("exact limit unchanged", func(t *testing.T) {
|
|
if got := truncateRunes("abcde", 5); got != "abcde" {
|
|
t.Errorf("got %q", got)
|
|
}
|
|
})
|
|
t.Run("ascii truncation", func(t *testing.T) {
|
|
got := truncateRunes("abcdefghij", 7)
|
|
if got != "abcd..." {
|
|
t.Errorf("got %q", got)
|
|
}
|
|
})
|
|
t.Run("multi-byte truncation at rune boundary", func(t *testing.T) {
|
|
input := strings.Repeat("中", 10)
|
|
got := truncateRunes(input, 7)
|
|
if !utf8.ValidString(got) {
|
|
t.Errorf("produced invalid UTF-8: %q", got)
|
|
}
|
|
runes := []rune(got)
|
|
if len(runes) != 7 {
|
|
t.Errorf("expected 7 runes, got %d (%q)", len(runes), got)
|
|
}
|
|
if !strings.HasSuffix(got, "...") {
|
|
t.Errorf("expected trailing ..., got %q", got)
|
|
}
|
|
})
|
|
t.Run("exact max multi-byte unchanged", func(t *testing.T) {
|
|
input := strings.Repeat("中", 5)
|
|
got := truncateRunes(input, 5)
|
|
if got != input {
|
|
t.Errorf("expected no truncation, got %q", got)
|
|
}
|
|
})
|
|
t.Run("max less than 4 clamped", func(t *testing.T) {
|
|
// Should not panic when max < 4
|
|
got := truncateRunes("abcdefgh", 2)
|
|
runes := []rune(got)
|
|
if len(runes) != 4 {
|
|
t.Errorf("expected 4 runes (clamped), got %d (%q)", len(runes), got)
|
|
}
|
|
})
|
|
}
|
|
|
|
// --- runShellWithProgress tests ---
|
|
|
|
func TestRunShellWithProgress_BasicOutput(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "test"}
|
|
e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish)
|
|
|
|
err := e.runShellWithProgress(p, "ctx", "echo hello", t.TempDir(), 5*time.Second, 4000)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
deadline := time.Now().Add(3 * time.Second)
|
|
for {
|
|
sent := p.getSent()
|
|
if len(sent) > 0 {
|
|
last := sent[len(sent)-1]
|
|
if !strings.Contains(last, "hello") {
|
|
t.Errorf("expected output to contain 'hello', got %q", last)
|
|
}
|
|
if !strings.Contains(last, "✅") {
|
|
t.Errorf("expected success emoji, got %q", last)
|
|
}
|
|
return
|
|
}
|
|
if time.Now().After(deadline) {
|
|
t.Fatal("timed out waiting for shell response")
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
}
|
|
|
|
func TestRunShellWithProgress_FailedCommand(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "test"}
|
|
e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish)
|
|
|
|
err := e.runShellWithProgress(p, "ctx", "exit 42", t.TempDir(), 5*time.Second, 4000)
|
|
if err == nil {
|
|
t.Fatal("expected error for non-zero exit")
|
|
}
|
|
|
|
deadline := time.Now().Add(3 * time.Second)
|
|
for {
|
|
sent := p.getSent()
|
|
if len(sent) > 0 {
|
|
last := sent[len(sent)-1]
|
|
if !strings.Contains(last, "❌") {
|
|
t.Errorf("expected failure emoji, got %q", last)
|
|
}
|
|
if !strings.Contains(last, "42") {
|
|
t.Errorf("expected exit code 42 in output, got %q", last)
|
|
}
|
|
return
|
|
}
|
|
if time.Now().After(deadline) {
|
|
t.Fatal("timed out waiting for shell response")
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
}
|
|
|
|
func TestRunShellWithProgress_Timeout(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "test"}
|
|
e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish)
|
|
|
|
err := e.runShellWithProgress(p, "ctx", "sleep 30", t.TempDir(), 200*time.Millisecond, 4000)
|
|
if err == nil {
|
|
t.Fatal("expected timeout error")
|
|
}
|
|
if !strings.Contains(err.Error(), "timed out") {
|
|
t.Errorf("expected 'timed out' in error, got %q", err.Error())
|
|
}
|
|
|
|
deadline := time.Now().Add(3 * time.Second)
|
|
for {
|
|
sent := p.getSent()
|
|
if len(sent) > 0 {
|
|
last := sent[len(sent)-1]
|
|
if !strings.Contains(last, "⚠️") && !strings.Contains(last, "timeout") {
|
|
t.Errorf("expected timeout indicator, got %q", last)
|
|
}
|
|
return
|
|
}
|
|
if time.Now().After(deadline) {
|
|
t.Fatal("timed out waiting for timeout message")
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
}
|
|
|
|
func TestRunShellWithProgress_EmptyOutput(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "test"}
|
|
e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish)
|
|
|
|
cmd := "true"
|
|
if runtime.GOOS == "windows" {
|
|
cmd = `cmd /c "exit /b 0"`
|
|
}
|
|
err := e.runShellWithProgress(p, "ctx", cmd, t.TempDir(), 5*time.Second, 4000)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
deadline := time.Now().Add(3 * time.Second)
|
|
for {
|
|
sent := p.getSent()
|
|
if len(sent) > 0 {
|
|
last := sent[len(sent)-1]
|
|
if !strings.Contains(last, "(no output)") {
|
|
t.Errorf("expected '(no output)', got %q", last)
|
|
}
|
|
return
|
|
}
|
|
if time.Now().After(deadline) {
|
|
t.Fatal("timed out waiting for shell response")
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
}
|
|
|
|
func TestRunShellWithProgress_StderrOutput(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "test"}
|
|
e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish)
|
|
|
|
cmd := "echo err >&2"
|
|
if runtime.GOOS == "windows" {
|
|
cmd = `cmd /c "echo err >&2"`
|
|
}
|
|
err := e.runShellWithProgress(p, "ctx", cmd, t.TempDir(), 5*time.Second, 4000)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
deadline := time.Now().Add(3 * time.Second)
|
|
for {
|
|
sent := p.getSent()
|
|
if len(sent) > 0 {
|
|
last := sent[len(sent)-1]
|
|
if !strings.Contains(last, "err") {
|
|
t.Errorf("expected stderr output 'err', got %q", last)
|
|
}
|
|
if !strings.Contains(last, "✅") {
|
|
t.Errorf("expected success emoji, got %q", last)
|
|
}
|
|
return
|
|
}
|
|
if time.Now().After(deadline) {
|
|
t.Fatal("timed out waiting for shell response")
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
}
|
|
|
|
func TestRunShellWithProgress_LongOutputTruncated(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "test"}
|
|
e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish)
|
|
|
|
// Generate output longer than maxOutput
|
|
cmd := "python3 -c 'print(\"x\" * 5000)'"
|
|
timeout := 5 * time.Second
|
|
if runtime.GOOS == "windows" {
|
|
cmd = `Write-Host ('x' * 5000)`
|
|
timeout = 15 * time.Second
|
|
}
|
|
err := e.runShellWithProgress(p, "ctx", cmd, t.TempDir(), timeout, 100)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
deadline := time.Now().Add(3 * time.Second)
|
|
for {
|
|
sent := p.getSent()
|
|
if len(sent) > 0 {
|
|
last := sent[len(sent)-1]
|
|
if !utf8.ValidString(last) {
|
|
t.Errorf("output contains invalid UTF-8")
|
|
}
|
|
// Should be truncated — the code block content should end with "..."
|
|
if !strings.Contains(last, "...") {
|
|
t.Errorf("expected truncation marker '...', got %q", last)
|
|
}
|
|
return
|
|
}
|
|
if time.Now().After(deadline) {
|
|
t.Fatal("timed out waiting for shell response")
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
}
|
|
|
|
func TestRunShellWithProgress_NonexistentCommand(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "test"}
|
|
e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish)
|
|
|
|
err := e.runShellWithProgress(p, "ctx", "nonexistent_command_xyz_12345", t.TempDir(), 5*time.Second, 4000)
|
|
if err == nil {
|
|
t.Fatal("expected error for nonexistent command")
|
|
}
|
|
|
|
deadline := time.Now().Add(3 * time.Second)
|
|
for {
|
|
sent := p.getSent()
|
|
if len(sent) > 0 {
|
|
last := sent[len(sent)-1]
|
|
if !strings.Contains(last, "❌") {
|
|
t.Errorf("expected failure emoji, got %q", last)
|
|
}
|
|
if !strings.Contains(last, "failed to start") && !strings.Contains(last, "not found") && !strings.Contains(last, "executable file not found") && !strings.Contains(last, "CommandNotFoundException") {
|
|
t.Errorf("expected start failure message, got %q", last)
|
|
}
|
|
return
|
|
}
|
|
if time.Now().After(deadline) {
|
|
t.Fatal("timed out waiting for shell response")
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
}
|
|
|
|
// --- /diff command tests ---
|
|
|
|
func TestCmdDiff_BlockedWithoutAdmin(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "test"}
|
|
e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish)
|
|
|
|
msg := &Message{
|
|
SessionKey: "test:ch:user1",
|
|
Content: "/diff main",
|
|
ReplyCtx: "ctx",
|
|
UserID: "user1",
|
|
Platform: "test",
|
|
}
|
|
e.handleCommand(p, msg, msg.Content)
|
|
|
|
sent := p.getSent()
|
|
foundAdmin := false
|
|
for _, s := range sent {
|
|
if strings.Contains(s, "admin") || strings.Contains(s, e.i18n.T(MsgAdminRequired)[:10]) {
|
|
foundAdmin = true
|
|
}
|
|
}
|
|
if !foundAdmin {
|
|
t.Fatalf("expected admin required reply, got %v", sent)
|
|
}
|
|
}
|
|
|
|
func TestCmdDiff_EmptyDiff(t *testing.T) {
|
|
// Create a temp git repo with no changes
|
|
dir := t.TempDir()
|
|
cmds := [][]string{
|
|
{"git", "init"},
|
|
{"git", "config", "user.email", "test@test.com"},
|
|
{"git", "config", "user.name", "test"},
|
|
{"git", "commit", "--allow-empty", "-m", "init"},
|
|
}
|
|
for _, args := range cmds {
|
|
cmd := exec.Command(args[0], args[1:]...)
|
|
cmd.Dir = dir
|
|
if out, err := cmd.CombinedOutput(); err != nil {
|
|
t.Fatalf("setup %v: %s %v", args, out, err)
|
|
}
|
|
}
|
|
|
|
agent := &stubWorkDirAgent{workDir: dir}
|
|
p := &stubPlatformEngine{n: "test"}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
e.SetAdminFrom("admin")
|
|
|
|
msg := &Message{
|
|
SessionKey: "test:ch:admin",
|
|
Content: "/diff",
|
|
ReplyCtx: "ctx",
|
|
UserID: "admin",
|
|
Platform: "test",
|
|
}
|
|
e.cmdDiff(p, msg, "/diff")
|
|
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for {
|
|
sent := p.getSent()
|
|
if len(sent) > 0 {
|
|
found := false
|
|
for _, s := range sent {
|
|
if strings.Contains(s, "diff") || strings.Contains(s, "clean") {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatalf("expected empty diff message, got %v", sent)
|
|
}
|
|
return
|
|
}
|
|
if time.Now().After(deadline) {
|
|
t.Fatal("timed out waiting for diff response")
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
}
|
|
|
|
func TestCmdDiff_PlainTextFallback(t *testing.T) {
|
|
// Create a temp git repo with uncommitted changes
|
|
dir := t.TempDir()
|
|
cmds := [][]string{
|
|
{"git", "init"},
|
|
{"git", "config", "user.email", "test@test.com"},
|
|
{"git", "config", "user.name", "test"},
|
|
}
|
|
for _, args := range cmds {
|
|
cmd := exec.Command(args[0], args[1:]...)
|
|
cmd.Dir = dir
|
|
if out, err := cmd.CombinedOutput(); err != nil {
|
|
t.Fatalf("setup %v: %s %v", args, out, err)
|
|
}
|
|
}
|
|
// Create and commit a file, then modify it
|
|
if err := os.WriteFile(filepath.Join(dir, "test.txt"), []byte("hello\n"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, args := range [][]string{
|
|
{"git", "add", "test.txt"},
|
|
{"git", "commit", "-m", "add test.txt"},
|
|
} {
|
|
cmd := exec.Command(args[0], args[1:]...)
|
|
cmd.Dir = dir
|
|
if out, err := cmd.CombinedOutput(); err != nil {
|
|
t.Fatalf("setup %v: %s %v", args, out, err)
|
|
}
|
|
}
|
|
if err := os.WriteFile(filepath.Join(dir, "test.txt"), []byte("hello\nworld\n"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Use stubPlatformEngine (no FileSender) → should fall back to plain text
|
|
agent := &stubWorkDirAgent{workDir: dir}
|
|
p := &stubPlatformEngine{n: "test"}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
e.SetAdminFrom("admin")
|
|
|
|
msg := &Message{
|
|
SessionKey: "test:ch:admin",
|
|
Content: "/diff",
|
|
ReplyCtx: "ctx",
|
|
UserID: "admin",
|
|
Platform: "test",
|
|
}
|
|
e.cmdDiff(p, msg, "/diff")
|
|
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for {
|
|
sent := p.getSent()
|
|
if len(sent) > 0 {
|
|
found := false
|
|
for _, s := range sent {
|
|
if strings.Contains(s, "```diff") && strings.Contains(s, "world") {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatalf("expected plain text diff with ```diff block, got %v", sent)
|
|
}
|
|
return
|
|
}
|
|
if time.Now().After(deadline) {
|
|
t.Fatal("timed out waiting for diff response")
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
}
|
|
|
|
func TestCmdDiff_FileSenderPath(t *testing.T) {
|
|
// Create a temp git repo with uncommitted changes
|
|
dir := t.TempDir()
|
|
cmds := [][]string{
|
|
{"git", "init"},
|
|
{"git", "config", "user.email", "test@test.com"},
|
|
{"git", "config", "user.name", "test"},
|
|
}
|
|
for _, args := range cmds {
|
|
cmd := exec.Command(args[0], args[1:]...)
|
|
cmd.Dir = dir
|
|
if out, err := cmd.CombinedOutput(); err != nil {
|
|
t.Fatalf("setup %v: %s %v", args, out, err)
|
|
}
|
|
}
|
|
if err := os.WriteFile(filepath.Join(dir, "test.txt"), []byte("hello\n"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, args := range [][]string{
|
|
{"git", "add", "test.txt"},
|
|
{"git", "commit", "-m", "add test.txt"},
|
|
} {
|
|
cmd := exec.Command(args[0], args[1:]...)
|
|
cmd.Dir = dir
|
|
if out, err := cmd.CombinedOutput(); err != nil {
|
|
t.Fatalf("setup %v: %s %v", args, out, err)
|
|
}
|
|
}
|
|
if err := os.WriteFile(filepath.Join(dir, "test.txt"), []byte("changed\n"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
agent := &stubWorkDirAgent{workDir: dir}
|
|
mp := &stubMediaPlatform{stubPlatformEngine: stubPlatformEngine{n: "test"}}
|
|
e := NewEngine("test", agent, []Platform{mp}, "", LangEnglish)
|
|
e.SetAdminFrom("admin")
|
|
|
|
msg := &Message{
|
|
SessionKey: "test:ch:admin",
|
|
Content: "/diff",
|
|
ReplyCtx: "ctx",
|
|
UserID: "admin",
|
|
Platform: "test",
|
|
}
|
|
e.cmdDiff(mp, msg, "/diff")
|
|
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for {
|
|
// If diff2html is installed, we get a file; otherwise plain text fallback
|
|
files := mp.files
|
|
sent := mp.getSent()
|
|
if len(files) > 0 {
|
|
f := files[0]
|
|
if f.MimeType != "text/html" {
|
|
t.Fatalf("expected text/html, got %s", f.MimeType)
|
|
}
|
|
if !strings.HasSuffix(f.FileName, ".html") {
|
|
t.Fatalf("expected .html filename, got %s", f.FileName)
|
|
}
|
|
return
|
|
}
|
|
if len(sent) > 0 {
|
|
// diff2html not installed → plain text fallback is also acceptable
|
|
found := false
|
|
for _, s := range sent {
|
|
if strings.Contains(s, "```diff") {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatalf("expected diff output (file or plain text), got %v", sent)
|
|
}
|
|
return
|
|
}
|
|
if time.Now().After(deadline) {
|
|
t.Fatal("timed out waiting for diff response")
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
}
|
|
|
|
func TestCmdShow_EmptyReference_ShowsUsage(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "test"}
|
|
e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish)
|
|
e.SetAdminFrom("admin")
|
|
|
|
msg := &Message{
|
|
SessionKey: "test:ch:admin",
|
|
Content: "/show",
|
|
ReplyCtx: "ctx",
|
|
UserID: "admin",
|
|
Platform: "test",
|
|
}
|
|
e.cmdShow(p, msg, nil)
|
|
|
|
sent := p.getSent()
|
|
if len(sent) != 1 || !strings.Contains(sent[0], "/show") {
|
|
t.Fatalf("sent = %v, want show usage", sent)
|
|
}
|
|
}
|
|
|
|
func TestCmdShow_MultiWorkspaceUsesBoundWorkDirForRelativeReference(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "test"}
|
|
agentName := "test-show-workspace"
|
|
RegisterAgent(agentName, func(opts map[string]any) (Agent, error) {
|
|
return &namedStubModelModeAgent{name: agentName}, nil
|
|
})
|
|
e := NewEngine("test", &namedStubModelModeAgent{name: agentName}, []Platform{p}, "", LangEnglish)
|
|
e.SetAdminFrom("admin")
|
|
|
|
baseDir := t.TempDir()
|
|
bindStore := filepath.Join(t.TempDir(), "bindings.json")
|
|
e.SetMultiWorkspace(baseDir, bindStore)
|
|
|
|
wsDir := filepath.Join(baseDir, "demo-repo")
|
|
if err := os.MkdirAll(filepath.Join(wsDir, "svc"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(wsDir, "svc", "handler.go"), []byte("package svc\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
e.workspaceBindings.Bind(sharedWorkspaceBindingsKey, "ch1", "demo", normalizeWorkspacePath(wsDir))
|
|
|
|
msg := &Message{
|
|
SessionKey: "test:ch1:admin",
|
|
Content: "/show svc/handler.go",
|
|
ReplyCtx: "ctx",
|
|
UserID: "admin",
|
|
Platform: "test",
|
|
}
|
|
e.cmdShow(p, msg, []string{"svc/handler.go"})
|
|
|
|
deadline := time.Now().Add(500 * time.Millisecond)
|
|
for {
|
|
sent := p.getSent()
|
|
if len(sent) > 0 {
|
|
if !strings.Contains(sent[0], "📄 svc/handler.go") {
|
|
t.Fatalf("output = %q, want relative title", sent[0])
|
|
}
|
|
if !strings.Contains(sent[0], "package svc") {
|
|
t.Fatalf("output = %q, want file content", sent[0])
|
|
}
|
|
return
|
|
}
|
|
if time.Now().After(deadline) {
|
|
t.Fatal("timed out waiting for /show response")
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
}
|
|
|
|
func TestHandleCommand_ShowRequiresAdmin(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "test"}
|
|
e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish)
|
|
e.SetAdminFrom("admin")
|
|
|
|
msg := &Message{
|
|
SessionKey: "test:ch:user1",
|
|
Content: "/show foo.txt",
|
|
ReplyCtx: "ctx",
|
|
UserID: "user1",
|
|
Platform: "test",
|
|
}
|
|
e.handleCommand(p, msg, msg.Content)
|
|
|
|
sent := p.getSent()
|
|
if len(sent) != 1 || !strings.Contains(strings.ToLower(sent[0]), "admin") {
|
|
t.Fatalf("sent = %v, want admin required message", sent)
|
|
}
|
|
}
|
|
|
|
func TestCmdShow_OutputRemainsRawWhenReferencesEnabled(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "feishu"}
|
|
agent := &stubWorkDirAgent{workDir: t.TempDir()}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
e.SetAdminFrom("admin")
|
|
e.references = normalizeReferenceRenderCfg(ReferenceRenderCfg{
|
|
NormalizeAgents: []string{"all"},
|
|
RenderPlatforms: []string{"all"},
|
|
DisplayPath: "relative",
|
|
MarkerStyle: "emoji",
|
|
EnclosureStyle: "code",
|
|
})
|
|
|
|
file := filepath.Join(agent.workDir, "svc", "handler.go")
|
|
if err := os.MkdirAll(filepath.Dir(file), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rawLine := "/root/code/demo-repo/ui/recovery_contact_form.tsx:11"
|
|
if err := os.WriteFile(file, []byte(rawLine+"\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
msg := &Message{
|
|
SessionKey: "test:ch:admin",
|
|
Content: "/show svc/handler.go",
|
|
ReplyCtx: "ctx",
|
|
UserID: "admin",
|
|
Platform: "feishu",
|
|
}
|
|
e.cmdShow(p, msg, []string{"svc/handler.go"})
|
|
|
|
sent := p.getSent()
|
|
if len(sent) != 1 {
|
|
t.Fatalf("sent = %v, want one response", sent)
|
|
}
|
|
if !strings.Contains(sent[0], rawLine) {
|
|
t.Fatalf("output = %q, want raw code content preserved", sent[0])
|
|
}
|
|
}
|
|
|
|
// --- 4. /workspace subcommands ---
|
|
|
|
func TestWorkspace_NotEnabled_RepliesDisabled(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "test"}
|
|
e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish)
|
|
|
|
msg := &Message{SessionKey: "test:ch1:user1", Content: "/workspace list", ReplyCtx: "ctx"}
|
|
e.handleCommand(p, msg, msg.Content)
|
|
|
|
sent := p.getSent()
|
|
if len(sent) == 0 {
|
|
t.Fatal("expected a reply")
|
|
}
|
|
}
|
|
|
|
func TestWorkspace_Bind_Unbind_List(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "test"}
|
|
e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish)
|
|
|
|
baseDir := t.TempDir()
|
|
wsDir := filepath.Join(baseDir, "my-project")
|
|
if err := os.MkdirAll(wsDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
bindStore := filepath.Join(t.TempDir(), "bindings.json")
|
|
e.SetMultiWorkspace(baseDir, bindStore)
|
|
|
|
// Bind
|
|
msg := &Message{SessionKey: "test:ch1:user1", Content: "/workspace bind my-project", ReplyCtx: "ctx"}
|
|
e.handleCommand(p, msg, msg.Content)
|
|
|
|
sent := p.getSent()
|
|
foundBind := false
|
|
for _, s := range sent {
|
|
if strings.Contains(s, "my-project") || strings.Contains(s, e.i18n.T(MsgWsBindSuccess)[:5]) {
|
|
foundBind = true
|
|
}
|
|
}
|
|
if !foundBind {
|
|
t.Fatalf("expected bind success, got %v", sent)
|
|
}
|
|
|
|
// List
|
|
p.clearSent()
|
|
msg = &Message{SessionKey: "test:ch1:user1", Content: "/workspace list", ReplyCtx: "ctx"}
|
|
e.handleCommand(p, msg, msg.Content)
|
|
|
|
sent = p.getSent()
|
|
foundList := false
|
|
for _, s := range sent {
|
|
if strings.Contains(s, "my-project") {
|
|
foundList = true
|
|
}
|
|
}
|
|
if !foundList {
|
|
t.Fatalf("expected list to show binding, got %v", sent)
|
|
}
|
|
|
|
// Unbind
|
|
p.clearSent()
|
|
msg = &Message{SessionKey: "test:ch1:user1", Content: "/workspace unbind", ReplyCtx: "ctx"}
|
|
e.handleCommand(p, msg, msg.Content)
|
|
|
|
sent = p.getSent()
|
|
foundUnbind := false
|
|
for _, s := range sent {
|
|
if strings.Contains(s, e.i18n.T(MsgWsUnbindSuccess)[:5]) {
|
|
foundUnbind = true
|
|
}
|
|
}
|
|
if !foundUnbind {
|
|
t.Fatalf("expected unbind success, got %v", sent)
|
|
}
|
|
|
|
// List again — should be empty
|
|
p.clearSent()
|
|
msg = &Message{SessionKey: "test:ch1:user1", Content: "/workspace list", ReplyCtx: "ctx"}
|
|
e.handleCommand(p, msg, msg.Content)
|
|
|
|
sent = p.getSent()
|
|
foundEmpty := false
|
|
for _, s := range sent {
|
|
if strings.Contains(s, e.i18n.T(MsgWsListEmpty)[:5]) {
|
|
foundEmpty = true
|
|
}
|
|
}
|
|
if !foundEmpty {
|
|
t.Fatalf("expected empty list, got %v", sent)
|
|
}
|
|
}
|
|
|
|
func TestWorkspace_Bind_NonexistentDir(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "test"}
|
|
e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish)
|
|
|
|
baseDir := t.TempDir()
|
|
bindStore := filepath.Join(t.TempDir(), "bindings.json")
|
|
e.SetMultiWorkspace(baseDir, bindStore)
|
|
|
|
msg := &Message{SessionKey: "test:ch1:user1", Content: "/workspace bind nonexistent", ReplyCtx: "ctx"}
|
|
e.handleCommand(p, msg, msg.Content)
|
|
|
|
sent := p.getSent()
|
|
found := false
|
|
for _, s := range sent {
|
|
if strings.Contains(s, "nonexistent") || strings.Contains(s, "not found") || strings.Contains(s, "Not found") {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatalf("expected not-found reply, got %v", sent)
|
|
}
|
|
}
|
|
|
|
func TestWorkspace_Route_ShowsCurrentAndSupportsSpaces(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "test"}
|
|
e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish)
|
|
|
|
baseDir := t.TempDir()
|
|
bindStore := filepath.Join(t.TempDir(), "bindings.json")
|
|
e.SetMultiWorkspace(baseDir, bindStore)
|
|
|
|
targetDir := filepath.Join(t.TempDir(), "routed project")
|
|
if err := os.MkdirAll(targetDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
msg := &Message{SessionKey: "test:ch1:user1", Content: "/workspace route " + targetDir, ReplyCtx: "ctx"}
|
|
e.handleCommand(p, msg, msg.Content)
|
|
|
|
normalizedTarget := normalizeWorkspacePath(targetDir)
|
|
channelKey := workspaceChannelKey("test", "ch1")
|
|
if got := e.workspaceBindings.Lookup("project:test", channelKey); got == nil || got.Workspace != normalizedTarget {
|
|
t.Fatalf("expected routed binding %q, got %+v", normalizedTarget, got)
|
|
}
|
|
|
|
sent := p.getSent()
|
|
if len(sent) == 0 || !strings.Contains(sent[0], normalizedTarget) {
|
|
t.Fatalf("expected route success reply to contain %q, got %v", normalizedTarget, sent)
|
|
}
|
|
|
|
p.clearSent()
|
|
msg.Content = "/workspace"
|
|
e.handleCommand(p, msg, msg.Content)
|
|
sent = p.getSent()
|
|
if len(sent) == 0 || !strings.Contains(sent[0], normalizedTarget) {
|
|
t.Fatalf("expected workspace info to contain routed path %q, got %v", normalizedTarget, sent)
|
|
}
|
|
}
|
|
|
|
func TestWorkspace_Route_RejectsRelativePath(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "test"}
|
|
e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish)
|
|
|
|
baseDir := t.TempDir()
|
|
bindStore := filepath.Join(t.TempDir(), "bindings.json")
|
|
e.SetMultiWorkspace(baseDir, bindStore)
|
|
|
|
msg := &Message{SessionKey: "test:ch1:user1", Content: "/workspace route relative/path", ReplyCtx: "ctx"}
|
|
e.handleCommand(p, msg, msg.Content)
|
|
|
|
sent := p.getSent()
|
|
if len(sent) == 0 || !strings.Contains(strings.ToLower(sent[0]), "absolute") {
|
|
t.Fatalf("expected absolute-path validation reply, got %v", sent)
|
|
}
|
|
if got := e.workspaceBindings.Lookup("project:test", workspaceChannelKey("test", "ch1")); got != nil {
|
|
t.Fatalf("expected no binding for relative route, got %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestWorkspace_Route_RejectsNonexistentPath(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "test"}
|
|
e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish)
|
|
|
|
baseDir := t.TempDir()
|
|
bindStore := filepath.Join(t.TempDir(), "bindings.json")
|
|
e.SetMultiWorkspace(baseDir, bindStore)
|
|
|
|
missingPath := filepath.Join(t.TempDir(), "missing")
|
|
msg := &Message{SessionKey: "test:ch1:user1", Content: "/workspace route " + missingPath, ReplyCtx: "ctx"}
|
|
e.handleCommand(p, msg, msg.Content)
|
|
|
|
sent := p.getSent()
|
|
if len(sent) == 0 || !strings.Contains(sent[0], missingPath) {
|
|
t.Fatalf("expected missing-path reply, got %v", sent)
|
|
}
|
|
if got := e.workspaceBindings.Lookup("project:test", workspaceChannelKey("test", "ch1")); got != nil {
|
|
t.Fatalf("expected no binding for missing route target, got %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestWorkspace_Route_RejectsFileTarget(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "test"}
|
|
e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish)
|
|
|
|
baseDir := t.TempDir()
|
|
bindStore := filepath.Join(t.TempDir(), "bindings.json")
|
|
e.SetMultiWorkspace(baseDir, bindStore)
|
|
|
|
fileTarget := filepath.Join(t.TempDir(), "workspace.txt")
|
|
if err := os.WriteFile(fileTarget, []byte("not a dir"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
msg := &Message{SessionKey: "test:ch1:user1", Content: "/workspace route " + fileTarget, ReplyCtx: "ctx"}
|
|
e.handleCommand(p, msg, msg.Content)
|
|
|
|
sent := p.getSent()
|
|
if len(sent) == 0 || !strings.Contains(strings.ToLower(sent[0]), "directory") {
|
|
t.Fatalf("expected not-directory reply, got %v", sent)
|
|
}
|
|
if got := e.workspaceBindings.Lookup("project:test", workspaceChannelKey("test", "ch1")); got != nil {
|
|
t.Fatalf("expected no binding for file route target, got %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestWorkspace_NoArgs_ShowsCurrent(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "test"}
|
|
e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish)
|
|
|
|
baseDir := t.TempDir()
|
|
bindStore := filepath.Join(t.TempDir(), "bindings.json")
|
|
e.SetMultiWorkspace(baseDir, bindStore)
|
|
|
|
// No binding yet — should show "no binding"
|
|
msg := &Message{SessionKey: "test:ch1:user1", Content: "/workspace", ReplyCtx: "ctx"}
|
|
e.handleCommand(p, msg, msg.Content)
|
|
|
|
sent := p.getSent()
|
|
if len(sent) == 0 {
|
|
t.Fatal("expected a reply")
|
|
}
|
|
}
|
|
|
|
func TestWorkspace_NoArgs_ShowsSharedBinding(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "test"}
|
|
e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish)
|
|
|
|
baseDir := t.TempDir()
|
|
bindStore := filepath.Join(t.TempDir(), "bindings.json")
|
|
e.SetMultiWorkspace(baseDir, bindStore)
|
|
|
|
wsDir := filepath.Join(baseDir, "shared-project")
|
|
if err := os.MkdirAll(wsDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
normalizedWsDir := normalizeWorkspacePath(wsDir)
|
|
e.workspaceBindings.Bind(sharedWorkspaceBindingsKey, "ch1", "shared-project", normalizedWsDir)
|
|
|
|
msg := &Message{SessionKey: "test:ch1:user1", Content: "/workspace", ReplyCtx: "ctx"}
|
|
e.handleCommand(p, msg, msg.Content)
|
|
|
|
sent := p.getSent()
|
|
if len(sent) == 0 {
|
|
t.Fatal("expected a reply")
|
|
}
|
|
if !strings.Contains(sent[0], normalizedWsDir) {
|
|
t.Fatalf("expected workspace info to contain shared workspace %q, got %q", normalizedWsDir, sent[0])
|
|
}
|
|
if !strings.Contains(strings.ToLower(sent[0]), "shared") {
|
|
t.Fatalf("expected workspace info to mention shared source, got %q", sent[0])
|
|
}
|
|
}
|
|
|
|
func TestWorkspace_SharedBind_AllowsRegularUser(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "test"}
|
|
e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish)
|
|
|
|
baseDir := t.TempDir()
|
|
wsDir := filepath.Join(baseDir, "shared-project")
|
|
if err := os.MkdirAll(wsDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
bindStore := filepath.Join(t.TempDir(), "bindings.json")
|
|
e.SetMultiWorkspace(baseDir, bindStore)
|
|
|
|
msg := &Message{
|
|
SessionKey: "test:ch1:user1",
|
|
Content: "/workspace shared bind shared-project",
|
|
ReplyCtx: "ctx",
|
|
UserID: "user1",
|
|
}
|
|
e.handleCommand(p, msg, msg.Content)
|
|
|
|
sent := p.getSent()
|
|
if len(sent) == 0 {
|
|
t.Fatal("expected shared bind reply")
|
|
}
|
|
normalizedWsDir := normalizeWorkspacePath(wsDir)
|
|
if !strings.Contains(sent[0], "shared-project") {
|
|
t.Fatalf("expected shared bind success reply to contain workspace name, got %v", sent)
|
|
}
|
|
if got := e.workspaceBindings.Lookup(sharedWorkspaceBindingsKey, workspaceChannelKey("test", "ch1")); got == nil || got.Workspace != normalizedWsDir {
|
|
t.Fatalf("expected shared binding %q for regular user, got %+v", normalizedWsDir, got)
|
|
}
|
|
}
|
|
|
|
func TestWorkspace_SharedBind_Unbind_List(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "test"}
|
|
e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish)
|
|
|
|
baseDir := t.TempDir()
|
|
wsDir := filepath.Join(baseDir, "shared-project")
|
|
if err := os.MkdirAll(wsDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
bindStore := filepath.Join(t.TempDir(), "bindings.json")
|
|
e.SetMultiWorkspace(baseDir, bindStore)
|
|
|
|
msg := &Message{
|
|
SessionKey: "test:ch1:user1",
|
|
Content: "/workspace shared bind shared-project",
|
|
ReplyCtx: "ctx",
|
|
UserID: "user1",
|
|
}
|
|
e.handleCommand(p, msg, msg.Content)
|
|
|
|
normalizedWsDir := normalizeWorkspacePath(wsDir)
|
|
channelKey := workspaceChannelKey("test", "ch1")
|
|
if got := e.workspaceBindings.Lookup(sharedWorkspaceBindingsKey, channelKey); got == nil || got.Workspace != normalizedWsDir {
|
|
t.Fatalf("expected shared binding %q, got %+v", normalizedWsDir, got)
|
|
}
|
|
|
|
p.clearSent()
|
|
msg.Content = "/workspace shared"
|
|
e.handleCommand(p, msg, msg.Content)
|
|
sent := p.getSent()
|
|
if len(sent) == 0 || !strings.Contains(sent[0], normalizedWsDir) || !strings.Contains(strings.ToLower(sent[0]), "shared") {
|
|
t.Fatalf("expected shared workspace info, got %v", sent)
|
|
}
|
|
|
|
p.clearSent()
|
|
msg.Content = "/workspace shared list"
|
|
e.handleCommand(p, msg, msg.Content)
|
|
sent = p.getSent()
|
|
if len(sent) == 0 || !strings.Contains(sent[0], "shared-project") {
|
|
t.Fatalf("expected shared list output, got %v", sent)
|
|
}
|
|
|
|
p.clearSent()
|
|
msg.Content = "/workspace shared unbind"
|
|
e.handleCommand(p, msg, msg.Content)
|
|
sent = p.getSent()
|
|
if len(sent) == 0 || !strings.Contains(strings.ToLower(sent[0]), "shared workspace") {
|
|
t.Fatalf("expected shared unbind success, got %v", sent)
|
|
}
|
|
if got := e.workspaceBindings.Lookup(sharedWorkspaceBindingsKey, channelKey); got != nil {
|
|
t.Fatalf("expected shared binding removed, got %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestWorkspace_SharedRoute_Unbind_List(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "test"}
|
|
e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish)
|
|
|
|
baseDir := t.TempDir()
|
|
bindStore := filepath.Join(t.TempDir(), "bindings.json")
|
|
e.SetMultiWorkspace(baseDir, bindStore)
|
|
|
|
targetDir := filepath.Join(t.TempDir(), "shared routed workspace")
|
|
if err := os.MkdirAll(targetDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
msg := &Message{
|
|
SessionKey: "test:ch1:user1",
|
|
Content: "/workspace shared route " + targetDir,
|
|
ReplyCtx: "ctx",
|
|
UserID: "user1",
|
|
}
|
|
e.handleCommand(p, msg, msg.Content)
|
|
|
|
normalizedTarget := normalizeWorkspacePath(targetDir)
|
|
channelKey := workspaceChannelKey("test", "ch1")
|
|
if got := e.workspaceBindings.Lookup(sharedWorkspaceBindingsKey, channelKey); got == nil || got.Workspace != normalizedTarget {
|
|
t.Fatalf("expected shared route binding %q, got %+v", normalizedTarget, got)
|
|
}
|
|
|
|
p.clearSent()
|
|
msg.Content = "/workspace shared"
|
|
e.handleCommand(p, msg, msg.Content)
|
|
sent := p.getSent()
|
|
if len(sent) == 0 || !strings.Contains(sent[0], normalizedTarget) || !strings.Contains(strings.ToLower(sent[0]), "shared") {
|
|
t.Fatalf("expected shared route info, got %v", sent)
|
|
}
|
|
|
|
p.clearSent()
|
|
msg.Content = "/workspace shared list"
|
|
e.handleCommand(p, msg, msg.Content)
|
|
sent = p.getSent()
|
|
if len(sent) == 0 || !strings.Contains(sent[0], normalizedTarget) {
|
|
t.Fatalf("expected shared route list output, got %v", sent)
|
|
}
|
|
|
|
p.clearSent()
|
|
msg.Content = "/workspace shared unbind"
|
|
e.handleCommand(p, msg, msg.Content)
|
|
sent = p.getSent()
|
|
if len(sent) == 0 || !strings.Contains(strings.ToLower(sent[0]), "shared workspace") {
|
|
t.Fatalf("expected shared unbind success, got %v", sent)
|
|
}
|
|
if got := e.workspaceBindings.Lookup(sharedWorkspaceBindingsKey, channelKey); got != nil {
|
|
t.Fatalf("expected shared route binding removed, got %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestWorkspace_SharedInit_BindsExistingDir(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "test"}
|
|
e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish)
|
|
|
|
baseDir := t.TempDir()
|
|
wsDir := filepath.Join(baseDir, "repo")
|
|
if err := os.MkdirAll(wsDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
bindStore := filepath.Join(t.TempDir(), "bindings.json")
|
|
e.SetMultiWorkspace(baseDir, bindStore)
|
|
|
|
msg := &Message{
|
|
SessionKey: "test:ch1:user1",
|
|
Content: "/workspace shared init https://github.com/example/repo.git",
|
|
ReplyCtx: "ctx",
|
|
UserID: "user1",
|
|
}
|
|
e.handleCommand(p, msg, msg.Content)
|
|
|
|
normalizedWsDir := normalizeWorkspacePath(wsDir)
|
|
if got := e.workspaceBindings.Lookup(sharedWorkspaceBindingsKey, workspaceChannelKey("test", "ch1")); got == nil || got.Workspace != normalizedWsDir {
|
|
t.Fatalf("expected shared init binding %q, got %+v", normalizedWsDir, got)
|
|
}
|
|
}
|
|
|
|
func TestWorkspace_Init_LocalDirAbsolute(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "test"}
|
|
e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish)
|
|
|
|
baseDir := t.TempDir()
|
|
wsDir := filepath.Join(baseDir, "my-project")
|
|
if err := os.MkdirAll(wsDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
bindStore := filepath.Join(t.TempDir(), "bindings.json")
|
|
e.SetMultiWorkspace(baseDir, bindStore)
|
|
e.SetWorkspaceInitAllowLocalPaths(true)
|
|
|
|
msg := &Message{
|
|
SessionKey: "test:ch1:user1",
|
|
Content: "/workspace init " + wsDir,
|
|
ReplyCtx: "ctx",
|
|
UserID: "user1",
|
|
}
|
|
e.handleCommand(p, msg, msg.Content)
|
|
|
|
normalizedWsDir := normalizeWorkspacePath(wsDir)
|
|
projectKey := "project:test"
|
|
if got := e.workspaceBindings.Lookup(projectKey, workspaceChannelKey("test", "ch1")); got == nil || got.Workspace != normalizedWsDir {
|
|
t.Fatalf("expected init binding %q, got %+v", normalizedWsDir, got)
|
|
}
|
|
}
|
|
|
|
func TestWorkspace_Init_LocalDirRelative(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "test"}
|
|
e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish)
|
|
|
|
baseDir := t.TempDir()
|
|
wsDir := filepath.Join(baseDir, "my-project")
|
|
if err := os.MkdirAll(wsDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
bindStore := filepath.Join(t.TempDir(), "bindings.json")
|
|
e.SetMultiWorkspace(baseDir, bindStore)
|
|
e.SetWorkspaceInitAllowLocalPaths(true)
|
|
|
|
// Use relative name — should resolve under baseDir.
|
|
msg := &Message{
|
|
SessionKey: "test:ch1:user1",
|
|
Content: "/workspace init my-project",
|
|
ReplyCtx: "ctx",
|
|
UserID: "user1",
|
|
}
|
|
e.handleCommand(p, msg, msg.Content)
|
|
|
|
normalizedWsDir := normalizeWorkspacePath(wsDir)
|
|
projectKey := "project:test"
|
|
if got := e.workspaceBindings.Lookup(projectKey, workspaceChannelKey("test", "ch1")); got == nil || got.Workspace != normalizedWsDir {
|
|
t.Fatalf("expected init binding %q, got %+v", normalizedWsDir, got)
|
|
}
|
|
}
|
|
|
|
func TestWorkspace_Init_LocalDirNotFound(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "test"}
|
|
e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish)
|
|
|
|
baseDir := t.TempDir()
|
|
bindStore := filepath.Join(t.TempDir(), "bindings.json")
|
|
e.SetMultiWorkspace(baseDir, bindStore)
|
|
e.SetWorkspaceInitAllowLocalPaths(true)
|
|
|
|
msg := &Message{
|
|
SessionKey: "test:ch1:user1",
|
|
Content: "/workspace init nonexistent-dir",
|
|
ReplyCtx: "ctx",
|
|
UserID: "user1",
|
|
}
|
|
e.handleCommand(p, msg, msg.Content)
|
|
|
|
sent := p.getSent()
|
|
if len(sent) == 0 || !strings.Contains(sent[0], "nonexistent-dir") {
|
|
t.Fatalf("expected error mentioning missing dir, got %v", sent)
|
|
}
|
|
|
|
projectKey := "project:test"
|
|
if got := e.workspaceBindings.Lookup(projectKey, workspaceChannelKey("test", "ch1")); got != nil {
|
|
t.Fatalf("expected no binding for nonexistent dir, got %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestWorkspace_Init_LocalDirDisabledByDefault(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "test"}
|
|
e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish)
|
|
|
|
baseDir := t.TempDir()
|
|
wsDir := filepath.Join(baseDir, "my-project")
|
|
if err := os.MkdirAll(wsDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
bindStore := filepath.Join(t.TempDir(), "bindings.json")
|
|
e.SetMultiWorkspace(baseDir, bindStore)
|
|
|
|
msg := &Message{
|
|
SessionKey: "test:ch1:user1",
|
|
Content: "/workspace init " + wsDir,
|
|
ReplyCtx: "ctx",
|
|
UserID: "user1",
|
|
}
|
|
e.handleCommand(p, msg, msg.Content)
|
|
|
|
sent := p.getSent()
|
|
if len(sent) == 0 || !strings.Contains(sent[0], "workspace_init_allow_local_paths") {
|
|
t.Fatalf("expected local-path disabled reply, got %v", sent)
|
|
}
|
|
if got := e.workspaceBindings.Lookup("project:test", workspaceChannelKey("test", "ch1")); got != nil {
|
|
t.Fatalf("expected no binding when local init paths are disabled, got %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestWorkspace_Unbind_SharedBindingShowsHint(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "test"}
|
|
e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish)
|
|
|
|
baseDir := t.TempDir()
|
|
bindStore := filepath.Join(t.TempDir(), "bindings.json")
|
|
e.SetMultiWorkspace(baseDir, bindStore)
|
|
|
|
wsDir := filepath.Join(baseDir, "shared-project")
|
|
if err := os.MkdirAll(wsDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
e.workspaceBindings.Bind(sharedWorkspaceBindingsKey, "ch1", "shared-project", normalizeWorkspacePath(wsDir))
|
|
|
|
msg := &Message{SessionKey: "test:ch1:user1", Content: "/workspace unbind", ReplyCtx: "ctx"}
|
|
e.handleCommand(p, msg, msg.Content)
|
|
|
|
sent := p.getSent()
|
|
if len(sent) == 0 || !strings.Contains(sent[0], "/workspace shared unbind") {
|
|
t.Fatalf("expected hint to use shared unbind, got %v", sent)
|
|
}
|
|
}
|
|
|
|
func TestWorkspace_NoArgs_IgnoresMissingSharedBinding(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "test"}
|
|
e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish)
|
|
|
|
baseDir := t.TempDir()
|
|
bindStore := filepath.Join(t.TempDir(), "bindings.json")
|
|
e.SetMultiWorkspace(baseDir, bindStore)
|
|
|
|
missingDir := filepath.Join(baseDir, "missing-shared-project")
|
|
e.workspaceBindings.Bind(sharedWorkspaceBindingsKey, "ch1", "shared-project", missingDir)
|
|
|
|
msg := &Message{SessionKey: "test:ch1:user1", Content: "/workspace", ReplyCtx: "ctx"}
|
|
e.handleCommand(p, msg, msg.Content)
|
|
|
|
sent := p.getSent()
|
|
if len(sent) == 0 {
|
|
t.Fatal("expected a reply")
|
|
}
|
|
if !strings.Contains(sent[0], e.i18n.T(MsgWsNoBinding)) {
|
|
t.Fatalf("expected missing shared binding to be treated as no binding, got %q", sent[0])
|
|
}
|
|
}
|
|
|
|
// --- 5. /switch ---
|
|
|
|
type switchableAgent struct {
|
|
stubAgent
|
|
sessions []AgentSessionInfo
|
|
}
|
|
|
|
func (a *switchableAgent) ListSessions(_ context.Context) ([]AgentSessionInfo, error) {
|
|
return a.sessions, nil
|
|
}
|
|
|
|
func TestCmdSwitch_NoArgs_ShowsUsage(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "test"}
|
|
e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish)
|
|
|
|
msg := &Message{SessionKey: "test:ch:user1", Content: "/switch", ReplyCtx: "ctx"}
|
|
e.handleCommand(p, msg, msg.Content)
|
|
|
|
sent := p.getSent()
|
|
foundUsage := false
|
|
for _, s := range sent {
|
|
if strings.Contains(s, "Usage") || strings.Contains(s, "/switch") {
|
|
foundUsage = true
|
|
}
|
|
}
|
|
if !foundUsage {
|
|
t.Fatalf("expected usage reply, got %v", sent)
|
|
}
|
|
}
|
|
|
|
func TestCmdSwitch_ByIndex_SetsSession(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "test"}
|
|
agent := &switchableAgent{
|
|
sessions: []AgentSessionInfo{
|
|
{ID: "sess-aaa", Summary: "First session", MessageCount: 5},
|
|
{ID: "sess-bbb", Summary: "Second session", MessageCount: 3},
|
|
},
|
|
}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
|
|
key := "test:ch:user1"
|
|
|
|
// Pre-create an interactive state to verify cleanup.
|
|
e.interactiveMu.Lock()
|
|
e.interactiveStates[key] = &interactiveState{agentSession: newControllableSession("old")}
|
|
e.interactiveMu.Unlock()
|
|
|
|
msg := &Message{SessionKey: key, Content: "/switch 2", ReplyCtx: "ctx"}
|
|
e.handleCommand(p, msg, msg.Content)
|
|
|
|
sent := p.getSent()
|
|
foundSwitch := false
|
|
for _, s := range sent {
|
|
if strings.Contains(s, "Second session") || strings.Contains(s, "sess-bbb") {
|
|
foundSwitch = true
|
|
}
|
|
}
|
|
if !foundSwitch {
|
|
t.Fatalf("expected switch success reply referencing session 2, got %v", sent)
|
|
}
|
|
|
|
// Verify old interactive state was cleaned up.
|
|
e.interactiveMu.Lock()
|
|
_, exists := e.interactiveStates[key]
|
|
e.interactiveMu.Unlock()
|
|
if exists {
|
|
t.Error("expected old interactive state to be cleaned up after /switch")
|
|
}
|
|
|
|
// Verify session was updated.
|
|
session := e.sessions.GetOrCreateActive(key)
|
|
if id := session.GetAgentSessionID(); id != "sess-bbb" {
|
|
t.Errorf("expected session ID sess-bbb, got %q", id)
|
|
}
|
|
}
|
|
|
|
func TestCmdSwitch_ByIDPrefix(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "test"}
|
|
agent := &switchableAgent{
|
|
sessions: []AgentSessionInfo{
|
|
{ID: "abc-123-def", Summary: "Target session"},
|
|
},
|
|
}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
|
|
msg := &Message{SessionKey: "test:ch:user1", Content: "/switch abc-123", ReplyCtx: "ctx"}
|
|
e.handleCommand(p, msg, msg.Content)
|
|
|
|
sent := p.getSent()
|
|
foundSwitch := false
|
|
for _, s := range sent {
|
|
if strings.Contains(s, "Target session") || strings.Contains(s, "abc-123") {
|
|
foundSwitch = true
|
|
}
|
|
}
|
|
if !foundSwitch {
|
|
t.Fatalf("expected switch by prefix to succeed, got %v", sent)
|
|
}
|
|
}
|
|
|
|
func TestCmdSwitch_NoMatch(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "test"}
|
|
agent := &switchableAgent{
|
|
sessions: []AgentSessionInfo{
|
|
{ID: "sess-111", Summary: "Only session"},
|
|
},
|
|
}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
|
|
msg := &Message{SessionKey: "test:ch:user1", Content: "/switch nonexistent", ReplyCtx: "ctx"}
|
|
e.handleCommand(p, msg, msg.Content)
|
|
|
|
sent := p.getSent()
|
|
foundNoMatch := false
|
|
for _, s := range sent {
|
|
if strings.Contains(s, "nonexistent") {
|
|
foundNoMatch = true
|
|
}
|
|
}
|
|
if !foundNoMatch {
|
|
t.Fatalf("expected no-match reply, got %v", sent)
|
|
}
|
|
}
|
|
|
|
func TestCmdSwitch_ByName(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "test"}
|
|
agent := &switchableAgent{
|
|
sessions: []AgentSessionInfo{
|
|
{ID: "sess-named-1", Summary: "Unnamed"},
|
|
{ID: "sess-named-2", Summary: "My Feature"},
|
|
},
|
|
}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
|
|
key := "test:ch:user1"
|
|
// Set a custom name for the second session.
|
|
e.sessions.SetSessionName("sess-named-2", "feature-branch")
|
|
|
|
msg := &Message{SessionKey: key, Content: "/switch feature-branch", ReplyCtx: "ctx"}
|
|
e.handleCommand(p, msg, msg.Content)
|
|
|
|
sent := p.getSent()
|
|
foundSwitch := false
|
|
for _, s := range sent {
|
|
if strings.Contains(s, "My Feature") || strings.Contains(s, "feature-branch") || strings.Contains(s, "sess-named-2") {
|
|
foundSwitch = true
|
|
}
|
|
}
|
|
if !foundSwitch {
|
|
t.Fatalf("expected switch by name to succeed, got %v", sent)
|
|
}
|
|
}
|
|
|
|
// --- 6. /memory ---
|
|
|
|
type stubMemoryAgentFull struct {
|
|
stubAgent
|
|
projectFile string
|
|
globalFile string
|
|
}
|
|
|
|
func (a *stubMemoryAgentFull) ProjectMemoryFile() string { return a.projectFile }
|
|
func (a *stubMemoryAgentFull) GlobalMemoryFile() string { return a.globalFile }
|
|
|
|
func TestCmdMemory_NotSupported(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "test"}
|
|
e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish)
|
|
|
|
msg := &Message{SessionKey: "test:ch:user1", Content: "/memory", ReplyCtx: "ctx"}
|
|
e.handleCommand(p, msg, msg.Content)
|
|
|
|
sent := p.getSent()
|
|
found := false
|
|
for _, s := range sent {
|
|
if strings.Contains(s, e.i18n.T(MsgMemoryNotSupported)) {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatalf("expected MsgMemoryNotSupported, got %v", sent)
|
|
}
|
|
}
|
|
|
|
func TestCmdMemory_ShowEmpty(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
projectFile := filepath.Join(tmpDir, "MEMORY.md")
|
|
|
|
p := &stubPlatformEngine{n: "test"}
|
|
agent := &stubMemoryAgentFull{projectFile: projectFile, globalFile: ""}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
|
|
msg := &Message{SessionKey: "test:ch:user1", Content: "/memory", ReplyCtx: "ctx"}
|
|
e.handleCommand(p, msg, msg.Content)
|
|
|
|
sent := p.getSent()
|
|
found := false
|
|
for _, s := range sent {
|
|
if strings.Contains(s, projectFile) {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatalf("expected empty memory reply with file path, got %v", sent)
|
|
}
|
|
}
|
|
|
|
func TestCmdMemory_Add_And_Show(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
projectFile := filepath.Join(tmpDir, "MEMORY.md")
|
|
|
|
p := &stubPlatformEngine{n: "test"}
|
|
agent := &stubMemoryAgentFull{projectFile: projectFile, globalFile: ""}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
|
|
// Add memory entry.
|
|
msg := &Message{SessionKey: "test:ch:user1", Content: "/memory add always use gofmt", ReplyCtx: "ctx"}
|
|
e.handleCommand(p, msg, msg.Content)
|
|
|
|
sent := p.getSent()
|
|
foundAdded := false
|
|
for _, s := range sent {
|
|
if strings.Contains(s, projectFile) {
|
|
foundAdded = true
|
|
}
|
|
}
|
|
if !foundAdded {
|
|
t.Fatalf("expected memory added confirmation, got %v", sent)
|
|
}
|
|
|
|
// Verify file content.
|
|
data, err := os.ReadFile(projectFile)
|
|
if err != nil {
|
|
t.Fatalf("failed to read memory file: %v", err)
|
|
}
|
|
if !strings.Contains(string(data), "always use gofmt") {
|
|
t.Fatalf("memory file should contain entry, got %q", string(data))
|
|
}
|
|
|
|
// Show memory.
|
|
p.clearSent()
|
|
msg = &Message{SessionKey: "test:ch:user1", Content: "/memory show", ReplyCtx: "ctx"}
|
|
e.handleCommand(p, msg, msg.Content)
|
|
|
|
sent = p.getSent()
|
|
foundShow := false
|
|
for _, s := range sent {
|
|
if strings.Contains(s, "always use gofmt") {
|
|
foundShow = true
|
|
}
|
|
}
|
|
if !foundShow {
|
|
t.Fatalf("expected memory show to contain the entry, got %v", sent)
|
|
}
|
|
}
|
|
|
|
func TestCmdMemory_Add_EmptyText_ShowsUsage(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
p := &stubPlatformEngine{n: "test"}
|
|
agent := &stubMemoryAgentFull{projectFile: filepath.Join(tmpDir, "M.md")}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
|
|
msg := &Message{SessionKey: "test:ch:user1", Content: "/memory add", ReplyCtx: "ctx"}
|
|
e.handleCommand(p, msg, msg.Content)
|
|
|
|
sent := p.getSent()
|
|
found := false
|
|
for _, s := range sent {
|
|
if strings.Contains(s, e.i18n.T(MsgMemoryAddUsage)[:10]) {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatalf("expected add usage reply, got %v", sent)
|
|
}
|
|
}
|
|
|
|
func TestCmdMemory_Global_Add_And_Show(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
globalFile := filepath.Join(tmpDir, "GLOBAL.md")
|
|
|
|
p := &stubPlatformEngine{n: "test"}
|
|
agent := &stubMemoryAgentFull{projectFile: "", globalFile: globalFile}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
|
|
// Add global memory.
|
|
msg := &Message{SessionKey: "test:ch:user1", Content: "/memory global add prefer structured logging", ReplyCtx: "ctx"}
|
|
e.handleCommand(p, msg, msg.Content)
|
|
|
|
sent := p.getSent()
|
|
foundAdded := false
|
|
for _, s := range sent {
|
|
if strings.Contains(s, globalFile) {
|
|
foundAdded = true
|
|
}
|
|
}
|
|
if !foundAdded {
|
|
t.Fatalf("expected global memory added, got %v", sent)
|
|
}
|
|
|
|
// Show global memory.
|
|
p.clearSent()
|
|
msg = &Message{SessionKey: "test:ch:user1", Content: "/memory global", ReplyCtx: "ctx"}
|
|
e.handleCommand(p, msg, msg.Content)
|
|
|
|
sent = p.getSent()
|
|
foundShow := false
|
|
for _, s := range sent {
|
|
if strings.Contains(s, "prefer structured logging") {
|
|
foundShow = true
|
|
}
|
|
}
|
|
if !foundShow {
|
|
t.Fatalf("expected global show to contain entry, got %v", sent)
|
|
}
|
|
}
|
|
|
|
func TestCmdMemory_Help(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
p := &stubPlatformEngine{n: "test"}
|
|
agent := &stubMemoryAgentFull{projectFile: filepath.Join(tmpDir, "M.md")}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
|
|
msg := &Message{SessionKey: "test:ch:user1", Content: "/memory help", ReplyCtx: "ctx"}
|
|
e.handleCommand(p, msg, msg.Content)
|
|
|
|
sent := p.getSent()
|
|
if len(sent) == 0 {
|
|
t.Fatal("expected help reply")
|
|
}
|
|
}
|
|
|
|
// ── /whoami tests ───────────────────────────────────────────
|
|
|
|
func TestCmdWhoami_ShowsUserID(t *testing.T) {
|
|
e := newTestEngine()
|
|
p := &stubPlatformEngine{n: "telegram"}
|
|
|
|
msg := &Message{
|
|
SessionKey: "telegram:chat123:user456",
|
|
Platform: "telegram",
|
|
UserID: "user456",
|
|
UserName: "Alice",
|
|
ReplyCtx: "ctx",
|
|
Content: "/whoami",
|
|
}
|
|
e.handleCommand(p, msg, msg.Content)
|
|
|
|
if len(p.sent) == 0 {
|
|
t.Fatal("expected /whoami to produce a reply")
|
|
}
|
|
reply := p.sent[0]
|
|
if !strings.Contains(reply, "user456") {
|
|
t.Errorf("expected reply to contain user ID 'user456', got: %s", reply)
|
|
}
|
|
if !strings.Contains(reply, "Alice") {
|
|
t.Errorf("expected reply to contain user name 'Alice', got: %s", reply)
|
|
}
|
|
if !strings.Contains(reply, "telegram") {
|
|
t.Errorf("expected reply to contain platform 'telegram', got: %s", reply)
|
|
}
|
|
if !strings.Contains(reply, "chat123") {
|
|
t.Errorf("expected reply to contain chat ID 'chat123', got: %s", reply)
|
|
}
|
|
if !strings.Contains(reply, "allow_from") {
|
|
t.Errorf("expected reply to mention allow_from usage, got: %s", reply)
|
|
}
|
|
}
|
|
|
|
func TestCmdWhoami_EmptyUserID(t *testing.T) {
|
|
e := newTestEngine()
|
|
p := &stubPlatformEngine{n: "test"}
|
|
|
|
msg := &Message{
|
|
SessionKey: "test:ch1",
|
|
Platform: "test",
|
|
UserID: "",
|
|
ReplyCtx: "ctx",
|
|
Content: "/whoami",
|
|
}
|
|
e.handleCommand(p, msg, msg.Content)
|
|
|
|
if len(p.sent) == 0 {
|
|
t.Fatal("expected /whoami to produce a reply")
|
|
}
|
|
if !strings.Contains(p.sent[0], "(unknown)") {
|
|
t.Errorf("expected '(unknown)' for empty UserID, got: %s", p.sent[0])
|
|
}
|
|
}
|
|
|
|
func TestCmdWhoami_AliasMyID(t *testing.T) {
|
|
e := newTestEngine()
|
|
p := &stubPlatformEngine{n: "test"}
|
|
|
|
msg := &Message{
|
|
SessionKey: "test:ch1:u1",
|
|
Platform: "test",
|
|
UserID: "u1",
|
|
ReplyCtx: "ctx",
|
|
Content: "/myid",
|
|
}
|
|
e.handleCommand(p, msg, msg.Content)
|
|
|
|
if len(p.sent) == 0 {
|
|
t.Fatal("expected /myid alias to produce a reply")
|
|
}
|
|
if !strings.Contains(p.sent[0], "u1") {
|
|
t.Errorf("expected reply to contain user ID, got: %s", p.sent[0])
|
|
}
|
|
}
|
|
|
|
func TestCmdStatus_ShowsUserID(t *testing.T) {
|
|
e := newTestEngine()
|
|
p := &stubPlatformEngine{n: "test"}
|
|
|
|
msg := &Message{
|
|
SessionKey: "test:ch1:myuser123",
|
|
Platform: "test",
|
|
UserID: "myuser123",
|
|
ReplyCtx: "ctx",
|
|
Content: "/status",
|
|
}
|
|
e.handleCommand(p, msg, msg.Content)
|
|
|
|
if len(p.sent) == 0 {
|
|
t.Fatal("expected /status to produce a reply")
|
|
}
|
|
if !strings.Contains(p.sent[0], "myuser123") {
|
|
t.Errorf("expected status to contain user ID 'myuser123', got: %s", p.sent[0])
|
|
}
|
|
}
|
|
|
|
func TestCmdWhoami_CardPlatform(t *testing.T) {
|
|
p := &stubCardPlatform{stubPlatformEngine: stubPlatformEngine{n: "feishu"}}
|
|
agent := &stubModelModeAgent{model: "gpt-4.1", mode: "default"}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangChinese)
|
|
|
|
msg := &Message{
|
|
SessionKey: "feishu:chat999:ou_abc123",
|
|
Platform: "feishu",
|
|
UserID: "ou_abc123",
|
|
UserName: "张三",
|
|
ReplyCtx: "ctx",
|
|
Content: "/whoami",
|
|
}
|
|
e.handleCommand(p, msg, msg.Content)
|
|
|
|
if len(p.repliedCards) == 0 && len(p.sentCards) == 0 {
|
|
t.Fatal("expected /whoami to produce a card")
|
|
}
|
|
|
|
var card *Card
|
|
if len(p.repliedCards) > 0 {
|
|
card = p.repliedCards[0]
|
|
} else {
|
|
card = p.sentCards[0]
|
|
}
|
|
|
|
if card.Header == nil || card.Header.Title == "" {
|
|
t.Fatal("expected card to have a header title")
|
|
}
|
|
|
|
text := card.RenderText()
|
|
if !strings.Contains(text, "ou_abc123") {
|
|
t.Errorf("expected card to contain user ID, got: %s", text)
|
|
}
|
|
if !strings.Contains(text, "张三") {
|
|
t.Errorf("expected card to contain user name, got: %s", text)
|
|
}
|
|
if !strings.Contains(text, "feishu") {
|
|
t.Errorf("expected card to contain platform, got: %s", text)
|
|
}
|
|
if !strings.Contains(text, "chat999") {
|
|
t.Errorf("expected card to contain chat ID, got: %s", text)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Engine method coverage tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestEngine_AddPlatform(t *testing.T) {
|
|
agent := &stubAgent{}
|
|
p1 := &stubPlatformEngine{n: "feishu"}
|
|
p2 := &stubPlatformEngine{n: "telegram"}
|
|
|
|
e := NewEngine("test", agent, []Platform{p1}, "", LangEnglish)
|
|
|
|
// Initially has 1 platform
|
|
if len(e.platforms) != 1 {
|
|
t.Fatalf("expected 1 platform, got %d", len(e.platforms))
|
|
}
|
|
|
|
// Add another platform
|
|
e.AddPlatform(p2)
|
|
|
|
if len(e.platforms) != 2 {
|
|
t.Fatalf("expected 2 platforms, got %d", len(e.platforms))
|
|
}
|
|
|
|
if e.platforms[0].Name() != "feishu" {
|
|
t.Errorf("expected first platform to be feishu, got %s", e.platforms[0].Name())
|
|
}
|
|
if e.platforms[1].Name() != "telegram" {
|
|
t.Errorf("expected second platform to be telegram, got %s", e.platforms[1].Name())
|
|
}
|
|
}
|
|
|
|
func TestEngine_GetAgent(t *testing.T) {
|
|
agent := &stubAgent{}
|
|
p := &stubPlatformEngine{n: "feishu"}
|
|
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
|
|
// GetAgent should return the agent
|
|
got := e.GetAgent()
|
|
if got == nil {
|
|
t.Fatal("expected GetAgent to return agent, got nil")
|
|
}
|
|
if got.Name() != "stub" {
|
|
t.Errorf("expected agent name 'stub', got %s", got.Name())
|
|
}
|
|
}
|
|
|
|
func TestEngine_ClearCommands(t *testing.T) {
|
|
agent := &stubAgent{}
|
|
p := &stubPlatformEngine{n: "feishu"}
|
|
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
|
|
// Add commands from two sources
|
|
e.AddCommand("cmd1", "desc1", "prompt1", "", "", "config")
|
|
e.AddCommand("cmd2", "desc2", "prompt2", "", "", "agent")
|
|
|
|
// Verify commands exist
|
|
if _, ok := e.commands.Resolve("cmd1"); !ok {
|
|
t.Fatal("expected cmd1 to exist")
|
|
}
|
|
|
|
// Clear commands from config source
|
|
e.ClearCommands("config")
|
|
|
|
// cmd1 should be gone, cmd2 should remain
|
|
if _, ok := e.commands.Resolve("cmd1"); ok {
|
|
t.Error("expected cmd1 to be cleared")
|
|
}
|
|
if _, ok := e.commands.Resolve("cmd2"); !ok {
|
|
t.Error("expected cmd2 to remain after clearing config source")
|
|
}
|
|
}
|
|
|
|
func TestEngine_SetAndGetAgent(t *testing.T) {
|
|
agent := &stubAgent{}
|
|
p := &stubPlatformEngine{n: "feishu"}
|
|
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
|
|
// Verify GetAgent returns correct agent
|
|
got := e.GetAgent()
|
|
if got.Name() != "stub" {
|
|
t.Errorf("expected agent name 'stub', got %s", got.Name())
|
|
}
|
|
}
|
|
|
|
func TestEngine_AddCommand(t *testing.T) {
|
|
agent := &stubAgent{}
|
|
p := &stubPlatformEngine{n: "feishu"}
|
|
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
|
|
// Add a command
|
|
e.AddCommand("testcmd", "A test command", "This is a test {{args}}", "", "", "config")
|
|
|
|
// Resolve should find it
|
|
cmd, ok := e.commands.Resolve("testcmd")
|
|
if !ok {
|
|
t.Fatal("expected to resolve testcmd")
|
|
}
|
|
if cmd.Name != "testcmd" {
|
|
t.Errorf("expected command name 'testcmd', got %s", cmd.Name)
|
|
}
|
|
if cmd.Description != "A test command" {
|
|
t.Errorf("expected description 'A test command', got %s", cmd.Description)
|
|
}
|
|
if cmd.Prompt != "This is a test {{args}}" {
|
|
t.Errorf("expected prompt 'This is a test {{args}}', got %s", cmd.Prompt)
|
|
}
|
|
}
|
|
|
|
func TestEngine_AddAlias(t *testing.T) {
|
|
agent := &stubAgent{}
|
|
p := &stubPlatformEngine{n: "feishu"}
|
|
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
|
|
// Add an alias
|
|
e.AddAlias("shortcut", "very-long-command")
|
|
|
|
// Check alias was stored (via internal map)
|
|
// We can verify this through command resolution if shortcut is used as a command
|
|
e.AddCommand("very-long-command", "Long command", "prompt", "", "", "config")
|
|
|
|
// The alias mechanism works through the alias map
|
|
if len(e.aliases) != 1 {
|
|
t.Fatalf("expected 1 alias, got %d", len(e.aliases))
|
|
}
|
|
}
|
|
|
|
func TestEstimateTokens(t *testing.T) {
|
|
// Test with empty entries
|
|
if got := estimateTokens(nil); got != 0 {
|
|
t.Errorf("estimateTokens(nil) = %d, want 0", got)
|
|
}
|
|
|
|
if got := estimateTokens([]HistoryEntry{}); got != 0 {
|
|
t.Errorf("estimateTokens([]) = %d, want 0", got)
|
|
}
|
|
|
|
// Test with entries
|
|
entries := []HistoryEntry{
|
|
{Role: "user", Content: "Hello"},
|
|
{Role: "assistant", Content: "Hi there!"},
|
|
}
|
|
got := estimateTokens(entries)
|
|
if got <= 0 {
|
|
t.Errorf("estimateTokens([Hello, Hi there!]) = %d, want > 0", got)
|
|
}
|
|
|
|
// Test with Chinese characters (should count as 1 token per character)
|
|
entriesChinese := []HistoryEntry{
|
|
{Role: "user", Content: "你好世界"}, // 4 characters
|
|
}
|
|
gotChinese := estimateTokens(entriesChinese)
|
|
// 4 characters / 4 = 1 token, but minimum should account for the formula
|
|
if gotChinese < 1 {
|
|
t.Errorf("estimateTokens([你好世界]) = %d, want >= 1", gotChinese)
|
|
}
|
|
}
|
|
|
|
func TestEstimateTokensWithPendingAssistant(t *testing.T) {
|
|
// Test with pending assistant message
|
|
entries := []HistoryEntry{
|
|
{Role: "user", Content: "Hello"},
|
|
}
|
|
got := estimateTokensWithPendingAssistant(entries, "Thinking...")
|
|
if got <= 0 {
|
|
t.Errorf("estimateTokensWithPendingAssistant([Hello], Thinking...) = %d, want > 0", got)
|
|
}
|
|
|
|
// Pending message should add to the count
|
|
gotWithoutPending := estimateTokensWithPendingAssistant(entries, "")
|
|
gotWithPending := estimateTokensWithPendingAssistant(entries, "Extra content here")
|
|
if gotWithPending <= gotWithoutPending {
|
|
t.Errorf("expected pending message to increase token count")
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Engine setter method coverage tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestEngine_SetterMethods(t *testing.T) {
|
|
agent := &stubAgent{}
|
|
p := &stubPlatformEngine{n: "feishu"}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
|
|
// Test SetSpeechConfig
|
|
e.SetSpeechConfig(SpeechCfg{Enabled: true})
|
|
|
|
// Test SetTTSConfig
|
|
e.SetTTSConfig(&TTSCfg{Voice: "voice-1"})
|
|
|
|
// Test SetTTSSaveFunc (just verify it doesn't panic)
|
|
e.SetTTSSaveFunc(func(text string) error {
|
|
return nil
|
|
})
|
|
|
|
// Test SetLanguageSaveFunc
|
|
e.SetLanguageSaveFunc(func(lang Language) error {
|
|
return nil
|
|
})
|
|
|
|
// Test SetProviderSaveFunc
|
|
e.SetProviderSaveFunc(func(providerName string) error {
|
|
return nil
|
|
})
|
|
|
|
// Test SetProviderAddSaveFunc
|
|
e.SetProviderAddSaveFunc(func(cfg ProviderConfig) error {
|
|
return nil
|
|
})
|
|
|
|
// Test SetProviderRemoveSaveFunc
|
|
e.SetProviderRemoveSaveFunc(func(name string) error {
|
|
return nil
|
|
})
|
|
|
|
// Test SetCommandSaveAddFunc
|
|
e.SetCommandSaveAddFunc(func(name, desc, prompt, exec, workDir string) error {
|
|
return nil
|
|
})
|
|
|
|
// Test SetCommandSaveDelFunc
|
|
e.SetCommandSaveDelFunc(func(name string) error {
|
|
return nil
|
|
})
|
|
|
|
// Test SetDisplaySaveFunc
|
|
e.SetDisplaySaveFunc(func(mode *string, thinkingMessages *bool, thinkMax, toolMax *int, toolMessages *bool) error {
|
|
return nil
|
|
})
|
|
|
|
// Test SetConfigReloadFunc
|
|
e.SetConfigReloadFunc(func() (*ConfigReloadResult, error) {
|
|
return nil, nil
|
|
})
|
|
|
|
// Test SetAliasSaveAddFunc
|
|
e.SetAliasSaveAddFunc(func(alias, cmd string) error {
|
|
return nil
|
|
})
|
|
|
|
// Test SetAliasSaveDelFunc
|
|
e.SetAliasSaveDelFunc(func(alias string) error {
|
|
return nil
|
|
})
|
|
|
|
// Test SetStreamPreviewCfg
|
|
e.SetStreamPreviewCfg(StreamPreviewCfg{Enabled: true})
|
|
|
|
// Verify setters didn't break core functionality
|
|
if e.GetAgent() == nil {
|
|
t.Error("GetAgent should still work after setters")
|
|
}
|
|
}
|
|
|
|
func TestEngine_SetUserRoles(t *testing.T) {
|
|
agent := &stubAgent{}
|
|
p := &stubPlatformEngine{n: "feishu"}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
|
|
mgr := NewUserRoleManager()
|
|
mgr.Configure("member", []RoleInput{
|
|
{Name: "admin", UserIDs: []string{"admin1"}, DisabledCommands: []string{}},
|
|
{Name: "member", UserIDs: []string{"*"}, DisabledCommands: []string{}},
|
|
})
|
|
|
|
e.SetUserRoles(mgr)
|
|
|
|
// Verify the manager was stored
|
|
e.userRolesMu.RLock()
|
|
stored := e.userRoles
|
|
e.userRolesMu.RUnlock()
|
|
if stored == nil {
|
|
t.Error("userRoles manager should be set")
|
|
}
|
|
if stored != mgr {
|
|
t.Error("stored manager should be the same as configured manager")
|
|
}
|
|
}
|
|
|
|
func TestEngine_SetStreamPreviewCfg(t *testing.T) {
|
|
agent := &stubAgent{}
|
|
p := &stubPlatformEngine{n: "feishu"}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
|
|
cfg := StreamPreviewCfg{Enabled: true, IntervalMs: 1000, MinDeltaChars: 10}
|
|
e.SetStreamPreviewCfg(cfg)
|
|
|
|
if e.streamPreview.Enabled != true {
|
|
t.Error("streamPreview.Enabled should be true")
|
|
}
|
|
if e.streamPreview.IntervalMs != 1000 {
|
|
t.Error("streamPreview.IntervalMs mismatch")
|
|
}
|
|
}
|
|
|
|
func TestEngine_AddPlatform_Multiple(t *testing.T) {
|
|
agent := &stubAgent{}
|
|
p1 := &stubPlatformEngine{n: "feishu"}
|
|
e := NewEngine("test", agent, []Platform{p1}, "", LangEnglish)
|
|
|
|
p2 := &stubPlatformEngine{n: "telegram"}
|
|
p3 := &stubPlatformEngine{n: "discord"}
|
|
|
|
e.AddPlatform(p2)
|
|
e.AddPlatform(p3)
|
|
|
|
if len(e.platforms) != 3 {
|
|
t.Fatalf("expected 3 platforms, got %d", len(e.platforms))
|
|
}
|
|
}
|
|
|
|
func TestExecuteCronJob_ResolvesCronReplyTarget(t *testing.T) {
|
|
dir := t.TempDir()
|
|
store, err := NewCronStore(dir)
|
|
if err != nil {
|
|
t.Fatalf("NewCronStore() error = %v", err)
|
|
}
|
|
scheduler := NewCronScheduler(store)
|
|
|
|
platform := &stubCronReplyTargetPlatform{
|
|
stubPlatformEngine: stubPlatformEngine{n: "discord"},
|
|
}
|
|
agentSession := newResultAgentSession("cron complete")
|
|
agent := &resultAgent{session: agentSession}
|
|
|
|
e := NewEngine("test", agent, []Platform{platform}, "", LangEnglish)
|
|
defer e.cancel()
|
|
e.cronScheduler = scheduler
|
|
|
|
job := &CronJob{
|
|
ID: "job-1",
|
|
SessionKey: "discord:channel-1:user-1",
|
|
Prompt: "summarize activity",
|
|
Description: "Daily summary",
|
|
}
|
|
if err := store.Add(job); err != nil {
|
|
t.Fatalf("store.Add() error = %v", err)
|
|
}
|
|
|
|
if err := e.ExecuteCronJob(job); err != nil {
|
|
t.Fatalf("ExecuteCronJob() error = %v", err)
|
|
}
|
|
if platform.resolvedSessionKey != "discord:channel-1:user-1" {
|
|
t.Fatalf("ResolveCronReplyTarget sessionKey = %q, want base session key", platform.resolvedSessionKey)
|
|
}
|
|
if platform.resolveTitle != "Daily summary" {
|
|
t.Fatalf("ResolveCronReplyTarget title = %q, want Daily summary", platform.resolveTitle)
|
|
}
|
|
|
|
sent := platform.getSent()
|
|
if len(sent) != 2 {
|
|
t.Fatalf("sent messages = %d, want 2", len(sent))
|
|
}
|
|
if sent[0] != "⏰ Daily summary" {
|
|
t.Fatalf("sent[0] = %q, want cron start notice", sent[0])
|
|
}
|
|
if sent[1] != "cron complete" {
|
|
t.Fatalf("sent[1] = %q, want final result", sent[1])
|
|
}
|
|
|
|
if got := len(e.sessions.ListSessions("discord:thread-fresh")); got != 0 {
|
|
t.Fatalf("fresh session count = %d, want 0 for reuse mode", got)
|
|
}
|
|
if got := len(e.sessions.ListSessions("discord:channel-1:user-1")); got != 1 {
|
|
t.Fatalf("base session count = %d, want 1", got)
|
|
}
|
|
if job.SessionKey != "discord:channel-1:user-1" {
|
|
t.Fatalf("job.SessionKey = %q, want unchanged base session key", job.SessionKey)
|
|
}
|
|
stored := store.Get("job-1")
|
|
if stored == nil || stored.SessionKey != "discord:channel-1:user-1" {
|
|
t.Fatalf("stored sessionKey = %#v, want unchanged base session key", stored)
|
|
}
|
|
|
|
if len(agentSession.sentPrompts) != 1 || !strings.Contains(agentSession.sentPrompts[0], "summarize activity") {
|
|
t.Fatalf("agent prompts = %#v, want prompt containing summarize activity", agentSession.sentPrompts)
|
|
}
|
|
}
|
|
|
|
func TestExecuteCronJob_WorkspacePrefixedSessionKey(t *testing.T) {
|
|
dir := t.TempDir()
|
|
store, err := NewCronStore(dir)
|
|
if err != nil {
|
|
t.Fatalf("NewCronStore() error = %v", err)
|
|
}
|
|
scheduler := NewCronScheduler(store)
|
|
|
|
platform := &stubCronReplyTargetPlatform{
|
|
stubPlatformEngine: stubPlatformEngine{n: "slack"},
|
|
}
|
|
agentSession := newResultAgentSession("done")
|
|
agent := &resultAgent{session: agentSession}
|
|
|
|
e := NewEngine("test", agent, []Platform{platform}, "", LangEnglish)
|
|
defer e.cancel()
|
|
e.cronScheduler = scheduler
|
|
|
|
// Simulate a session key that was stored with a workspace prefix
|
|
// (as happens in multi-workspace mode).
|
|
prefixedKey := "/home/user/workspace/myproject:slack:C123:U456"
|
|
job := &CronJob{
|
|
ID: "job-ws",
|
|
SessionKey: prefixedKey,
|
|
Prompt: "daily standup",
|
|
Description: "Standup",
|
|
}
|
|
if err := store.Add(job); err != nil {
|
|
t.Fatalf("store.Add() error = %v", err)
|
|
}
|
|
|
|
if err := e.ExecuteCronJob(job); err != nil {
|
|
t.Fatalf("ExecuteCronJob() with workspace-prefixed key error = %v", err)
|
|
}
|
|
|
|
// The platform should have received the cron start notice and agent reply.
|
|
sent := platform.getSent()
|
|
if len(sent) < 1 {
|
|
t.Fatalf("expected at least one message sent to platform, got %d", len(sent))
|
|
}
|
|
|
|
// Stored session key must remain unchanged.
|
|
if job.SessionKey != prefixedKey {
|
|
t.Fatalf("job.SessionKey = %q, want unchanged %q", job.SessionKey, prefixedKey)
|
|
}
|
|
}
|
|
|
|
func TestExecuteCronJob_ExpandsSlashSkillPrompt(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
prompt string
|
|
wantContains []string
|
|
wantNotExpand bool // true = expected to be passed through literally
|
|
}{
|
|
{
|
|
name: "registered skill expands with args",
|
|
prompt: "/daily-brief today",
|
|
wantContains: []string{"## Skill:", "daily-brief", "Prompt body", "today"},
|
|
},
|
|
{
|
|
name: "registered skill expands with no args",
|
|
prompt: "/daily-brief",
|
|
wantContains: []string{"## Skill:", "daily-brief", "Prompt body"},
|
|
},
|
|
{
|
|
name: "unknown slash command passes through literally",
|
|
prompt: "/no-such-skill arg",
|
|
wantContains: []string{"/no-such-skill arg"},
|
|
wantNotExpand: true,
|
|
},
|
|
{
|
|
name: "non-slash prompt passes through unchanged",
|
|
prompt: "summarize today's activity",
|
|
wantContains: []string{"summarize today's activity"},
|
|
wantNotExpand: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
skillRoot := t.TempDir()
|
|
writeSkillFile(t, filepath.Join(skillRoot, "daily-brief", "SKILL.md"), "Daily brief skill")
|
|
|
|
dir := t.TempDir()
|
|
store, err := NewCronStore(dir)
|
|
if err != nil {
|
|
t.Fatalf("NewCronStore() error = %v", err)
|
|
}
|
|
|
|
platform := &stubCronReplyTargetPlatform{
|
|
stubPlatformEngine: stubPlatformEngine{n: "discord"},
|
|
}
|
|
agentSession := newResultAgentSession("ok")
|
|
agent := &resultAgent{session: agentSession}
|
|
|
|
e := NewEngine("test", agent, []Platform{platform}, "", LangEnglish)
|
|
defer e.cancel()
|
|
e.skills.SetDirs([]string{skillRoot})
|
|
|
|
job := &CronJob{
|
|
ID: "job-skill",
|
|
SessionKey: "discord:channel-1:user-1",
|
|
Prompt: tt.prompt,
|
|
}
|
|
if err := store.Add(job); err != nil {
|
|
t.Fatalf("store.Add() error = %v", err)
|
|
}
|
|
|
|
if err := e.ExecuteCronJob(job); err != nil {
|
|
t.Fatalf("ExecuteCronJob() error = %v", err)
|
|
}
|
|
|
|
if len(agentSession.sentPrompts) != 1 {
|
|
t.Fatalf("sentPrompts = %d, want 1: %#v", len(agentSession.sentPrompts), agentSession.sentPrompts)
|
|
}
|
|
got := agentSession.sentPrompts[0]
|
|
for _, want := range tt.wantContains {
|
|
if !strings.Contains(got, want) {
|
|
t.Errorf("agent prompt does not contain %q\ngot: %s", want, got)
|
|
}
|
|
}
|
|
if tt.wantNotExpand && strings.Contains(got, "## Skill Instructions:") {
|
|
t.Errorf("expected raw passthrough, but prompt was skill-expanded\ngot: %s", got)
|
|
}
|
|
// Stored prompt must not be rewritten on the job itself.
|
|
if job.Prompt != tt.prompt {
|
|
t.Errorf("job.Prompt = %q, want %q (unchanged)", job.Prompt, tt.prompt)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestExtractSessionKeyParts(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
sessionKey string
|
|
wantPlatform string
|
|
wantChannel string
|
|
wantKey string
|
|
wantUser string
|
|
}{
|
|
{"full format", "feishu:channel123:user456", "feishu", "channel123", "feishu:channel123", "user456"},
|
|
{"platform and channel only", "telegram:987654321", "telegram", "987654321", "telegram:987654321", ""},
|
|
{"no colons", "simplekey", "simplekey", "", "", ""},
|
|
{"single colon", "discord:channel1", "discord", "channel1", "discord:channel1", ""},
|
|
{"empty string", "", "", "", "", ""},
|
|
{"just platform colon user", "line::user1", "line", "", "", "user1"},
|
|
{"four-segment with type tag", "dingtalk:g:cidXXX:staff1", "dingtalk", "cidXXX", "dingtalk:cidXXX", "staff1"},
|
|
{"three-segment with type tag (shared session)", "dingtalk:g:cidZZZ", "dingtalk", "cidZZZ", "dingtalk:cidZZZ", ""},
|
|
{"three-segment qq group", "qq:g:12345", "qq", "12345", "qq:12345", ""},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
gotPlatform := extractPlatformName(tt.sessionKey)
|
|
if gotPlatform != tt.wantPlatform {
|
|
t.Errorf("extractPlatformName(%q) = %q, want %q", tt.sessionKey, gotPlatform, tt.wantPlatform)
|
|
}
|
|
|
|
gotChannel := extractChannelID(tt.sessionKey)
|
|
if gotChannel != tt.wantChannel {
|
|
t.Errorf("extractChannelID(%q) = %q, want %q", tt.sessionKey, gotChannel, tt.wantChannel)
|
|
}
|
|
|
|
gotKey := extractWorkspaceChannelKey(tt.sessionKey)
|
|
if gotKey != tt.wantKey {
|
|
t.Errorf("extractWorkspaceChannelKey(%q) = %q, want %q", tt.sessionKey, gotKey, tt.wantKey)
|
|
}
|
|
|
|
gotUser := extractUserID(tt.sessionKey)
|
|
if gotUser != tt.wantUser {
|
|
t.Errorf("extractUserID(%q) = %q, want %q", tt.sessionKey, gotUser, tt.wantUser)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSetObserveConfig(t *testing.T) {
|
|
e := NewEngine("test", &stubAgent{}, nil, "", LangEnglish)
|
|
e.SetObserveConfig("/tmp/test-project", "slack:C123:U456")
|
|
if !e.observeEnabled {
|
|
t.Fatal("observe should be enabled")
|
|
}
|
|
if e.observeProjectDir != "/tmp/test-project" {
|
|
t.Fatalf("unexpected project dir: %s", e.observeProjectDir)
|
|
}
|
|
}
|
|
|
|
func TestObserveStartsOnlyWithSlack(t *testing.T) {
|
|
stub := &stubPlatformWithObserve{stubPlatform: stubPlatform{n: "slack"}}
|
|
e := NewEngine("test", &stubAgent{}, []Platform{stub}, "", LangEnglish)
|
|
e.SetObserveConfig("/tmp/fake-project", "slack:C123:U456")
|
|
|
|
target := e.findObserverTarget()
|
|
if target == nil {
|
|
t.Fatal("expected to find observer target for Slack")
|
|
}
|
|
}
|
|
|
|
func TestObserveNoTargetWithoutSlack(t *testing.T) {
|
|
stub := &stubPlatform{n: "telegram"}
|
|
e := NewEngine("test", &stubAgent{}, []Platform{stub}, "", LangEnglish)
|
|
e.SetObserveConfig("/tmp/fake-project", "slack:C123:U456")
|
|
|
|
target := e.findObserverTarget()
|
|
if target != nil {
|
|
t.Fatal("expected no observer target without Slack")
|
|
}
|
|
}
|
|
|
|
type stubPlatformWithObserve struct {
|
|
stubPlatform
|
|
}
|
|
|
|
func (s *stubPlatformWithObserve) SendObservation(_ context.Context, _, _ string) error {
|
|
return nil
|
|
}
|
|
|
|
// --- Instant Reply tests ---
|
|
|
|
// stubStreamingCardPlatform simulates a platform that supports StreamingCardPlatform
|
|
// (e.g. DingTalk with AI Card configured), so instant reply should be skipped.
|
|
type stubStreamingCardPlatform struct {
|
|
stubPlatformEngine
|
|
cardCreated bool
|
|
cardFail bool // when true, CreateStreamingCard returns an error
|
|
}
|
|
|
|
func (p *stubStreamingCardPlatform) CreateStreamingCard(_ context.Context, _ any) (StreamingCard, error) {
|
|
if p.cardFail {
|
|
return nil, fmt.Errorf("stub: card_template_id not configured")
|
|
}
|
|
p.cardCreated = true
|
|
return &stubStreamingCard{}, nil
|
|
}
|
|
|
|
// stubStreamingCard is a minimal StreamingCard for tests.
|
|
type stubStreamingCard struct{}
|
|
|
|
func (c *stubStreamingCard) Update(_ context.Context, _ string) error { return nil }
|
|
func (c *stubStreamingCard) Finalize(_ context.Context, _ string) error { return nil }
|
|
func (c *stubStreamingCard) Failed() bool { return false }
|
|
|
|
func TestHandleMessage_InstantReply_SendsConfirmationWhenEnabled(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "test"}
|
|
agentSession := newResultAgentSession("agent reply")
|
|
agent := &resultAgent{session: agentSession}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
e.SetInstantReply(InstantReplyCfg{Enabled: true, Content: "🤔 Thinking..."})
|
|
|
|
msg := &Message{
|
|
SessionKey: "test:user1",
|
|
Platform: "test",
|
|
UserID: "u1",
|
|
UserName: "user",
|
|
Content: "hello",
|
|
ReplyCtx: "ctx",
|
|
}
|
|
e.handleMessage(p, msg)
|
|
|
|
// Wait for async processing to complete
|
|
deadline := time.After(2 * time.Second)
|
|
for {
|
|
sent := p.getSent()
|
|
if len(sent) >= 2 {
|
|
break
|
|
}
|
|
select {
|
|
case <-deadline:
|
|
t.Fatalf("timed out waiting for replies, got: %v", p.getSent())
|
|
default:
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
}
|
|
|
|
sent := p.getSent()
|
|
if sent[0] != "🤔 Thinking..." {
|
|
t.Fatalf("first reply = %q, want instant reply '🤔 Thinking...'", sent[0])
|
|
}
|
|
}
|
|
|
|
func TestHandleMessage_InstantReply_UsesDefaultI18nWhenContentEmpty(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "test"}
|
|
agentSession := newResultAgentSession("agent reply")
|
|
agent := &resultAgent{session: agentSession}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangChinese)
|
|
e.SetInstantReply(InstantReplyCfg{Enabled: true}) // Content empty → use MsgStarting
|
|
|
|
msg := &Message{
|
|
SessionKey: "test:user1",
|
|
Platform: "test",
|
|
UserID: "u1",
|
|
UserName: "user",
|
|
Content: "hello",
|
|
ReplyCtx: "ctx",
|
|
}
|
|
e.handleMessage(p, msg)
|
|
|
|
deadline := time.After(2 * time.Second)
|
|
for {
|
|
sent := p.getSent()
|
|
if len(sent) >= 2 {
|
|
break
|
|
}
|
|
select {
|
|
case <-deadline:
|
|
t.Fatalf("timed out waiting for replies, got: %v", p.getSent())
|
|
default:
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
}
|
|
|
|
sent := p.getSent()
|
|
if sent[0] != "⏳ 处理中..." {
|
|
t.Fatalf("first reply = %q, want i18n default '⏳ 处理中...'", sent[0])
|
|
}
|
|
}
|
|
|
|
func TestHandleMessage_InstantReply_SkippedWhenDisabled(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "test"}
|
|
agentSession := newResultAgentSession("agent reply")
|
|
agent := &resultAgent{session: agentSession}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
// InstantReply not set (default: disabled)
|
|
|
|
msg := &Message{
|
|
SessionKey: "test:user1",
|
|
Platform: "test",
|
|
UserID: "u1",
|
|
UserName: "user",
|
|
Content: "hello",
|
|
ReplyCtx: "ctx",
|
|
}
|
|
e.handleMessage(p, msg)
|
|
|
|
deadline := time.After(2 * time.Second)
|
|
for {
|
|
sent := p.getSent()
|
|
if len(sent) >= 1 {
|
|
break
|
|
}
|
|
select {
|
|
case <-deadline:
|
|
t.Fatalf("timed out waiting for replies, got: %v", p.getSent())
|
|
default:
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
}
|
|
|
|
sent := p.getSent()
|
|
// The only reply should be the agent result, no instant reply
|
|
if len(sent) != 1 {
|
|
t.Fatalf("sent messages = %d, want exactly 1 (no instant reply), got: %v", len(sent), sent)
|
|
}
|
|
if sent[0] != "agent reply" {
|
|
t.Fatalf("first reply = %q, want 'agent reply'", sent[0])
|
|
}
|
|
}
|
|
|
|
func TestHandleMessage_InstantReply_SkippedForStreamingCardPlatform(t *testing.T) {
|
|
p := &stubStreamingCardPlatform{stubPlatformEngine: stubPlatformEngine{n: "dingtalk"}}
|
|
agentSession := newResultAgentSession("agent reply")
|
|
agent := &resultAgent{session: agentSession}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
e.SetInstantReply(InstantReplyCfg{Enabled: true, Content: "🤔 Thinking..."})
|
|
|
|
msg := &Message{
|
|
SessionKey: "dingtalk:user1",
|
|
Platform: "dingtalk",
|
|
UserID: "u1",
|
|
UserName: "user",
|
|
Content: "hello",
|
|
ReplyCtx: "ctx",
|
|
}
|
|
e.handleMessage(p, msg)
|
|
|
|
// When streaming card succeeds, the agent reply goes through streamCard.Finalize,
|
|
// not p.Send. Wait briefly then verify no instant reply was sent via p.Send.
|
|
time.Sleep(500 * time.Millisecond)
|
|
|
|
sent := p.getSent()
|
|
for _, s := range sent {
|
|
if s == "🤔 Thinking..." {
|
|
t.Fatalf("instant reply should be skipped for StreamingCardPlatform, but got: %v", sent)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestHandleMessage_InstantReply_SentWhenStreamingCardFails(t *testing.T) {
|
|
p := &stubStreamingCardPlatform{
|
|
stubPlatformEngine: stubPlatformEngine{n: "dingtalk"},
|
|
cardFail: true,
|
|
}
|
|
agentSession := newResultAgentSession("agent reply")
|
|
agent := &resultAgent{session: agentSession}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
e.SetInstantReply(InstantReplyCfg{Enabled: true, Content: "🤔 Thinking..."})
|
|
|
|
msg := &Message{
|
|
SessionKey: "dingtalk:user1",
|
|
Platform: "dingtalk",
|
|
UserID: "u1",
|
|
UserName: "user",
|
|
Content: "hello",
|
|
ReplyCtx: "ctx",
|
|
}
|
|
e.handleMessage(p, msg)
|
|
|
|
deadline := time.After(2 * time.Second)
|
|
for {
|
|
sent := p.getSent()
|
|
if len(sent) >= 2 {
|
|
break
|
|
}
|
|
select {
|
|
case <-deadline:
|
|
t.Fatalf("timed out waiting for replies, got: %v", p.getSent())
|
|
default:
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
}
|
|
|
|
sent := p.getSent()
|
|
if sent[0] != "🤔 Thinking..." {
|
|
t.Fatalf("first reply = %q, want instant reply when card creation fails", sent[0])
|
|
}
|
|
}
|
|
|
|
func TestHandleMessage_InstantReply_SkippedForSlashCommands(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "test"}
|
|
e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish)
|
|
e.SetInstantReply(InstantReplyCfg{Enabled: true, Content: "🤔 Thinking..."})
|
|
|
|
msg := &Message{
|
|
SessionKey: "test:user1",
|
|
Platform: "test",
|
|
UserID: "u1",
|
|
UserName: "user",
|
|
Content: "/help",
|
|
ReplyCtx: "ctx",
|
|
}
|
|
e.handleMessage(p, msg)
|
|
|
|
// Give a short time for any async processing
|
|
time.Sleep(200 * time.Millisecond)
|
|
|
|
sent := p.getSent()
|
|
for _, s := range sent {
|
|
if s == "🤔 Thinking..." {
|
|
t.Fatalf("instant reply should be skipped for slash commands, but got: %v", sent)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ===========================================================================
|
|
// Unsolicited events tests
|
|
// ===========================================================================
|
|
|
|
// waitForPlatformSend polls until the platform has at least n messages or timeout.
|
|
func waitForPlatformSend(p *stubPlatformEngine, n int, timeout time.Duration) []string {
|
|
deadline := time.After(timeout)
|
|
for {
|
|
sent := p.getSent()
|
|
if len(sent) >= n {
|
|
return sent
|
|
}
|
|
select {
|
|
case <-deadline:
|
|
return sent
|
|
case <-time.After(10 * time.Millisecond):
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestUnsolicitedReader_RelaysEventResult verifies that the unsolicited reader
|
|
// goroutine relays EventResult content to the platform.
|
|
func TestUnsolicitedReader_RelaysEventResult(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "test"}
|
|
sess := newControllableSession("unsol-relay")
|
|
e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish)
|
|
defer e.Stop()
|
|
|
|
sessions := e.sessions
|
|
session := sessions.GetOrCreateActive("test:ch1:u1")
|
|
|
|
state := &interactiveState{
|
|
agentSession: sess,
|
|
platform: p,
|
|
replyCtx: "ctx",
|
|
eventsNeedResync: false,
|
|
}
|
|
iKey := "test:ch1:u1"
|
|
e.interactiveMu.Lock()
|
|
e.interactiveStates[iKey] = state
|
|
e.interactiveMu.Unlock()
|
|
|
|
e.startUnsolicitedReader(state, session, sessions, iKey, "")
|
|
defer e.stopUnsolicitedReader(state)
|
|
|
|
// Send only EventResult (no EventText) to ensure the reader uses EventResult.Content.
|
|
sess.events <- Event{Type: EventResult, Content: "All 5 campaigns created successfully"}
|
|
|
|
sent := waitForPlatformSend(p, 1, 5*time.Second)
|
|
if len(sent) == 0 {
|
|
t.Fatal("expected unsolicited reader to relay EventResult to platform, got nothing")
|
|
}
|
|
found := false
|
|
for _, s := range sent {
|
|
if strings.Contains(s, "5 campaigns created successfully") {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("expected relayed content to contain '5 campaigns created successfully', got %v", sent)
|
|
}
|
|
|
|
// Verify eventsNeedResync is false after clean EventResult.
|
|
state.mu.Lock()
|
|
resync := state.eventsNeedResync
|
|
state.mu.Unlock()
|
|
if resync {
|
|
t.Error("expected eventsNeedResync=false after clean unsolicited EventResult")
|
|
}
|
|
}
|
|
|
|
// TestUnsolicitedReader_StopsOnCancel verifies that stopUnsolicitedReader
|
|
// cleanly stops the reader goroutine and waits for it to exit.
|
|
func TestUnsolicitedReader_StopsOnCancel(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "test"}
|
|
sess := newControllableSession("unsol-cancel")
|
|
e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish)
|
|
defer e.Stop()
|
|
|
|
sessions := e.sessions
|
|
session := sessions.GetOrCreateActive("test:ch1:u1")
|
|
|
|
state := &interactiveState{
|
|
agentSession: sess,
|
|
platform: p,
|
|
replyCtx: "ctx",
|
|
eventsNeedResync: false,
|
|
}
|
|
|
|
e.startUnsolicitedReader(state, session, sessions, "test:ch1:u1", "")
|
|
|
|
// Capture the done channel before stop nils it.
|
|
state.mu.Lock()
|
|
doneCh := state.unsolicitedDone
|
|
state.mu.Unlock()
|
|
|
|
if doneCh == nil {
|
|
t.Fatal("expected unsolicitedDone to be set after startUnsolicitedReader")
|
|
}
|
|
|
|
start := time.Now()
|
|
e.stopUnsolicitedReader(state)
|
|
elapsed := time.Since(start)
|
|
|
|
if elapsed > 3*time.Second {
|
|
t.Errorf("stopUnsolicitedReader took too long: %v", elapsed)
|
|
}
|
|
|
|
// Verify the goroutine actually exited by checking the done channel.
|
|
select {
|
|
case <-doneCh:
|
|
// Good — goroutine exited.
|
|
default:
|
|
t.Error("expected unsolicited reader goroutine to have exited (done channel not closed)")
|
|
}
|
|
}
|
|
|
|
// TestUnsolicitedReader_SetsResyncOnChannelClose verifies that when the agent
|
|
// process exits (events channel closed), eventsNeedResync is set to true.
|
|
func TestUnsolicitedReader_SetsResyncOnChannelClose(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "test"}
|
|
sess := newControllableSession("unsol-close")
|
|
e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish)
|
|
defer e.Stop()
|
|
|
|
sessions := e.sessions
|
|
session := sessions.GetOrCreateActive("test:close:u1")
|
|
|
|
state := &interactiveState{
|
|
agentSession: sess,
|
|
platform: p,
|
|
replyCtx: "ctx",
|
|
eventsNeedResync: false,
|
|
}
|
|
|
|
e.startUnsolicitedReader(state, session, sessions, "test:close:u1", "")
|
|
|
|
state.mu.Lock()
|
|
doneCh := state.unsolicitedDone
|
|
state.mu.Unlock()
|
|
|
|
// Close the events channel (simulates agent process exit).
|
|
close(sess.events)
|
|
|
|
// Wait for reader to detect the close.
|
|
select {
|
|
case <-doneCh:
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("unsolicited reader did not exit after channel close")
|
|
}
|
|
|
|
state.mu.Lock()
|
|
resync := state.eventsNeedResync
|
|
state.mu.Unlock()
|
|
if !resync {
|
|
t.Error("expected eventsNeedResync=true after channel close")
|
|
}
|
|
}
|
|
|
|
// TestUnsolicitedReader_SetsResyncOnEventError verifies that EventError
|
|
// sets eventsNeedResync to true and relays the error.
|
|
func TestUnsolicitedReader_SetsResyncOnEventError(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "test"}
|
|
sess := newControllableSession("unsol-error")
|
|
e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish)
|
|
defer e.Stop()
|
|
|
|
sessions := e.sessions
|
|
session := sessions.GetOrCreateActive("test:error:u1")
|
|
|
|
state := &interactiveState{
|
|
agentSession: sess,
|
|
platform: p,
|
|
replyCtx: "ctx",
|
|
eventsNeedResync: false,
|
|
}
|
|
|
|
e.startUnsolicitedReader(state, session, sessions, "test:error:u1", "")
|
|
|
|
state.mu.Lock()
|
|
doneCh := state.unsolicitedDone
|
|
state.mu.Unlock()
|
|
|
|
// Send an error event.
|
|
sess.events <- Event{Type: EventError, Error: errors.New("something broke")}
|
|
|
|
select {
|
|
case <-doneCh:
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("unsolicited reader did not exit after EventError")
|
|
}
|
|
|
|
state.mu.Lock()
|
|
resync := state.eventsNeedResync
|
|
state.mu.Unlock()
|
|
if !resync {
|
|
t.Error("expected eventsNeedResync=true after EventError")
|
|
}
|
|
|
|
// Verify error was relayed to platform.
|
|
sent := p.getSent()
|
|
found := false
|
|
for _, s := range sent {
|
|
if strings.Contains(s, "something broke") {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("expected error to be relayed to platform, got %v", sent)
|
|
}
|
|
}
|
|
|
|
// TestUnsolicitedReader_PermissionDeny verifies that unsolicited permission
|
|
// requests are denied when approveAll is false.
|
|
func TestUnsolicitedReader_PermissionDeny(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "test"}
|
|
e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish)
|
|
defer e.Stop()
|
|
|
|
sess := newControllableSession("unsol-perm")
|
|
permRecorder := &permRecordingSession{
|
|
controllableAgentSession: *sess,
|
|
}
|
|
|
|
sessions := e.sessions
|
|
session := sessions.GetOrCreateActive("test:perm:u1")
|
|
|
|
state := &interactiveState{
|
|
agentSession: permRecorder,
|
|
platform: p,
|
|
replyCtx: "ctx",
|
|
eventsNeedResync: false,
|
|
approveAll: false,
|
|
}
|
|
|
|
e.startUnsolicitedReader(state, session, sessions, "test:perm:u1", "")
|
|
|
|
// Send a permission request.
|
|
permRecorder.events <- Event{
|
|
Type: EventPermissionRequest,
|
|
RequestID: "req-1",
|
|
ToolName: "Bash",
|
|
}
|
|
|
|
// Wait for the response.
|
|
deadline := time.After(5 * time.Second)
|
|
for {
|
|
permRecorder.mu.Lock()
|
|
calls := permRecorder.permCalls
|
|
permRecorder.mu.Unlock()
|
|
if calls > 0 {
|
|
break
|
|
}
|
|
select {
|
|
case <-deadline:
|
|
t.Fatal("timed out waiting for permission response")
|
|
case <-time.After(10 * time.Millisecond):
|
|
}
|
|
}
|
|
|
|
permRecorder.mu.Lock()
|
|
result := permRecorder.lastPermResult
|
|
permRecorder.mu.Unlock()
|
|
|
|
if result.Behavior != "deny" {
|
|
t.Errorf("expected deny, got %q", result.Behavior)
|
|
}
|
|
|
|
e.stopUnsolicitedReader(state)
|
|
}
|
|
|
|
// permRecordingSession wraps controllableAgentSession and records permission responses.
|
|
type permRecordingSession struct {
|
|
controllableAgentSession
|
|
mu sync.Mutex
|
|
permCalls int
|
|
lastPermResult PermissionResult
|
|
}
|
|
|
|
func (s *permRecordingSession) RespondPermission(_ string, res PermissionResult) error {
|
|
s.mu.Lock()
|
|
s.permCalls++
|
|
s.lastPermResult = res
|
|
s.mu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
// TestEventsNeedResync_DefaultTrue verifies that new interactiveState
|
|
// constructors set eventsNeedResync to true.
|
|
func TestEventsNeedResync_DefaultTrue(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "test"}
|
|
e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish)
|
|
defer e.Stop()
|
|
|
|
e.ensureInteractiveStateForQueueing("key1", p, "ctx")
|
|
|
|
e.interactiveMu.Lock()
|
|
state := e.interactiveStates["key1"]
|
|
e.interactiveMu.Unlock()
|
|
|
|
if state == nil {
|
|
t.Fatal("expected state to be created")
|
|
}
|
|
if !state.eventsNeedResync {
|
|
t.Error("expected eventsNeedResync to be true for new state")
|
|
}
|
|
}
|
|
|
|
// TestEventsNeedResync_ClearedOnCleanResult verifies that eventsNeedResync
|
|
// is cleared after a clean EventResult in processInteractiveEvents.
|
|
func TestEventsNeedResync_ClearedOnCleanResult(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "test"}
|
|
sess := newControllableSession("resync-clean")
|
|
e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish)
|
|
defer e.Stop()
|
|
|
|
sessions := e.sessions
|
|
session := sessions.GetOrCreateActive("test:resync:u1")
|
|
session.TryLock()
|
|
|
|
state := &interactiveState{
|
|
agentSession: sess,
|
|
platform: p,
|
|
replyCtx: "ctx",
|
|
eventsNeedResync: true,
|
|
}
|
|
|
|
// Send EventResult to trigger clean exit.
|
|
go func() {
|
|
sess.events <- Event{Type: EventResult, Content: "done"}
|
|
}()
|
|
|
|
sendDone := make(chan error, 1)
|
|
sendDone <- nil
|
|
e.processInteractiveEvents(state, session, sessions, "test:resync:u1", "", time.Now(), nil, sendDone, "ctx")
|
|
|
|
state.mu.Lock()
|
|
resync := state.eventsNeedResync
|
|
state.mu.Unlock()
|
|
|
|
if resync {
|
|
t.Error("expected eventsNeedResync to be false after clean EventResult")
|
|
}
|
|
}
|
|
|
|
// TestCleanupInteractiveState_StopsUnsolicitedReader verifies that cleanup
|
|
// stops the unsolicited reader goroutine and waits for it to exit.
|
|
func TestCleanupInteractiveState_StopsUnsolicitedReader(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "test"}
|
|
sess := newControllableSession("cleanup-unsol")
|
|
e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish)
|
|
defer e.Stop()
|
|
|
|
sessions := e.sessions
|
|
session := sessions.GetOrCreateActive("test:cleanup:u1")
|
|
|
|
state := &interactiveState{
|
|
agentSession: sess,
|
|
platform: p,
|
|
replyCtx: "ctx",
|
|
eventsNeedResync: false,
|
|
}
|
|
iKey := "test:cleanup:u1"
|
|
e.interactiveMu.Lock()
|
|
e.interactiveStates[iKey] = state
|
|
e.interactiveMu.Unlock()
|
|
|
|
e.startUnsolicitedReader(state, session, sessions, iKey, "")
|
|
|
|
// Capture the done channel before cleanup nils it.
|
|
state.mu.Lock()
|
|
doneCh := state.unsolicitedDone
|
|
state.mu.Unlock()
|
|
|
|
// Cleanup should stop the reader and close the session.
|
|
e.cleanupInteractiveState(iKey)
|
|
|
|
// Verify the goroutine actually exited.
|
|
select {
|
|
case <-doneCh:
|
|
// Good.
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("unsolicited reader goroutine did not exit after cleanup")
|
|
}
|
|
}
|
|
|
|
// TestWorkspaceIdleTimeout_Configurable verifies that SetWorkspaceIdleTimeout
|
|
// changes the workspace pool's idle timeout.
|
|
func TestWorkspaceIdleTimeout_Configurable(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "test"}
|
|
e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish)
|
|
defer e.Stop()
|
|
|
|
tmpDir := t.TempDir()
|
|
e.SetMultiWorkspace(tmpDir, filepath.Join(tmpDir, "bindings.json"))
|
|
|
|
// Default should be DefaultWorkspaceIdleTimeout
|
|
e.workspacePool.mu.RLock()
|
|
defaultTimeout := e.workspacePool.idleTimeout
|
|
e.workspacePool.mu.RUnlock()
|
|
if defaultTimeout != DefaultWorkspaceIdleTimeout {
|
|
t.Errorf("expected default timeout %v, got %v", DefaultWorkspaceIdleTimeout, defaultTimeout)
|
|
}
|
|
|
|
// Set custom timeout
|
|
e.SetWorkspaceIdleTimeout(30 * time.Minute)
|
|
e.workspacePool.mu.RLock()
|
|
newTimeout := e.workspacePool.idleTimeout
|
|
e.workspacePool.mu.RUnlock()
|
|
if newTimeout != 30*time.Minute {
|
|
t.Errorf("expected 30m timeout, got %v", newTimeout)
|
|
}
|
|
|
|
// Disable reaping
|
|
e.SetWorkspaceIdleTimeout(0)
|
|
e.workspacePool.mu.RLock()
|
|
zeroTimeout := e.workspacePool.idleTimeout
|
|
e.workspacePool.mu.RUnlock()
|
|
if zeroTimeout != 0 {
|
|
t.Errorf("expected 0 timeout, got %v", zeroTimeout)
|
|
}
|
|
}
|
|
|
|
// TestReapIdle_DisabledWhenZeroTimeout verifies that ReapIdle returns nil
|
|
// when idleTimeout is zero.
|
|
func TestReapIdle_DisabledWhenZeroTimeout(t *testing.T) {
|
|
pool := newWorkspacePool(0)
|
|
ws := pool.GetOrCreate("/test/workspace")
|
|
ws.Touch()
|
|
// Even with an existing workspace, zero timeout disables reaping.
|
|
reaped := pool.ReapIdle()
|
|
if len(reaped) != 0 {
|
|
t.Errorf("expected no reaping with zero timeout, got %v", reaped)
|
|
}
|
|
}
|
|
|
|
func TestIsSilentReply(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
in string
|
|
want bool
|
|
}{
|
|
{"exact marker", "NO_REPLY", true},
|
|
{"lowercase", "no_reply", true},
|
|
{"mixed case", "No_Reply", true},
|
|
{"leading/trailing spaces", " NO_REPLY ", true},
|
|
{"surrounding newlines", "\nNO_REPLY\n", true},
|
|
{"tabs around", "\tNO_REPLY\t", true},
|
|
|
|
{"empty", "", false},
|
|
{"whitespace only", " ", false},
|
|
{"mixed with content", "Hello NO_REPLY", false},
|
|
{"marker with suffix", "NO_REPLY_EXTRA", false},
|
|
{"marker with prefix", "X NO_REPLY", false},
|
|
{"missing underscore", "NO REPLY", false},
|
|
{"partial", "NO_REPL", false},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if got := isSilentReply(tc.in); got != tc.want {
|
|
t.Errorf("isSilentReply(%q) = %v, want %v", tc.in, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestStripTrailingSilent(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
in string
|
|
want string
|
|
wantOK bool
|
|
}{
|
|
{"trailing on new line", "Hello\nNO_REPLY", "Hello", true},
|
|
{"trailing lowercase on new line", "Hello\nno_reply", "Hello", true},
|
|
{"trailing after space", "Some reasoning here NO_REPLY", "Some reasoning here", true},
|
|
{"multi-line then marker", "Line1\nLine2\nNO_REPLY", "Line1\nLine2", true},
|
|
{"trailing with markdown emphasis", "Done. *NO_REPLY*", "Done. *NO_REPLY*", false},
|
|
{"trailing preceded by asterisks", "Done.**NO_REPLY", "Done.", true},
|
|
{"trailing with crlf", "Hello\r\nNO_REPLY", "Hello", true},
|
|
{"marker followed by trailing whitespace", "Hello NO_REPLY ", "Hello", true},
|
|
|
|
{"no marker", "Hello world", "Hello world", false},
|
|
{"marker not at end", "NO_REPLY then more", "NO_REPLY then more", false},
|
|
{"marker with suffix token", "Hello NO_REPLY_EXTRA", "Hello NO_REPLY_EXTRA", false},
|
|
{"marker touching prior letters", "somethingNO_REPLY", "somethingNO_REPLY", false},
|
|
{"empty", "", "", false},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got, ok := stripTrailingSilent(tc.in)
|
|
if ok != tc.wantOK {
|
|
t.Errorf("stripTrailingSilent(%q) ok=%v, want %v", tc.in, ok, tc.wantOK)
|
|
}
|
|
if got != tc.want {
|
|
t.Errorf("stripTrailingSilent(%q) got=%q, want %q", tc.in, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCouldBeSilentPrefix(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
in string
|
|
want bool
|
|
}{
|
|
{"empty", "", true},
|
|
{"whitespace only", " ", true},
|
|
{"one letter", "N", true},
|
|
{"two letters", "NO", true},
|
|
{"underscore partial", "NO_", true},
|
|
{"five letters", "NO_RE", true},
|
|
{"almost full", "NO_REPL", true},
|
|
{"full marker", "NO_REPLY", true},
|
|
{"lowercase partial", "no_r", true},
|
|
{"mixed case partial", "No_Re", true},
|
|
{"trimmed surrounding whitespace", " NO_ ", true},
|
|
|
|
{"non-N start", "Hello", false},
|
|
{"one wrong letter", "X", false},
|
|
{"longer than marker", "NO_REPLYX", false},
|
|
{"similar but divergent", "NO-REPLY", false},
|
|
{"partial then wrong", "NO_Q", false},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if got := couldBeSilentPrefix(tc.in); got != tc.want {
|
|
t.Errorf("couldBeSilentPrefix(%q) = %v, want %v", tc.in, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Integration tests for /list visibility after /new and provider switches
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// TestCmdList_AllSessionsVisibleAfterRepeatedNew verifies that /list shows ALL
|
|
// sessions after multiple /new cycles. This is the exact reproduction scenario
|
|
// reported by users: /new clears the active session's AgentSessionID, causing
|
|
// filterOwnedSessions to progressively hide older sessions.
|
|
func TestCmdList_AllSessionsVisibleAfterRepeatedNew(t *testing.T) {
|
|
base := time.Date(2026, 4, 1, 10, 0, 0, 0, time.UTC)
|
|
agentSessions := make([]AgentSessionInfo, 5)
|
|
for i := range agentSessions {
|
|
agentSessions[i] = AgentSessionInfo{
|
|
ID: fmt.Sprintf("codex-thread-%d", i+1),
|
|
Summary: fmt.Sprintf("Session %d summary", i+1),
|
|
MessageCount: (i + 1) * 2,
|
|
ModifiedAt: base.Add(time.Duration(i) * time.Hour),
|
|
}
|
|
}
|
|
|
|
agent := &stubListAgent{sessions: agentSessions}
|
|
p := &stubPlatformEngine{n: "plain"}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
userKey := "test:user1"
|
|
|
|
for i, as := range agentSessions {
|
|
if i > 0 {
|
|
old := e.sessions.GetOrCreateActive(userKey)
|
|
old.SetAgentSessionID("", "")
|
|
old.ClearHistory()
|
|
e.sessions.Save()
|
|
e.sessions.NewSession(userKey, fmt.Sprintf("session-%d", i+1))
|
|
}
|
|
s := e.sessions.GetOrCreateActive(userKey)
|
|
s.SetAgentSessionID(as.ID, "codex")
|
|
e.sessions.Save()
|
|
}
|
|
|
|
p.sent = nil
|
|
msg := &Message{SessionKey: userKey, ReplyCtx: "ctx"}
|
|
e.cmdList(p, msg, nil)
|
|
|
|
if len(p.sent) != 1 {
|
|
t.Fatalf("expected 1 reply, got %d", len(p.sent))
|
|
}
|
|
for _, as := range agentSessions {
|
|
if !strings.Contains(p.sent[0], as.Summary) {
|
|
t.Errorf("/list output missing session %q:\n%s", as.ID, p.sent[0])
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestCmdList_AllSessionsVisibleAfterResetAllSessions simulates a management
|
|
// API provider switch (resetAllSessions) followed by creating a new session.
|
|
// All previously tracked sessions must remain visible in /list.
|
|
func TestCmdList_AllSessionsVisibleAfterResetAllSessions(t *testing.T) {
|
|
base := time.Date(2026, 4, 1, 10, 0, 0, 0, time.UTC)
|
|
agentSessions := make([]AgentSessionInfo, 4)
|
|
for i := range agentSessions {
|
|
agentSessions[i] = AgentSessionInfo{
|
|
ID: fmt.Sprintf("thread-%d", i+1),
|
|
Summary: fmt.Sprintf("Chat %d", i+1),
|
|
MessageCount: 5,
|
|
ModifiedAt: base.Add(time.Duration(i) * time.Hour),
|
|
}
|
|
}
|
|
|
|
agent := &stubListAgent{sessions: agentSessions}
|
|
p := &stubPlatformEngine{n: "plain"}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
userKey := "test:user1"
|
|
|
|
for _, as := range agentSessions[:3] {
|
|
s := e.sessions.NewSession(userKey, "")
|
|
s.SetAgentSessionID(as.ID, "codex")
|
|
}
|
|
e.sessions.Save()
|
|
|
|
e.resetAllSessions()
|
|
|
|
newS := e.sessions.NewSession(userKey, "fresh")
|
|
newS.SetAgentSessionID(agentSessions[3].ID, "codex")
|
|
e.sessions.Save()
|
|
|
|
p.sent = nil
|
|
msg := &Message{SessionKey: userKey, ReplyCtx: "ctx"}
|
|
e.cmdList(p, msg, nil)
|
|
|
|
if len(p.sent) != 1 {
|
|
t.Fatalf("expected 1 reply, got %d", len(p.sent))
|
|
}
|
|
for _, as := range agentSessions {
|
|
if !strings.Contains(p.sent[0], as.Summary) {
|
|
t.Errorf("/list output missing session %q after resetAllSessions:\n%s", as.ID, p.sent[0])
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestCmdList_SessionVisibleDuringAgentProcessing simulates the window where
|
|
// a new session has been created (/new) and a message sent, but the agent
|
|
// has not yet responded with a session ID. During this window, the active
|
|
// session has no AgentSessionID. Previously this caused filterOwnedSessions
|
|
// to either return all sessions (empty known set) or hide sessions (if other
|
|
// sessions also had cleared IDs). The fix ensures deterministic behavior.
|
|
func TestCmdList_SessionVisibleDuringAgentProcessing(t *testing.T) {
|
|
base := time.Date(2026, 4, 1, 10, 0, 0, 0, time.UTC)
|
|
agentSessions := []AgentSessionInfo{
|
|
{ID: "old-thread-1", Summary: "Old session 1", MessageCount: 10, ModifiedAt: base},
|
|
{ID: "old-thread-2", Summary: "Old session 2", MessageCount: 8, ModifiedAt: base.Add(time.Hour)},
|
|
{ID: "new-thread-3", Summary: "Processing...", MessageCount: 1, ModifiedAt: base.Add(2 * time.Hour)},
|
|
}
|
|
|
|
agent := &stubListAgent{sessions: agentSessions}
|
|
p := &stubPlatformEngine{n: "plain"}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
userKey := "test:user1"
|
|
|
|
s1 := e.sessions.GetOrCreateActive(userKey)
|
|
s1.SetAgentSessionID("old-thread-1", "codex")
|
|
e.sessions.Save()
|
|
|
|
s1.SetAgentSessionID("", "")
|
|
s2 := e.sessions.NewSession(userKey, "session-2")
|
|
s2.SetAgentSessionID("old-thread-2", "codex")
|
|
e.sessions.Save()
|
|
|
|
s2.SetAgentSessionID("", "")
|
|
e.sessions.NewSession(userKey, "processing")
|
|
e.sessions.Save()
|
|
|
|
p.sent = nil
|
|
msg := &Message{SessionKey: userKey, ReplyCtx: "ctx"}
|
|
e.cmdList(p, msg, nil)
|
|
|
|
if len(p.sent) != 1 {
|
|
t.Fatalf("expected 1 reply, got %d", len(p.sent))
|
|
}
|
|
reply := p.sent[0]
|
|
if !strings.Contains(reply, "Old session 1") {
|
|
t.Errorf("/list missing 'Old session 1' during processing:\n%s", reply)
|
|
}
|
|
if !strings.Contains(reply, "Old session 2") {
|
|
t.Errorf("/list missing 'Old session 2' during processing:\n%s", reply)
|
|
}
|
|
}
|
|
|
|
// TestRenderListCard_AllSessionsVisibleAfterRepeatedNew is the card-based
|
|
// variant of the /new regression test.
|
|
func TestRenderListCard_AllSessionsVisibleAfterRepeatedNew(t *testing.T) {
|
|
base := time.Date(2026, 4, 1, 10, 0, 0, 0, time.UTC)
|
|
agentSessions := make([]AgentSessionInfo, 6)
|
|
for i := range agentSessions {
|
|
agentSessions[i] = AgentSessionInfo{
|
|
ID: fmt.Sprintf("thread-%d", i+1),
|
|
Summary: fmt.Sprintf("Session %d", i+1),
|
|
MessageCount: 3,
|
|
ModifiedAt: base.Add(time.Duration(i) * time.Minute),
|
|
}
|
|
}
|
|
|
|
agent := &stubListAgent{sessions: agentSessions}
|
|
e := NewEngine("test", agent, []Platform{&stubPlatformEngine{n: "test"}}, "", LangEnglish)
|
|
userKey := "test:user1"
|
|
|
|
for i, as := range agentSessions {
|
|
if i > 0 {
|
|
old := e.sessions.GetOrCreateActive(userKey)
|
|
old.SetAgentSessionID("", "")
|
|
old.ClearHistory()
|
|
e.sessions.NewSession(userKey, fmt.Sprintf("s%d", i+1))
|
|
}
|
|
s := e.sessions.GetOrCreateActive(userKey)
|
|
s.SetAgentSessionID(as.ID, "codex")
|
|
}
|
|
e.sessions.Save()
|
|
|
|
card, err := e.renderListCard(userKey, 1)
|
|
if err != nil {
|
|
t.Fatalf("renderListCard error: %v", err)
|
|
}
|
|
|
|
switchActions := countCardActionValues(card, "act:/switch ")
|
|
if switchActions != len(agentSessions) {
|
|
t.Fatalf("card switch actions = %d, want %d (some sessions hidden by filter)",
|
|
switchActions, len(agentSessions))
|
|
}
|
|
}
|
|
|
|
// TestCmdList_ProviderSwitchThenNewDoesNotHideSessions simulates the full
|
|
// real-world scenario: user has sessions → switches provider → creates new
|
|
// sessions → all sessions (old and new) must remain visible.
|
|
func TestCmdList_ProviderSwitchThenNewDoesNotHideSessions(t *testing.T) {
|
|
base := time.Date(2026, 4, 1, 10, 0, 0, 0, time.UTC)
|
|
allAgentSessions := []AgentSessionInfo{
|
|
{ID: "old-1", Summary: "Before switch 1", MessageCount: 5, ModifiedAt: base},
|
|
{ID: "old-2", Summary: "Before switch 2", MessageCount: 3, ModifiedAt: base.Add(time.Hour)},
|
|
{ID: "new-1", Summary: "After switch 1", MessageCount: 2, ModifiedAt: base.Add(2 * time.Hour)},
|
|
{ID: "new-2", Summary: "After switch 2", MessageCount: 1, ModifiedAt: base.Add(3 * time.Hour)},
|
|
}
|
|
agent := &stubListAgent{sessions: allAgentSessions}
|
|
p := &stubPlatformEngine{n: "plain"}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
userKey := "test:user1"
|
|
|
|
for _, as := range allAgentSessions[:2] {
|
|
s := e.sessions.NewSession(userKey, "")
|
|
s.SetAgentSessionID(as.ID, "codex")
|
|
}
|
|
e.sessions.Save()
|
|
|
|
e.resetAllSessions()
|
|
|
|
for i, as := range allAgentSessions[2:] {
|
|
if i > 0 {
|
|
old := e.sessions.GetOrCreateActive(userKey)
|
|
old.SetAgentSessionID("", "")
|
|
}
|
|
s := e.sessions.NewSession(userKey, "")
|
|
s.SetAgentSessionID(as.ID, "codex")
|
|
}
|
|
e.sessions.Save()
|
|
|
|
p.sent = nil
|
|
msg := &Message{SessionKey: userKey, ReplyCtx: "ctx"}
|
|
e.cmdList(p, msg, nil)
|
|
|
|
if len(p.sent) != 1 {
|
|
t.Fatalf("expected 1 reply, got %d", len(p.sent))
|
|
}
|
|
for _, as := range allAgentSessions {
|
|
if !strings.Contains(p.sent[0], as.Summary) {
|
|
t.Errorf("/list missing %q after provider switch + new:\n%s", as.Summary, p.sent[0])
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestCmdList_RealWorldLegacyDataFullFlow is a precise reproduction of the
|
|
// user-reported bug using data shaped exactly like the real qa-release project:
|
|
// - 15 internal sessions, 14 with lost AgentSessionIDs (old code damage)
|
|
// - 1 active session (s15) with a valid AgentSessionID
|
|
// - 37 codex sessions on disk
|
|
//
|
|
// Steps (matching user's exact reproduction):
|
|
// 1. /list → must show all 37 sessions (legacy data, no filtering)
|
|
// 2. /new "我的新会话" → create named session
|
|
// 3. send message (agent hasn't replied yet) → /list → must STILL show all sessions
|
|
// 4. agent replies with SessionID → /list → must show all sessions + new one
|
|
// 5. session name "我的新会话" must appear in the list
|
|
func TestCmdList_RealWorldLegacyDataFullFlow(t *testing.T) {
|
|
dir := t.TempDir()
|
|
sessPath := filepath.Join(dir, "sessions.json")
|
|
|
|
// Write legacy session data (no past_id_tracking, simulates pre-fix data)
|
|
legacyJSON := `{
|
|
"sessions": {
|
|
"s1": {"id":"s1", "name":"default", "agent_session_id":"", "history":null, "created_at":"2026-03-26T22:25:56Z", "updated_at":"2026-03-26T22:25:56Z"},
|
|
"s2": {"id":"s2", "name":"default", "agent_session_id":"", "history":null, "created_at":"2026-04-18T09:02:57Z", "updated_at":"2026-04-18T09:02:57Z"},
|
|
"s3": {"id":"s3", "name":"", "agent_session_id":"", "history":null, "created_at":"2026-04-18T09:03:07Z", "updated_at":"2026-04-18T09:03:07Z"},
|
|
"s4": {"id":"s4", "name":"", "agent_session_id":"", "history":null, "created_at":"2026-04-18T09:07:15Z", "updated_at":"2026-04-18T09:07:15Z"},
|
|
"s5": {"id":"s5", "name":"", "agent_session_id":"", "history":null, "created_at":"2026-04-18T11:14:14Z", "updated_at":"2026-04-18T11:14:14Z"},
|
|
"s6": {"id":"s6", "name":"", "agent_session_id":"", "history":null, "created_at":"2026-04-18T11:39:15Z", "updated_at":"2026-04-18T11:39:15Z"},
|
|
"s7": {"id":"s7", "name":"", "agent_session_id":"", "history":null, "created_at":"2026-04-18T11:42:27Z", "updated_at":"2026-04-18T11:42:27Z"},
|
|
"s8": {"id":"s8", "name":"", "agent_session_id":"", "history":null, "created_at":"2026-04-18T12:01:02Z", "updated_at":"2026-04-18T12:01:22Z"},
|
|
"s9": {"id":"s9", "name":"", "agent_session_id":"", "history":null, "created_at":"2026-04-18T12:06:31Z", "updated_at":"2026-04-18T12:08:37Z"},
|
|
"s10": {"id":"s10","name":"", "agent_session_id":"", "history":null, "created_at":"2026-04-18T12:18:55Z", "updated_at":"2026-04-18T12:18:55Z"},
|
|
"s11": {"id":"s11","name":"", "agent_session_id":"", "history":null, "created_at":"2026-04-18T14:07:03Z", "updated_at":"2026-04-18T14:07:47Z"},
|
|
"s12": {"id":"s12","name":"", "agent_session_id":"", "history":null, "created_at":"2026-04-18T14:07:59Z", "updated_at":"2026-04-18T14:18:49Z"},
|
|
"s13": {"id":"s13","name":"", "agent_session_id":"", "history":null, "created_at":"2026-04-18T15:50:39Z", "updated_at":"2026-04-20T21:44:37Z"},
|
|
"s14": {"id":"s14","name":"今天", "agent_session_id":"", "history":null, "created_at":"2026-04-20T21:44:58Z", "updated_at":"2026-04-20T21:44:58Z"},
|
|
"s15": {"id":"s15","name":"新的会话", "agent_session_id":"019dab28-1a0f-7f60-87ed-b4fda306ebef", "agent_type":"codex", "history":null, "created_at":"2026-04-20T21:50:14Z", "updated_at":"2026-04-20T21:50:14Z"}
|
|
},
|
|
"active_session": {"feishu:chat:user1":"s15"},
|
|
"user_sessions": {"feishu:chat:user1":["s2","s3","s4","s5","s6","s7","s8","s9","s10","s11","s12","s13","s14","s15"]},
|
|
"counter": 15
|
|
}`
|
|
if err := os.WriteFile(sessPath, []byte(legacyJSON), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
base := time.Date(2026, 4, 18, 9, 0, 0, 0, time.UTC)
|
|
agentSessions := make([]AgentSessionInfo, 37)
|
|
for i := range agentSessions {
|
|
agentSessions[i] = AgentSessionInfo{
|
|
ID: fmt.Sprintf("codex-thread-%03d", i+1),
|
|
Summary: fmt.Sprintf("Codex session %d", i+1),
|
|
MessageCount: 3,
|
|
ModifiedAt: base.Add(time.Duration(i) * 30 * time.Minute),
|
|
}
|
|
}
|
|
// s15's actual codex session is at index 36 (most recent)
|
|
agentSessions[36].ID = "019dab28-1a0f-7f60-87ed-b4fda306ebef"
|
|
agentSessions[36].Summary = "陈奕迅最有名是那首歌"
|
|
|
|
agent := &stubListAgent{sessions: agentSessions}
|
|
p := &stubPlatformEngine{n: "plain"}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
e.sessions = NewSessionManager(sessPath) // load real data
|
|
userKey := "feishu:chat:user1"
|
|
msg := &Message{SessionKey: userKey, ReplyCtx: "ctx"}
|
|
|
|
// ── Step 1: /list on startup ───────────────────────────────
|
|
p.sent = nil
|
|
e.cmdList(p, msg, nil)
|
|
if len(p.sent) != 1 {
|
|
t.Fatalf("step1: expected 1 reply, got %d", len(p.sent))
|
|
}
|
|
step1Count := strings.Count(p.sent[0], "msgs")
|
|
if step1Count != 20 {
|
|
t.Fatalf("step1: /list should show first page (20 sessions), got %d", step1Count)
|
|
}
|
|
|
|
// ── Step 2: /new "我的新会话" ──────────────────────────────
|
|
e.cmdNew(p, msg, []string{"我的新会话"})
|
|
|
|
// ── Step 3: send message, agent not yet replied → /list ────
|
|
// (agent process started but hasn't returned SessionID yet)
|
|
p.sent = nil
|
|
e.cmdList(p, msg, nil)
|
|
if len(p.sent) != 1 {
|
|
t.Fatalf("step3: expected 1 reply, got %d", len(p.sent))
|
|
}
|
|
step3Count := strings.Count(p.sent[0], "msgs")
|
|
if step3Count < 20 {
|
|
t.Fatalf("step3: /list BEFORE agent reply should still show all sessions (page 1 = 20), got %d\nreply:\n%s",
|
|
step3Count, p.sent[0])
|
|
}
|
|
|
|
// ── Step 4: agent replies → set SessionID → /list ──────────
|
|
newSession := e.sessions.GetOrCreateActive(userKey)
|
|
newThreadID := "codex-thread-new-038"
|
|
newSession.CompareAndSetAgentSessionID(newThreadID, "codex")
|
|
// Engine maps the pending name to the new agent session ID
|
|
pendingName := newSession.GetName()
|
|
if pendingName != "" && pendingName != "session" && pendingName != "default" {
|
|
e.sessions.SetSessionName(newThreadID, pendingName)
|
|
}
|
|
e.sessions.Save()
|
|
|
|
// Agent now reports this new session in ListSessions
|
|
agent.sessions = append(agent.sessions, AgentSessionInfo{
|
|
ID: newThreadID,
|
|
Summary: "我的新消息内容",
|
|
MessageCount: 2,
|
|
ModifiedAt: time.Now(),
|
|
})
|
|
|
|
p.sent = nil
|
|
e.cmdList(p, msg, nil)
|
|
if len(p.sent) != 1 {
|
|
t.Fatalf("step4: expected 1 reply, got %d", len(p.sent))
|
|
}
|
|
step4Count := strings.Count(p.sent[0], "msgs")
|
|
if step4Count < 20 {
|
|
t.Fatalf("step4: /list AFTER agent reply should show all sessions (page 1 = 20), got %d\nreply:\n%s",
|
|
step4Count, p.sent[0])
|
|
}
|
|
|
|
// ── Step 5: verify session name on page 2 ─────────────────
|
|
// The newest session is at the end of the list; check page 2.
|
|
p.sent = nil
|
|
e.cmdList(p, msg, []string{"2"})
|
|
if len(p.sent) != 1 {
|
|
t.Fatalf("step5: expected 1 reply for page 2, got %d", len(p.sent))
|
|
}
|
|
// The new session should show "我的新会话" (the name from /new), not the message content
|
|
if !strings.Contains(p.sent[0], "我的新会话") {
|
|
t.Errorf("step5: /list page 2 should display session name '我的新会话' but it's missing:\n%s", p.sent[0])
|
|
}
|
|
}
|
|
|
|
// TestCmdList_FilterExternalSessionsEnabled verifies that when
|
|
// filter_external_sessions is enabled, only cc-connect-tracked sessions
|
|
// appear in /list.
|
|
func TestCmdList_FilterExternalSessionsEnabled(t *testing.T) {
|
|
agentSessions := []AgentSessionInfo{
|
|
{ID: "tracked-1", Summary: "Tracked 1", MessageCount: 5},
|
|
{ID: "tracked-2", Summary: "Tracked 2", MessageCount: 3},
|
|
{ID: "external-1", Summary: "External CLI session", MessageCount: 10},
|
|
}
|
|
|
|
agent := &stubListAgent{sessions: agentSessions}
|
|
p := &stubPlatformEngine{n: "plain"}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
e.SetFilterExternalSessions(true)
|
|
userKey := "test:user1"
|
|
|
|
s1 := e.sessions.GetOrCreateActive(userKey)
|
|
s1.SetAgentSessionID("tracked-1", "codex")
|
|
e.sessions.Save()
|
|
s1.SetAgentSessionID("", "")
|
|
s2 := e.sessions.NewSession(userKey, "session2")
|
|
s2.SetAgentSessionID("tracked-2", "codex")
|
|
e.sessions.Save()
|
|
|
|
p.sent = nil
|
|
msg := &Message{SessionKey: userKey, ReplyCtx: "ctx"}
|
|
e.cmdList(p, msg, nil)
|
|
|
|
if len(p.sent) != 1 {
|
|
t.Fatalf("expected 1 reply, got %d", len(p.sent))
|
|
}
|
|
reply := p.sent[0]
|
|
if !strings.Contains(reply, "Tracked 1") {
|
|
t.Errorf("filter enabled: should show tracked session 'Tracked 1':\n%s", reply)
|
|
}
|
|
if !strings.Contains(reply, "Tracked 2") {
|
|
t.Errorf("filter enabled: should show tracked session 'Tracked 2':\n%s", reply)
|
|
}
|
|
if strings.Contains(reply, "External CLI session") {
|
|
t.Errorf("filter enabled: should NOT show external session:\n%s", reply)
|
|
}
|
|
}
|
|
|
|
// TestCmdList_DefaultShowsAllSessions verifies that with default config
|
|
// (filter_external_sessions=false), all sessions including external ones appear.
|
|
func TestCmdList_DefaultShowsAllSessions(t *testing.T) {
|
|
agentSessions := []AgentSessionInfo{
|
|
{ID: "tracked-1", Summary: "Tracked session", MessageCount: 5},
|
|
{ID: "external-1", Summary: "External session", MessageCount: 10},
|
|
}
|
|
|
|
agent := &stubListAgent{sessions: agentSessions}
|
|
p := &stubPlatformEngine{n: "plain"}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
userKey := "test:user1"
|
|
|
|
s := e.sessions.GetOrCreateActive(userKey)
|
|
s.SetAgentSessionID("tracked-1", "codex")
|
|
e.sessions.Save()
|
|
|
|
p.sent = nil
|
|
msg := &Message{SessionKey: userKey, ReplyCtx: "ctx"}
|
|
e.cmdList(p, msg, nil)
|
|
|
|
if len(p.sent) != 1 {
|
|
t.Fatalf("expected 1 reply, got %d", len(p.sent))
|
|
}
|
|
reply := p.sent[0]
|
|
if !strings.Contains(reply, "Tracked session") {
|
|
t.Errorf("default mode: should show tracked session:\n%s", reply)
|
|
}
|
|
if !strings.Contains(reply, "External session") {
|
|
t.Errorf("default mode: should show external session:\n%s", reply)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// filter_external_sessions integration test suite
|
|
// Covers /list, /switch, /delete, renderListCard under both modes.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// setupFilterTestEngine creates a test Engine with 3 agent sessions, 2 tracked
|
|
// by cc-connect and 1 external. Returns (engine, platform, userKey, agentSessions).
|
|
func setupFilterTestEngine(t *testing.T, filterEnabled bool) (*Engine, *stubPlatformEngine, string, []AgentSessionInfo) {
|
|
t.Helper()
|
|
agentSessions := []AgentSessionInfo{
|
|
{ID: "tracked-1", Summary: "Tracked session 1", MessageCount: 5, ModifiedAt: time.Now().Add(-2 * time.Hour)},
|
|
{ID: "tracked-2", Summary: "Tracked session 2", MessageCount: 3, ModifiedAt: time.Now().Add(-time.Hour)},
|
|
{ID: "external-1", Summary: "External CLI session", MessageCount: 10, ModifiedAt: time.Now()},
|
|
}
|
|
agent := &stubDeleteAgent{
|
|
stubListAgent: stubListAgent{sessions: agentSessions},
|
|
errByID: map[string]error{},
|
|
}
|
|
p := &stubPlatformEngine{n: "plain"}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
e.SetFilterExternalSessions(filterEnabled)
|
|
userKey := "test:filter-user"
|
|
|
|
s1 := e.sessions.GetOrCreateActive(userKey)
|
|
s1.SetAgentSessionID("tracked-1", "codex")
|
|
e.sessions.Save()
|
|
s1.SetAgentSessionID("", "")
|
|
s2 := e.sessions.NewSession(userKey, "session2")
|
|
s2.SetAgentSessionID("tracked-2", "codex")
|
|
e.sessions.Save()
|
|
|
|
return e, p, userKey, agentSessions
|
|
}
|
|
|
|
func TestFilterExternalSessions_SwitchByIndex(t *testing.T) {
|
|
t.Run("disabled: index 3 reaches external session", func(t *testing.T) {
|
|
e, p, userKey, _ := setupFilterTestEngine(t, false)
|
|
p.sent = nil
|
|
e.cmdSwitch(p, &Message{SessionKey: userKey, ReplyCtx: "ctx"}, []string{"3"})
|
|
if len(p.sent) != 1 {
|
|
t.Fatalf("expected 1 reply, got %d", len(p.sent))
|
|
}
|
|
if !strings.Contains(p.sent[0], "External CLI session") {
|
|
t.Errorf("default mode: /switch 3 should reach external session:\n%s", p.sent[0])
|
|
}
|
|
})
|
|
|
|
t.Run("enabled: index 3 out of range", func(t *testing.T) {
|
|
e, p, userKey, _ := setupFilterTestEngine(t, true)
|
|
p.sent = nil
|
|
e.cmdSwitch(p, &Message{SessionKey: userKey, ReplyCtx: "ctx"}, []string{"3"})
|
|
if len(p.sent) != 1 {
|
|
t.Fatalf("expected 1 reply, got %d", len(p.sent))
|
|
}
|
|
if strings.Contains(p.sent[0], "External CLI session") {
|
|
t.Errorf("filter enabled: /switch 3 should NOT reach external session:\n%s", p.sent[0])
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestFilterExternalSessions_SwitchByIDPrefix(t *testing.T) {
|
|
t.Run("disabled: can switch to external by ID prefix", func(t *testing.T) {
|
|
e, p, userKey, _ := setupFilterTestEngine(t, false)
|
|
p.sent = nil
|
|
e.cmdSwitch(p, &Message{SessionKey: userKey, ReplyCtx: "ctx"}, []string{"external"})
|
|
if len(p.sent) != 1 {
|
|
t.Fatalf("expected 1 reply, got %d", len(p.sent))
|
|
}
|
|
if !strings.Contains(p.sent[0], "External CLI session") {
|
|
t.Errorf("default mode: /switch external should find external session:\n%s", p.sent[0])
|
|
}
|
|
})
|
|
|
|
t.Run("enabled: external ID prefix not found", func(t *testing.T) {
|
|
e, p, userKey, _ := setupFilterTestEngine(t, true)
|
|
p.sent = nil
|
|
e.cmdSwitch(p, &Message{SessionKey: userKey, ReplyCtx: "ctx"}, []string{"external"})
|
|
if len(p.sent) != 1 {
|
|
t.Fatalf("expected 1 reply, got %d", len(p.sent))
|
|
}
|
|
if strings.Contains(p.sent[0], "External CLI session") {
|
|
t.Errorf("filter enabled: /switch external should NOT find external session:\n%s", p.sent[0])
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestFilterExternalSessions_DeleteByIndex(t *testing.T) {
|
|
t.Run("disabled: /delete 3 hits external session", func(t *testing.T) {
|
|
e, p, userKey, _ := setupFilterTestEngine(t, false)
|
|
p.sent = nil
|
|
e.cmdDelete(p, &Message{SessionKey: userKey, ReplyCtx: "ctx"}, []string{"3"})
|
|
if len(p.sent) == 0 {
|
|
t.Fatal("expected reply from /delete")
|
|
}
|
|
reply := strings.Join(p.sent, "\n")
|
|
if !strings.Contains(reply, "external-1") && !strings.Contains(reply, "External CLI session") {
|
|
t.Errorf("default mode: /delete 3 should target external session:\n%s", reply)
|
|
}
|
|
})
|
|
|
|
t.Run("enabled: /delete 3 out of range", func(t *testing.T) {
|
|
e, p, userKey, _ := setupFilterTestEngine(t, true)
|
|
p.sent = nil
|
|
e.cmdDelete(p, &Message{SessionKey: userKey, ReplyCtx: "ctx"}, []string{"3"})
|
|
if len(p.sent) == 0 {
|
|
t.Fatal("expected reply from /delete")
|
|
}
|
|
reply := strings.Join(p.sent, "\n")
|
|
if strings.Contains(reply, "external-1") || strings.Contains(reply, "External CLI session") {
|
|
t.Errorf("filter enabled: /delete 3 should NOT target external session:\n%s", reply)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestFilterExternalSessions_RenderListCard(t *testing.T) {
|
|
t.Run("disabled: card shows all sessions", func(t *testing.T) {
|
|
e, _, userKey, agentSessions := setupFilterTestEngine(t, false)
|
|
card, err := e.renderListCard(userKey, 1)
|
|
if err != nil {
|
|
t.Fatalf("renderListCard: %v", err)
|
|
}
|
|
switchActions := countCardActionValues(card, "act:/switch ")
|
|
if switchActions != len(agentSessions) {
|
|
t.Errorf("default mode: card should show %d sessions, got %d", len(agentSessions), switchActions)
|
|
}
|
|
})
|
|
|
|
t.Run("enabled: card hides external sessions", func(t *testing.T) {
|
|
e, _, userKey, _ := setupFilterTestEngine(t, true)
|
|
card, err := e.renderListCard(userKey, 1)
|
|
if err != nil {
|
|
t.Fatalf("renderListCard: %v", err)
|
|
}
|
|
switchActions := countCardActionValues(card, "act:/switch ")
|
|
if switchActions != 2 {
|
|
t.Errorf("filter enabled: card should show 2 tracked sessions, got %d", switchActions)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestFilterExternalSessions_DynamicToggle(t *testing.T) {
|
|
e, p, userKey, agentSessions := setupFilterTestEngine(t, false)
|
|
msg := &Message{SessionKey: userKey, ReplyCtx: "ctx"}
|
|
|
|
p.sent = nil
|
|
e.cmdList(p, msg, nil)
|
|
count1 := strings.Count(p.sent[0], "msgs")
|
|
if count1 != len(agentSessions) {
|
|
t.Fatalf("before toggle: expected %d sessions, got %d", len(agentSessions), count1)
|
|
}
|
|
|
|
e.SetFilterExternalSessions(true)
|
|
|
|
p.sent = nil
|
|
e.cmdList(p, msg, nil)
|
|
count2 := strings.Count(p.sent[0], "msgs")
|
|
if count2 != 2 {
|
|
t.Fatalf("after enabling filter: expected 2 sessions, got %d\nreply:\n%s", count2, p.sent[0])
|
|
}
|
|
|
|
e.SetFilterExternalSessions(false)
|
|
|
|
p.sent = nil
|
|
e.cmdList(p, msg, nil)
|
|
count3 := strings.Count(p.sent[0], "msgs")
|
|
if count3 != len(agentSessions) {
|
|
t.Fatalf("after disabling filter: expected %d sessions, got %d", len(agentSessions), count3)
|
|
}
|
|
}
|
|
|
|
// codexLikeSession simulates real codex agent behavior:
|
|
// - CurrentSessionID() returns "" until Send() is called
|
|
// - Send() sets the thread ID and pushes an EventResult with the SessionID
|
|
type codexLikeSession struct {
|
|
threadID string
|
|
events chan Event
|
|
alive bool
|
|
hasSentID bool
|
|
}
|
|
|
|
func newCodexLikeSession(threadID string) *codexLikeSession {
|
|
return &codexLikeSession{
|
|
threadID: threadID,
|
|
events: make(chan Event, 8),
|
|
alive: true,
|
|
}
|
|
}
|
|
|
|
func (s *codexLikeSession) Send(prompt string, _ []ImageAttachment, _ []FileAttachment) error {
|
|
s.hasSentID = true
|
|
s.events <- Event{Type: EventText, Content: "Agent reply to: " + prompt}
|
|
s.events <- Event{Type: EventResult, SessionID: s.threadID, Content: "Done", Done: true}
|
|
return nil
|
|
}
|
|
func (s *codexLikeSession) RespondPermission(_ string, _ PermissionResult) error { return nil }
|
|
func (s *codexLikeSession) Events() <-chan Event { return s.events }
|
|
func (s *codexLikeSession) CurrentSessionID() string {
|
|
if s.hasSentID {
|
|
return s.threadID
|
|
}
|
|
return ""
|
|
}
|
|
func (s *codexLikeSession) Alive() bool { return s.alive }
|
|
func (s *codexLikeSession) Close() error { s.alive = false; return nil }
|
|
|
|
// TestSessionName_CodexLikeFlow does an end-to-end test simulating real codex
|
|
// behavior: CurrentSessionID()="" initially, thread ID only available after Send().
|
|
// This is the exact bug: /new xxx → send message → agent replies with SessionID
|
|
// in EventResult → name "xxx" must appear in /list.
|
|
func TestSessionName_CodexLikeFlow(t *testing.T) {
|
|
sess := newCodexLikeSession("codex-thread-new-001")
|
|
listSessions := []AgentSessionInfo{
|
|
{ID: "codex-thread-old", Summary: "Old session", MessageCount: 5, ModifiedAt: time.Now().Add(-time.Hour)},
|
|
}
|
|
agent := &controllableAgent{
|
|
nextSession: sess,
|
|
listFn: func() ([]AgentSessionInfo, error) {
|
|
return listSessions, nil
|
|
},
|
|
}
|
|
p := &stubPlatformEngine{n: "plain"}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
userKey := "test:user1"
|
|
|
|
// Setup: create initial session with a known agent session ID
|
|
initial := e.sessions.GetOrCreateActive(userKey)
|
|
initial.SetAgentSessionID("codex-thread-old", "codex")
|
|
e.sessions.Save()
|
|
|
|
// Step 1: /new "我的新会话"
|
|
e.cmdNew(p, &Message{SessionKey: userKey, ReplyCtx: "ctx"}, []string{"我的新会话"})
|
|
|
|
// Step 2: send a message (this triggers startOrResumeSession + processInteractiveEvents)
|
|
e.ReceiveMessage(p, &Message{
|
|
SessionKey: userKey,
|
|
Content: "请帮我做个功能",
|
|
ReplyCtx: "ctx2",
|
|
})
|
|
|
|
// Wait for the event loop to complete
|
|
time.Sleep(200 * time.Millisecond)
|
|
|
|
// Step 3: verify session name was mapped
|
|
newSession := e.sessions.GetOrCreateActive(userKey)
|
|
agentID := newSession.GetAgentSessionID()
|
|
if agentID != "codex-thread-new-001" {
|
|
t.Fatalf("AgentSessionID = %q, want %q", agentID, "codex-thread-new-001")
|
|
}
|
|
|
|
gotName := e.sessions.GetSessionName("codex-thread-new-001")
|
|
if gotName != "我的新会话" {
|
|
t.Fatalf("GetSessionName(%q) = %q, want %q", "codex-thread-new-001", gotName, "我的新会话")
|
|
}
|
|
|
|
// Step 4: verify /list displays the name
|
|
listSessions = append(listSessions, AgentSessionInfo{
|
|
ID: "codex-thread-new-001",
|
|
Summary: "请帮我做个功能",
|
|
MessageCount: 2,
|
|
ModifiedAt: time.Now(),
|
|
})
|
|
p.sent = nil
|
|
e.cmdList(p, &Message{SessionKey: userKey, ReplyCtx: "ctx"}, nil)
|
|
if len(p.sent) != 1 {
|
|
t.Fatalf("expected 1 reply, got %d", len(p.sent))
|
|
}
|
|
if !strings.Contains(p.sent[0], "我的新会话") {
|
|
t.Errorf("/list should show session name '我的新会话':\n%s", p.sent[0])
|
|
}
|
|
}
|
|
|
|
// claudeCodeLikeSession simulates claudecode/gemini/cursor behavior:
|
|
// - CurrentSessionID() returns "" at creation
|
|
// - Send() emits an early EventText with SessionID (system/init event)
|
|
// - Then normal EventText without SessionID
|
|
// - Finally EventResult with SessionID
|
|
type claudeCodeLikeSession struct {
|
|
threadID string
|
|
events chan Event
|
|
alive bool
|
|
hasSentID bool
|
|
}
|
|
|
|
func newClaudeCodeLikeSession(threadID string) *claudeCodeLikeSession {
|
|
return &claudeCodeLikeSession{
|
|
threadID: threadID,
|
|
events: make(chan Event, 8),
|
|
alive: true,
|
|
}
|
|
}
|
|
|
|
func (s *claudeCodeLikeSession) Send(prompt string, _ []ImageAttachment, _ []FileAttachment) error {
|
|
s.hasSentID = true
|
|
// claudecode sends an early system event with SessionID (empty content)
|
|
s.events <- Event{Type: EventText, Content: "", SessionID: s.threadID}
|
|
// Normal streaming text (no SessionID)
|
|
s.events <- Event{Type: EventText, Content: "Reply to: " + prompt}
|
|
// Final result
|
|
s.events <- Event{Type: EventResult, SessionID: s.threadID, Content: "Done", Done: true}
|
|
return nil
|
|
}
|
|
func (s *claudeCodeLikeSession) RespondPermission(_ string, _ PermissionResult) error { return nil }
|
|
func (s *claudeCodeLikeSession) Events() <-chan Event { return s.events }
|
|
func (s *claudeCodeLikeSession) CurrentSessionID() string {
|
|
if s.hasSentID {
|
|
return s.threadID
|
|
}
|
|
return ""
|
|
}
|
|
func (s *claudeCodeLikeSession) Alive() bool { return s.alive }
|
|
func (s *claudeCodeLikeSession) Close() error { s.alive = false; return nil }
|
|
|
|
// TestSessionName_ClaudeCodeLikeFlow tests the claudecode/gemini/cursor pattern:
|
|
// CurrentSessionID()="" initially, but an early EventText carries SessionID.
|
|
func TestSessionName_ClaudeCodeLikeFlow(t *testing.T) {
|
|
sess := newClaudeCodeLikeSession("claude-session-001")
|
|
agent := &controllableAgent{nextSession: sess}
|
|
p := &stubPlatformEngine{n: "plain"}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
userKey := "test:user1"
|
|
|
|
initial := e.sessions.GetOrCreateActive(userKey)
|
|
initial.SetAgentSessionID("claude-session-old", "claudecode")
|
|
e.sessions.Save()
|
|
|
|
// /new with a custom name
|
|
e.cmdNew(p, &Message{SessionKey: userKey, ReplyCtx: "ctx"}, []string{"Claude任务"})
|
|
|
|
// Send message
|
|
e.ReceiveMessage(p, &Message{
|
|
SessionKey: userKey,
|
|
Content: "帮我重构代码",
|
|
ReplyCtx: "ctx2",
|
|
})
|
|
time.Sleep(200 * time.Millisecond)
|
|
|
|
// Verify session name mapped via EventText path
|
|
gotName := e.sessions.GetSessionName("claude-session-001")
|
|
if gotName != "Claude任务" {
|
|
t.Fatalf("GetSessionName(%q) = %q, want %q — claudecode-like EventText name mapping failed",
|
|
"claude-session-001", gotName, "Claude任务")
|
|
}
|
|
}
|
|
|
|
// acpLikeSession simulates ACP behavior:
|
|
// - CurrentSessionID() returns the thread ID immediately after creation
|
|
// (ACP does handshake before returning from StartSession)
|
|
type acpLikeSession struct {
|
|
threadID string
|
|
events chan Event
|
|
alive bool
|
|
}
|
|
|
|
func newACPLikeSession(threadID string) *acpLikeSession {
|
|
return &acpLikeSession{
|
|
threadID: threadID,
|
|
events: make(chan Event, 8),
|
|
alive: true,
|
|
}
|
|
}
|
|
|
|
func (s *acpLikeSession) Send(prompt string, _ []ImageAttachment, _ []FileAttachment) error {
|
|
s.events <- Event{Type: EventText, Content: "Reply", SessionID: s.threadID}
|
|
s.events <- Event{Type: EventResult, SessionID: s.threadID, Content: "Done", Done: true}
|
|
return nil
|
|
}
|
|
func (s *acpLikeSession) RespondPermission(_ string, _ PermissionResult) error { return nil }
|
|
func (s *acpLikeSession) Events() <-chan Event { return s.events }
|
|
func (s *acpLikeSession) CurrentSessionID() string { return s.threadID }
|
|
func (s *acpLikeSession) Alive() bool { return s.alive }
|
|
func (s *acpLikeSession) Close() error { s.alive = false; return nil }
|
|
|
|
// TestSessionName_ACPLikeFlow tests ACP pattern: CurrentSessionID() is non-empty
|
|
// immediately at creation, so name mapping happens in startOrResumeSession.
|
|
func TestSessionName_ACPLikeFlow(t *testing.T) {
|
|
sess := newACPLikeSession("acp-session-001")
|
|
agent := &controllableAgent{nextSession: sess}
|
|
p := &stubPlatformEngine{n: "plain"}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
userKey := "test:user1"
|
|
|
|
// /new with a custom name
|
|
e.cmdNew(p, &Message{SessionKey: userKey, ReplyCtx: "ctx"}, []string{"ACP任务"})
|
|
|
|
// Send message — startOrResumeSession should map the name immediately
|
|
e.ReceiveMessage(p, &Message{
|
|
SessionKey: userKey,
|
|
Content: "帮我部署",
|
|
ReplyCtx: "ctx2",
|
|
})
|
|
time.Sleep(200 * time.Millisecond)
|
|
|
|
gotName := e.sessions.GetSessionName("acp-session-001")
|
|
if gotName != "ACP任务" {
|
|
t.Fatalf("GetSessionName(%q) = %q, want %q — ACP-like immediate ID name mapping failed",
|
|
"acp-session-001", gotName, "ACP任务")
|
|
}
|
|
}
|
|
|
|
// TestBtwAlias_ResolvesToPs verifies that /btw is accepted as an alias for /ps.
|
|
func TestBtwAlias_ResolvesToPs(t *testing.T) {
|
|
id := matchPrefix("btw", builtinCommands)
|
|
if id != "ps" {
|
|
t.Fatalf("matchPrefix(\"btw\") = %q, want \"ps\"", id)
|
|
}
|
|
id2 := matchPrefix("ps", builtinCommands)
|
|
if id2 != "ps" {
|
|
t.Fatalf("matchPrefix(\"ps\") = %q, want \"ps\"", id2)
|
|
}
|
|
}
|
|
|
|
func TestHandlePendingPermission_AskQuestion_EmptyContentRejected(t *testing.T) {
|
|
// Regression test for #1086: empty or whitespace-only messages must NOT
|
|
// be accepted as AskUserQuestion answers. Some platforms deliver read-receipts
|
|
// or delivery notifications as empty messages within ~500ms; before this fix,
|
|
// they resolved the question with empty answers immediately.
|
|
e := newTestEngine()
|
|
|
|
session := &recordingAgentSession{}
|
|
pending := &pendingPermission{
|
|
RequestID: "req-askq",
|
|
Questions: testQuestions(),
|
|
Answers: map[int]string{},
|
|
Resolved: make(chan struct{}),
|
|
}
|
|
|
|
iKey := "ws:sk"
|
|
e.interactiveMu.Lock()
|
|
e.interactiveStates[iKey] = &interactiveState{
|
|
agentSession: session,
|
|
pending: pending,
|
|
}
|
|
e.interactiveMu.Unlock()
|
|
|
|
p := &stubPlatformEngine{n: "test"}
|
|
msg := &Message{SessionKey: "sk", ReplyCtx: "ctx"}
|
|
|
|
for _, emptyContent := range []string{"", " ", "\t", "\n"} {
|
|
if e.handlePendingPermission(p, msg, emptyContent, iKey) {
|
|
t.Errorf("handlePendingPermission(%q) = true, want false (empty answer must be rejected)", emptyContent)
|
|
}
|
|
select {
|
|
case <-pending.Resolved:
|
|
t.Errorf("AskUserQuestion resolved with empty content %q", emptyContent)
|
|
default:
|
|
}
|
|
if session.calls != 0 {
|
|
t.Errorf("RespondPermission called with empty content %q", emptyContent)
|
|
}
|
|
}
|
|
|
|
// A real answer should still work after the empty ones were rejected.
|
|
if !e.handlePendingPermission(p, msg, "1", iKey) {
|
|
t.Fatal("handlePendingPermission(\"1\") = false, want true")
|
|
}
|
|
if session.calls != 1 {
|
|
t.Fatalf("RespondPermission calls = %d, want 1", session.calls)
|
|
}
|
|
}
|
|
|
|
func TestMaybeAutoResetSessionOnIdle_UsesLastUserActivity(t *testing.T) {
|
|
// Regression test for #1115 Bug 2: maybeAutoResetSessionOnIdle must use
|
|
// LastUserActivity (only updated on real user messages) rather than
|
|
// UpdatedAt (bumped by every session.Unlock including heartbeats).
|
|
// Without the fix, automated activity (heartbeats, unsolicited agent output)
|
|
// would continuously bump UpdatedAt and prevent idle reset from ever firing.
|
|
e := newTestEngine()
|
|
e.SetResetOnIdle(30 * time.Minute)
|
|
|
|
sm := NewSessionManager(t.TempDir())
|
|
session := sm.GetOrCreateActive("user:sk")
|
|
// Simulate history so the session is eligible for reset.
|
|
session.AddHistory("user", "hello")
|
|
session.SetAgentSessionID("agent-id-1", "claudecode")
|
|
session.TryLock()
|
|
|
|
// Simulate that UpdatedAt is recent (heartbeat just updated it)
|
|
// but LastUserActivity is old (last real user message was 35 minutes ago).
|
|
old := time.Now().Add(-35 * time.Minute)
|
|
session.mu.Lock()
|
|
session.UpdatedAt = time.Now() // heartbeat bumped this just now
|
|
session.LastUserActivity = old // last real user message was 35 min ago
|
|
session.mu.Unlock()
|
|
|
|
p := &stubPlatformEngine{n: "test"}
|
|
msg := &Message{SessionKey: "sk", ReplyCtx: "ctx"}
|
|
|
|
rotated := e.maybeAutoResetSessionOnIdle(p, msg, sm, "ws:sk", session)
|
|
if rotated == nil {
|
|
t.Fatal("expected idle reset to fire because LastUserActivity is 35min ago, but it did not")
|
|
}
|
|
}
|
|
|
|
func TestMaybeAutoResetSessionOnIdle_NotFiredWhenUserActivityRecent(t *testing.T) {
|
|
// Complementary test: when LastUserActivity is recent, the reset must NOT fire
|
|
// even if UpdatedAt is also recent (normal case).
|
|
e := newTestEngine()
|
|
e.SetResetOnIdle(30 * time.Minute)
|
|
|
|
sm := NewSessionManager(t.TempDir())
|
|
session := sm.GetOrCreateActive("user:sk2")
|
|
session.AddHistory("user", "hello")
|
|
session.SetAgentSessionID("agent-id-2", "claudecode")
|
|
session.TryLock()
|
|
|
|
// LastUserActivity is only 5 minutes ago — should not idle-reset.
|
|
session.mu.Lock()
|
|
session.LastUserActivity = time.Now().Add(-5 * time.Minute)
|
|
session.mu.Unlock()
|
|
|
|
p := &stubPlatformEngine{n: "test"}
|
|
msg := &Message{SessionKey: "sk2", ReplyCtx: "ctx"}
|
|
|
|
rotated := e.maybeAutoResetSessionOnIdle(p, msg, sm, "ws:sk2", session)
|
|
if rotated != nil {
|
|
t.Fatal("expected no idle reset because LastUserActivity is only 5min ago")
|
|
}
|
|
}
|