* feat(core): relay unsolicited agent events between user turns Add a background goroutine per interactive session that consumes agent events produced between user-initiated turns (e.g. Claude Code background task completions via run_in_background). Previously these events piled up in the buffered channel and were unconditionally discarded by drainEvents() when the next user message arrived. Key changes: - Add startUnsolicitedReader/stopUnsolicitedReader with context-based lifecycle management and bounded handoff to foreground turns - Replace unconditional drainEvents with conditional resync via eventsNeedResync flag (default true, cleared on clean EventResult) - Integrate unsolicited reader teardown into all cleanup paths: cleanupInteractiveState, stopInteractiveSession, session recycle, /compress, and idle reaper - Track BeginTurn/EndTurn for unsolicited turns so idle reaper does not kill workspaces with active background processing - Make workspace pool idle timeout configurable via workspace_idle_timeout_mins in project config (was hardcoded 15min) - Export SetWorkspaceIdleTimeout and DefaultWorkspaceIdleTimeout Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test(core): add unit tests for unsolicited events feature Add 10 tests covering the unsolicited events implementation: - TestUnsolicitedReader_RelaysEventResult: verifies EventResult relay - TestUnsolicitedReader_StopsOnCancel: verifies clean goroutine shutdown - TestUnsolicitedReader_SetsResyncOnChannelClose: abnormal exit path - TestUnsolicitedReader_SetsResyncOnEventError: error event handling - TestUnsolicitedReader_PermissionDeny: permission auto-deny when no user - TestEventsNeedResync_DefaultTrue: constructor initialization - TestEventsNeedResync_ClearedOnCleanResult: clean EventResult path - TestCleanupInteractiveState_StopsUnsolicitedReader: cleanup teardown - TestWorkspaceIdleTimeout_Configurable: timeout config API - TestReapIdle_DisabledWhenZeroTimeout: zero timeout disables reaping Tests use event-driven synchronization (channel waits with timeouts) instead of fixed time.Sleep to avoid CI flakiness. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(core): stop unsolicited reader in drainOrphanedQueue to prevent race drainOrphanedQueue calls drainPendingMessages which reads from the Events() channel. Without stopping the unsolicited reader first, two goroutines can concurrently read from the same channel — a data race. Also restart the unsolicited reader after draining completes so background events continue to be relayed. Found during self-review concurrency audit. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test(integration): add end-to-end tests for unsolicited events Add two integration tests covering the full flow: TestIntegration_UnsolicitedEventsEndToEnd — verifies the happy path: 1. User sends msg → agent turn completes (EventResult) 2. Agent emits new events later (simulating run_in_background completion) → unsolicited reader relays them to platform 3. User sends second msg → conditional drain skipped (clean exit) → new turn response delivered correctly TestIntegration_StaleEventsDrainedAfterAbnormalExit — verifies the safety net: after EventError sets eventsNeedResync=true, buffered leftover events are drained on the next user message (not relayed as unsolicited, not attributed to the new turn). Uses a persistentEventsSession that keeps the Events() channel open across turns (unlike FakeAgentSession which closes per-call). Synchronization is event-driven via waitForMessage and waitForPromptCount — no fixed sleeps. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(core): address PR #608 review comments Addresses all five review comments from @chenhg5 on PR #608: 1. **[blocker]** Promote workspace_idle_timeout_mins to a global config. Adds top-level Config.WorkspaceIdleTimeoutMins; per-project ProjectConfig.WorkspaceIdleTimeoutMins retains precedence so existing configs keep working. main.go picks project > global > default. 2. Close the concurrent-reader window around stopUnsolicitedReader. After cancel + bounded 5s wait, the reader now double-checks ctx after reading an event — if cancellation happened between the outer select firing and the check, the event is dropped and resync is requested, preventing the old reader from silently consuming an event that belongs to the incoming foreground turn. The wait stays bounded because getOrCreateInteractiveStateWith and stopInteractiveSession call this while holding interactiveMu, and an unbounded wait there would stall unrelated sessions. 3. Notify the user on auto-deny. When approveAll=false, the reader now sends a localized MsgBackgroundAutoDenied message (EN/ZH/ZH-TW/JA/ES) identifying the requested tool so a silently blocked background task isn't invisible. RespondPermission is dispatched on a detached goroutine so the slow adapter call cannot block reader shutdown. 4. Accumulate and log tool events. EventToolUse / EventToolResult now record tool names and emit debug logs. If the channel closes mid-turn with buffered context, a warning log captures the tools/text that were lost — removes the misleading "accumulate silently" comments. 5. Document Session.AddHistory thread-safety. Session.AddHistory is already internally locked; stopUnsolicitedReader's bounded wait orders unsolicited-vs-foreground history writes. Added an explanatory comment so the invariant is explicit. Verified with go test -race ./... and go test -race -tags=integration ./tests/integration/. Reviewed end-to-end by Codex (gpt-5.3-codex, high reasoning effort) — no regressions flagged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(config): make workspace_idle_timeout_mins global-only Addresses the remaining blocker on PR #608: the maintainer asked for workspace_idle_timeout_mins to be a global config rather than per-project. Changes: - Remove ProjectConfig.WorkspaceIdleTimeoutMins as a supported per-project knob; the canonical setting is now the existing top-level Config.WorkspaceIdleTimeoutMins. - Keep parsing the legacy per-project key (renamed to WorkspaceIdleTimeoutMinsLegacy in code) so existing configs continue to work. When the top-level field is unset, the legacy per-project value is honored as a fallback and a deprecation warning is logged at startup. This avoids silently breaking deployments that used `0` to disable reaping or a custom timeout. - Clarify the comment on the global field to state that per-project configuration is intentionally not supported. Also commits the unsolicited-reader test additions in core/engine_test.go (437 lines covering reader relay, cancel/stop, channel close, error handling, permission deny, resync flag defaults/clearing, cleanup, and workspace idle timeout configurability) that landed alongside the prior review-fix commit but were not previously committed. Verified via `codex exec review --uncommitted` (gpt-5.3-codex). Build clean, all relevant tests pass; the 3 known macOS-specific pre-existing failures (TestProcessInteractiveEvents_AppendsReplyFooter*, TestResolveLocalDirPath_AcceptsSubdir) are out of scope. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
11181 lines
344 KiB
Go
11181 lines
344 KiB
Go
package core
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// --- stubs for Engine tests ---
|
|
|
|
type stubAgent struct{}
|
|
|
|
func (a *stubAgent) Name() string { return "stub" }
|
|
func (a *stubAgent) StartSession(_ context.Context, _ string) (AgentSession, error) {
|
|
return &stubAgentSession{}, nil
|
|
}
|
|
func (a *stubAgent) ListSessions(_ context.Context) ([]AgentSessionInfo, error) { return nil, nil }
|
|
func (a *stubAgent) Stop() error { return nil }
|
|
|
|
type stubAgentSession struct{}
|
|
|
|
func (s *stubAgentSession) Send(_ string, _ []ImageAttachment, _ []FileAttachment) error { return nil }
|
|
func (s *stubAgentSession) RespondPermission(_ string, _ PermissionResult) error { return nil }
|
|
func (s *stubAgentSession) Events() <-chan Event { return make(chan Event) }
|
|
func (s *stubAgentSession) CurrentSessionID() string { return "stub-session" }
|
|
func (s *stubAgentSession) Alive() bool { return true }
|
|
func (s *stubAgentSession) Close() error { return nil }
|
|
|
|
type recordingAgentSession struct {
|
|
stubAgentSession
|
|
lastID string
|
|
lastResult PermissionResult
|
|
calls int
|
|
}
|
|
|
|
func (s *recordingAgentSession) RespondPermission(id string, res PermissionResult) error {
|
|
s.lastID = id
|
|
s.lastResult = res
|
|
s.calls++
|
|
return nil
|
|
}
|
|
|
|
type stubPlatformEngine struct {
|
|
n string
|
|
sent []string
|
|
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 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) 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_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)
|
|
|
|
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_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)
|
|
|
|
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) {
|
|
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 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")
|
|
}
|
|
}
|
|
|
|
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.Name = "Focus"
|
|
session.SetAgentSessionID("session-123", "test")
|
|
session.History = append(session.History, HistoryEntry{Role: "user", Content: "hello", Timestamp: time.Now()})
|
|
|
|
e.cmdCurrent(p, msg)
|
|
|
|
if len(p.sent) != 1 {
|
|
t.Fatalf("sent messages = %d, want 1", len(p.sent))
|
|
}
|
|
if !strings.Contains(p.sent[0], "Current session") {
|
|
t.Fatalf("current text = %q, want legacy current session text", p.sent[0])
|
|
}
|
|
if strings.Contains(p.sent[0], "cc-connect") {
|
|
t.Fatalf("current text = %q, should not be card fallback title", p.sent[0])
|
|
}
|
|
}
|
|
|
|
func TestCmdDelete_BatchCommaList(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "plain"}
|
|
agent := &stubDeleteAgent{stubListAgent: stubListAgent{sessions: []AgentSessionInfo{
|
|
{ID: "session-1", Summary: "One"},
|
|
{ID: "session-2", Summary: "Two"},
|
|
{ID: "session-3", Summary: "Three"},
|
|
{ID: "session-4", Summary: "Four"},
|
|
}}}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
msg := &Message{SessionKey: "test:user1", ReplyCtx: "ctx"}
|
|
|
|
e.cmdDelete(p, msg, []string{"1,2,3"})
|
|
|
|
if got, want := strings.Join(agent.deleted, ","), "session-1,session-2,session-3"; got != want {
|
|
t.Fatalf("deleted = %q, want %q", got, want)
|
|
}
|
|
if len(p.sent) != 1 {
|
|
t.Fatalf("sent messages = %d, want 1", len(p.sent))
|
|
}
|
|
if !strings.Contains(p.sent[0], "Session deleted: One") || !strings.Contains(p.sent[0], "Session deleted: Three") {
|
|
t.Fatalf("reply = %q, want combined delete summary", p.sent[0])
|
|
}
|
|
}
|
|
|
|
func TestCmdDelete_BatchRange(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "plain"}
|
|
agent := &stubDeleteAgent{stubListAgent: stubListAgent{sessions: []AgentSessionInfo{
|
|
{ID: "session-1", Summary: "One"},
|
|
{ID: "session-2", Summary: "Two"},
|
|
{ID: "session-3", Summary: "Three"},
|
|
{ID: "session-4", Summary: "Four"},
|
|
{ID: "session-5", Summary: "Five"},
|
|
{ID: "session-6", Summary: "Six"},
|
|
{ID: "session-7", Summary: "Seven"},
|
|
{ID: "session-8", Summary: "Eight"},
|
|
}}}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
msg := &Message{SessionKey: "test:user1", ReplyCtx: "ctx"}
|
|
|
|
e.cmdDelete(p, msg, []string{"3-7"})
|
|
|
|
if got, want := strings.Join(agent.deleted, ","), "session-3,session-4,session-5,session-6,session-7"; got != want {
|
|
t.Fatalf("deleted = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestCmdDelete_BatchMixedSyntax(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "plain"}
|
|
agent := &stubDeleteAgent{stubListAgent: stubListAgent{sessions: []AgentSessionInfo{
|
|
{ID: "session-1", Summary: "One"},
|
|
{ID: "session-2", Summary: "Two"},
|
|
{ID: "session-3", Summary: "Three"},
|
|
{ID: "session-4", Summary: "Four"},
|
|
{ID: "session-5", Summary: "Five"},
|
|
{ID: "session-6", Summary: "Six"},
|
|
{ID: "session-7", Summary: "Seven"},
|
|
{ID: "session-8", Summary: "Eight"},
|
|
}}}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
msg := &Message{SessionKey: "test:user1", ReplyCtx: "ctx"}
|
|
|
|
e.cmdDelete(p, msg, []string{"1,3-5,8"})
|
|
|
|
if got, want := strings.Join(agent.deleted, ","), "session-1,session-3,session-4,session-5,session-8"; got != want {
|
|
t.Fatalf("deleted = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestCmdDelete_InvalidExplicitBatchSyntaxShowsUsage(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "plain"}
|
|
agent := &stubDeleteAgent{stubListAgent: stubListAgent{sessions: []AgentSessionInfo{
|
|
{ID: "session-1", Summary: "One"},
|
|
{ID: "session-2", Summary: "Two"},
|
|
{ID: "session-3", Summary: "Three"},
|
|
}}}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
msg := &Message{SessionKey: "test:user1", ReplyCtx: "ctx"}
|
|
|
|
e.cmdDelete(p, msg, []string{"1,3-a,8"})
|
|
|
|
if len(agent.deleted) != 0 {
|
|
t.Fatalf("deleted = %v, want none", agent.deleted)
|
|
}
|
|
if len(p.sent) != 1 || p.sent[0] != e.i18n.T(MsgDeleteUsage) {
|
|
t.Fatalf("sent = %v, want usage", p.sent)
|
|
}
|
|
}
|
|
|
|
func TestCmdDelete_WhitespaceSeparatedArgsAreRejected(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "plain"}
|
|
agent := &stubDeleteAgent{stubListAgent: stubListAgent{sessions: []AgentSessionInfo{
|
|
{ID: "session-1", Summary: "One"},
|
|
{ID: "session-2", Summary: "Two"},
|
|
{ID: "session-3", Summary: "Three"},
|
|
}}}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
msg := &Message{SessionKey: "test:user1", ReplyCtx: "ctx"}
|
|
|
|
e.cmdDelete(p, msg, []string{"1", "2", "3"})
|
|
|
|
if len(agent.deleted) != 0 {
|
|
t.Fatalf("deleted = %v, want none", agent.deleted)
|
|
}
|
|
if len(p.sent) != 1 || p.sent[0] != e.i18n.T(MsgDeleteUsage) {
|
|
t.Fatalf("sent = %v, want usage", p.sent)
|
|
}
|
|
}
|
|
|
|
func TestCmdDelete_SingleSessionPrefixStillWorks(t *testing.T) {
|
|
p := &stubPlatformEngine{n: "plain"}
|
|
agent := &stubDeleteAgent{stubListAgent: stubListAgent{sessions: []AgentSessionInfo{
|
|
{ID: "abc123456789", Summary: "One"},
|
|
{ID: "def987654321", Summary: "Two"},
|
|
}}}
|
|
e := NewEngine("test", agent, []Platform{p}, "", LangEnglish)
|
|
msg := &Message{SessionKey: "test:user1", ReplyCtx: "ctx"}
|
|
|
|
e.cmdDelete(p, msg, []string{"abc123"})
|
|
|
|
if got, want := strings.Join(agent.deleted, ","), "abc123456789"; got != want {
|
|
t.Fatalf("deleted = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestCmdDelete_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 != "" {
|
|
t.Fatalf("session id = %q, want cleared 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 != "" {
|
|
t.Fatalf("workspace session id = %q, want cleared", 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 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)
|
|
}
|
|
}
|
|
|
|
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{ThinkingMessages: true, ToolMessages: true, ThinkingMaxLen: 300, ToolMaxLen: 500})
|
|
msg := &Message{SessionKey: "test:user1", ReplyCtx: "ctx"}
|
|
|
|
// First /quiet: both on → both off (quiet ON)
|
|
e.cmdQuiet(p, msg, nil)
|
|
if e.display.ThinkingMessages || e.display.ToolMessages {
|
|
t.Fatalf("after first /quiet: ThinkingMessages=%v, ToolMessages=%v, want both false",
|
|
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)
|
|
}
|
|
|
|
// Second /quiet: both off → both on (quiet OFF)
|
|
p.sent = nil
|
|
e.cmdQuiet(p, msg, nil)
|
|
if !e.display.ThinkingMessages || !e.display.ToolMessages {
|
|
t.Fatalf("after second /quiet: ThinkingMessages=%v, ToolMessages=%v, want both true",
|
|
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)
|
|
}
|
|
}
|
|
|
|
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 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, testQuestions(), collected)
|
|
answers, ok := result["answers"].(map[string]any)
|
|
if !ok {
|
|
t.Fatal("expected answers map")
|
|
}
|
|
if answers["0"] != "PostgreSQL" {
|
|
t.Errorf("expected answer[0]=PostgreSQL, got %v", answers["0"])
|
|
}
|
|
if answers["1"] != "Gin" {
|
|
t.Errorf("expected answer[1]=Gin, got %v", answers["1"])
|
|
}
|
|
if _, ok := result["questions"]; !ok {
|
|
t.Error("expected original questions to be preserved")
|
|
}
|
|
}
|
|
|
|
func TestSendAskQuestionPrompt_CardPlatform(t *testing.T) {
|
|
e := newTestEngine()
|
|
p := &stubCardPlatform{stubPlatformEngine: stubPlatformEngine{n: "feishu"}}
|
|
e.sendAskQuestionPrompt(p, "ctx", testQuestions(), 0)
|
|
|
|
if len(p.sentCards) != 1 {
|
|
t.Fatalf("expected 1 card, got %d", len(p.sentCards))
|
|
}
|
|
card := p.sentCards[0]
|
|
if card.Header == nil || card.Header.Color != "blue" {
|
|
t.Errorf("expected blue header, got %+v", card.Header)
|
|
}
|
|
askqCount := countCardActionValues(card, "askq:")
|
|
if askqCount != 3 {
|
|
t.Errorf("expected 3 askq buttons, got %d", askqCount)
|
|
}
|
|
}
|
|
|
|
func TestSendAskQuestionPrompt_CardPlatform_MultiQuestion_ShowsIndex(t *testing.T) {
|
|
e := newTestEngine()
|
|
p := &stubCardPlatform{stubPlatformEngine: stubPlatformEngine{n: "feishu"}}
|
|
qs := testMultiQuestions()
|
|
e.sendAskQuestionPrompt(p, "ctx", qs, 0)
|
|
|
|
if len(p.sentCards) != 1 {
|
|
t.Fatalf("expected 1 card, got %d", len(p.sentCards))
|
|
}
|
|
card := p.sentCards[0]
|
|
if !strings.Contains(card.Header.Title, "(1/2)") {
|
|
t.Errorf("expected (1/2) in title, got %s", card.Header.Title)
|
|
}
|
|
}
|
|
|
|
func TestSendAskQuestionPrompt_InlineButtonPlatform(t *testing.T) {
|
|
e := newTestEngine()
|
|
p := &stubInlineButtonPlatform{stubPlatformEngine: stubPlatformEngine{n: "telegram"}}
|
|
e.sendAskQuestionPrompt(p, "ctx", testQuestions(), 0)
|
|
|
|
if len(p.buttonRows) != 3 {
|
|
t.Fatalf("expected 3 button rows, got %d", len(p.buttonRows))
|
|
}
|
|
if p.buttonRows[0][0].Data != "askq:0:1" {
|
|
t.Errorf("expected askq:0:1, got %s", p.buttonRows[0][0].Data)
|
|
}
|
|
}
|
|
|
|
func TestSendAskQuestionPrompt_PlainPlatform(t *testing.T) {
|
|
e := newTestEngine()
|
|
p := &stubPlatformEngine{n: "plain"}
|
|
e.sendAskQuestionPrompt(p, "ctx", testQuestions(), 0)
|
|
|
|
if len(p.sent) != 1 {
|
|
t.Fatal("expected 1 message")
|
|
}
|
|
msg := p.sent[0]
|
|
if !strings.Contains(msg, "Which database?") {
|
|
t.Errorf("expected question text, got %s", msg)
|
|
}
|
|
if !strings.Contains(msg, "1. **PostgreSQL**") {
|
|
t.Errorf("expected numbered options, got %s", msg)
|
|
}
|
|
}
|
|
|
|
func TestHandlePendingPermission_AskUserQuestion_SingleQuestion(t *testing.T) {
|
|
e := newTestEngine()
|
|
p := &stubPlatformEngine{n: "test"}
|
|
rec := &recordingAgentSession{}
|
|
|
|
state := &interactiveState{
|
|
agentSession: rec,
|
|
platform: p,
|
|
replyCtx: "ctx",
|
|
pending: &pendingPermission{
|
|
RequestID: "req-1",
|
|
ToolName: "AskUserQuestion",
|
|
ToolInput: map[string]any{
|
|
"questions": []any{map[string]any{"question": "Which?"}},
|
|
},
|
|
Questions: testQuestions(),
|
|
Resolved: make(chan struct{}),
|
|
},
|
|
}
|
|
e.interactiveMu.Lock()
|
|
e.interactiveStates["test:chat:user1"] = state
|
|
e.interactiveMu.Unlock()
|
|
|
|
handled := e.handlePendingPermission(p, &Message{
|
|
SessionKey: "test:chat:user1",
|
|
UserID: "user1",
|
|
Content: "2",
|
|
ReplyCtx: "ctx",
|
|
}, "2")
|
|
|
|
if !handled {
|
|
t.Fatal("expected handlePendingPermission to return true")
|
|
}
|
|
if rec.calls != 1 {
|
|
t.Fatalf("expected 1 RespondPermission call, got %d", rec.calls)
|
|
}
|
|
answers, ok := rec.lastResult.UpdatedInput["answers"].(map[string]any)
|
|
if !ok {
|
|
t.Fatal("expected answers in updatedInput")
|
|
}
|
|
if answers["0"] != "SQLite" {
|
|
t.Errorf("expected answer=SQLite, got %v", answers["0"])
|
|
}
|
|
|
|
state.mu.Lock()
|
|
if state.pending != nil {
|
|
t.Error("expected pending to be cleared after response")
|
|
}
|
|
state.mu.Unlock()
|
|
}
|
|
|
|
func TestHandlePendingPermission_AskUserQuestion_MultiQuestion_Sequential(t *testing.T) {
|
|
e := newTestEngine()
|
|
p := &stubPlatformEngine{n: "test"}
|
|
rec := &recordingAgentSession{}
|
|
|
|
qs := testMultiQuestions()
|
|
state := &interactiveState{
|
|
agentSession: rec,
|
|
platform: p,
|
|
replyCtx: "ctx",
|
|
pending: &pendingPermission{
|
|
RequestID: "req-1",
|
|
ToolName: "AskUserQuestion",
|
|
ToolInput: map[string]any{"questions": []any{}},
|
|
Questions: qs,
|
|
Resolved: make(chan struct{}),
|
|
},
|
|
}
|
|
e.interactiveMu.Lock()
|
|
e.interactiveStates["test:chat:user1"] = state
|
|
e.interactiveMu.Unlock()
|
|
|
|
// Answer question 0 — should NOT resolve yet
|
|
handled := e.handlePendingPermission(p, &Message{
|
|
SessionKey: "test:chat:user1",
|
|
UserID: "user1",
|
|
Content: "1",
|
|
ReplyCtx: "ctx",
|
|
}, "1")
|
|
if !handled {
|
|
t.Fatal("expected handled=true for question 0")
|
|
}
|
|
if rec.calls != 0 {
|
|
t.Fatalf("should not have called RespondPermission yet, got %d calls", rec.calls)
|
|
}
|
|
state.mu.Lock()
|
|
if state.pending == nil {
|
|
t.Fatal("pending should still exist (more questions)")
|
|
}
|
|
if state.pending.CurrentQuestion != 1 {
|
|
t.Errorf("expected CurrentQuestion=1, got %d", state.pending.CurrentQuestion)
|
|
}
|
|
state.mu.Unlock()
|
|
|
|
// Answer question 1 — should resolve
|
|
handled = e.handlePendingPermission(p, &Message{
|
|
SessionKey: "test:chat:user1",
|
|
UserID: "user1",
|
|
Content: "2",
|
|
ReplyCtx: "ctx",
|
|
}, "2")
|
|
if !handled {
|
|
t.Fatal("expected handled=true for question 1")
|
|
}
|
|
if rec.calls != 1 {
|
|
t.Fatalf("expected 1 RespondPermission call, got %d", rec.calls)
|
|
}
|
|
answers, ok := rec.lastResult.UpdatedInput["answers"].(map[string]any)
|
|
if !ok {
|
|
t.Fatal("expected answers in updatedInput")
|
|
}
|
|
if answers["0"] != "PostgreSQL" {
|
|
t.Errorf("expected answer[0]=PostgreSQL, got %v", answers["0"])
|
|
}
|
|
if answers["1"] != "Echo" {
|
|
t.Errorf("expected answer[1]=Echo, got %v", answers["1"])
|
|
}
|
|
|
|
state.mu.Lock()
|
|
if state.pending != nil {
|
|
t.Error("expected pending to be cleared after all questions answered")
|
|
}
|
|
state.mu.Unlock()
|
|
}
|
|
|
|
func TestHandlePendingPermission_AskUserQuestion_SkipsPermFlow(t *testing.T) {
|
|
e := newTestEngine()
|
|
p := &stubPlatformEngine{n: "test"}
|
|
rec := &recordingAgentSession{}
|
|
|
|
state := &interactiveState{
|
|
agentSession: rec,
|
|
platform: p,
|
|
replyCtx: "ctx",
|
|
pending: &pendingPermission{
|
|
RequestID: "req-1",
|
|
ToolName: "AskUserQuestion",
|
|
ToolInput: map[string]any{
|
|
"questions": []any{map[string]any{"question": "Which?"}},
|
|
},
|
|
Questions: testQuestions(),
|
|
Resolved: make(chan struct{}),
|
|
},
|
|
}
|
|
e.interactiveMu.Lock()
|
|
e.interactiveStates["test:chat:user1"] = state
|
|
e.interactiveMu.Unlock()
|
|
|
|
// "allow" should NOT be interpreted as permission allow; should be treated as free text answer
|
|
handled := e.handlePendingPermission(p, &Message{
|
|
SessionKey: "test:chat:user1",
|
|
UserID: "user1",
|
|
Content: "allow",
|
|
ReplyCtx: "ctx",
|
|
}, "allow")
|
|
|
|
if !handled {
|
|
t.Fatal("expected handled=true")
|
|
}
|
|
answers, ok := rec.lastResult.UpdatedInput["answers"].(map[string]any)
|
|
if !ok {
|
|
t.Fatal("expected answers in updatedInput")
|
|
}
|
|
if answers["0"] != "allow" {
|
|
t.Errorf("expected free text 'allow' as answer, got %v", answers["0"])
|
|
}
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────
|
|
// Session routing / cleanup CAS tests
|
|
// ──────────────────────────────────────────────────────────────
|
|
|
|
// controllableAgentSession is an AgentSession stub whose session ID, liveness,
|
|
// and events channel can be controlled by the test.
|
|
type controllableAgentSession struct {
|
|
sessionID string
|
|
alive bool
|
|
events chan Event
|
|
closed chan struct{} // closed when Close() is called
|
|
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)
|
|
}
|
|
|
|
func (a *controllableAgent) Name() string { return "controllable" }
|
|
func (a *controllableAgent) StartSession(_ context.Context, _ string) (AgentSession, error) {
|
|
if a.nextSession != nil {
|
|
return a.nextSession, nil
|
|
}
|
|
return newControllableSession("default"), nil
|
|
}
|
|
func (a *controllableAgent) ListSessions(_ context.Context) ([]AgentSessionInfo, error) {
|
|
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")
|
|
}
|
|
}
|
|
|
|
// 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))
|
|
}
|
|
}
|
|
|
|
// 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 != "" {
|
|
t.Fatalf("workspace session id = %q, want cleared", 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)
|
|
}
|
|
}
|
|
|
|
// --- 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 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:b:c:d", "b"},
|
|
}
|
|
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 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 {
|
|
output := sent[0]
|
|
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)
|
|
}
|
|
}
|
|
|
|
// --- /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)
|
|
|
|
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)
|
|
|
|
// 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)
|
|
|
|
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_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(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 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"},
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// ===========================================================================
|
|
// 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)
|
|
}
|
|
}
|