初始化仓库
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
//go:build blackbox
|
||||
|
||||
// This file registers all agent factories by importing the agent packages.
|
||||
// Without these blank imports, core.CreateAgent would return "unknown agent".
|
||||
// Each import triggers the package's init() function which calls core.RegisterAgent.
|
||||
package helper
|
||||
|
||||
import (
|
||||
_ "github.com/chenhg5/cc-connect/agent/claudecode"
|
||||
_ "github.com/chenhg5/cc-connect/agent/codex"
|
||||
_ "github.com/chenhg5/cc-connect/agent/cursor"
|
||||
_ "github.com/chenhg5/cc-connect/agent/gemini"
|
||||
_ "github.com/chenhg5/cc-connect/agent/opencode"
|
||||
_ "github.com/chenhg5/cc-connect/agent/qoder"
|
||||
)
|
||||
@@ -0,0 +1,443 @@
|
||||
// Package helper provides test environment setup for blackbox tests.
|
||||
//
|
||||
// BlackboxEnv wraps a real cc-connect Engine, a real Agent (Claude Code,
|
||||
// Codex, etc.), and a MockPlatform. Tests inject messages and assert on what
|
||||
// the platform receives — exactly what a real user would see.
|
||||
package helper
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/chenhg5/cc-connect/core"
|
||||
bbplatform "github.com/chenhg5/cc-connect/tests/blackbox/platform"
|
||||
)
|
||||
|
||||
const (
|
||||
// DefaultReplyTimeout is generous because real agents (Claude Code, etc.)
|
||||
// take 10-60 seconds to respond. Tests using the default timeout accept
|
||||
// that slowness is the price of real testing.
|
||||
DefaultReplyTimeout = 120 * time.Second
|
||||
|
||||
// DefaultUser and DefaultChat are used by Send / helpers that don't need
|
||||
// multi-user scenarios.
|
||||
DefaultUser = "user1"
|
||||
DefaultChat = "chat1"
|
||||
)
|
||||
|
||||
// Env is a fully wired blackbox test environment.
|
||||
// Always create via NewEnv — never construct directly.
|
||||
type Env struct {
|
||||
T *testing.T
|
||||
Platform *bbplatform.MockPlatform
|
||||
Engine *core.Engine
|
||||
WorkDir string
|
||||
DataDir string
|
||||
|
||||
// agentType is stored for diagnostic messages only.
|
||||
agentType string
|
||||
}
|
||||
|
||||
// NewEnv creates a blackbox test environment with a real agent.
|
||||
//
|
||||
// agentType selects the agent: "claudecode", "codex", "gemini", etc.
|
||||
// The test is skipped (not failed) when:
|
||||
// - the agent CLI binary is not in PATH
|
||||
// - the required API credentials are missing from the environment
|
||||
//
|
||||
// A successful NewEnv call registers a t.Cleanup that stops the engine.
|
||||
func NewEnv(t *testing.T, agentType string) *Env {
|
||||
return NewEnvWithSetup(t, agentType, nil)
|
||||
}
|
||||
|
||||
// NewEnvWithSetup is like NewEnv but accepts an optional setup function that
|
||||
// is called after the engine is created but BEFORE engine.Start(). Use this
|
||||
// to configure engine options (display, banned words, disabled commands, etc.)
|
||||
// that must be set before the engine begins processing messages.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// env := helper.NewEnvWithSetup(t, "claudecode", func(e *core.Engine) {
|
||||
// e.SetShowContextIndicator(false)
|
||||
// e.SetBannedWords([]string{"secret"})
|
||||
// })
|
||||
func NewEnvWithSetup(t *testing.T, agentType string, setup func(*core.Engine)) *Env {
|
||||
t.Helper()
|
||||
|
||||
requireAgent(t, agentType)
|
||||
|
||||
workDir := t.TempDir()
|
||||
dataDir := t.TempDir()
|
||||
|
||||
opts := map[string]any{
|
||||
"work_dir": workDir,
|
||||
}
|
||||
|
||||
// Cursor requires force/yolo mode in automated tests to bypass interactive
|
||||
// workspace-trust prompts. Without --force the agent exits immediately.
|
||||
if agentType == "cursor" {
|
||||
opts["mode"] = "force"
|
||||
}
|
||||
|
||||
applyProviderFromEnv(t, agentType, opts)
|
||||
|
||||
agent, err := core.CreateAgent(agentType, opts)
|
||||
if err != nil {
|
||||
t.Skipf("blackbox skip: cannot create %s agent: %v", agentType, err)
|
||||
}
|
||||
|
||||
wireProviders(t, agentType, agent)
|
||||
|
||||
mp := bbplatform.New(agentType + "-mock")
|
||||
|
||||
sessPath := filepath.Join(dataDir, "sessions.json")
|
||||
engine := core.NewEngine("blackbox-"+t.Name(), agent, []core.Platform{mp}, sessPath, core.LangEnglish)
|
||||
|
||||
if setup != nil {
|
||||
setup(engine)
|
||||
}
|
||||
|
||||
if err := engine.Start(); err != nil {
|
||||
t.Fatalf("blackbox: engine.Start failed: %v", err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
engine.Stop()
|
||||
agent.Stop()
|
||||
})
|
||||
|
||||
return &Env{
|
||||
T: t,
|
||||
Platform: mp,
|
||||
Engine: engine,
|
||||
WorkDir: workDir,
|
||||
DataDir: dataDir,
|
||||
agentType: agentType,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Message helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
// Send injects a message from DefaultUser in DefaultChat and waits up to
|
||||
// DefaultReplyTimeout for the first reply. Fails the test on timeout.
|
||||
func (e *Env) Send(content string) *bbplatform.SentMessage {
|
||||
e.T.Helper()
|
||||
return e.SendAs(DefaultUser, DefaultChat, content, DefaultReplyTimeout)
|
||||
}
|
||||
|
||||
// SendWithTimeout is like Send but uses a custom timeout.
|
||||
func (e *Env) SendWithTimeout(content string, timeout time.Duration) *bbplatform.SentMessage {
|
||||
e.T.Helper()
|
||||
return e.SendAs(DefaultUser, DefaultChat, content, timeout)
|
||||
}
|
||||
|
||||
// SendAs injects a message from a specific user/chat pair and waits for a
|
||||
// reply. Use this for multi-user isolation tests.
|
||||
func (e *Env) SendAs(userID, chatID, content string, timeout time.Duration) *bbplatform.SentMessage {
|
||||
e.T.Helper()
|
||||
before := e.Platform.MessageCount()
|
||||
e.Platform.InjectMessage(userID, chatID, content)
|
||||
reply := e.Platform.WaitForReply(before, timeout)
|
||||
if reply == nil {
|
||||
e.T.Fatalf(
|
||||
"blackbox timeout (%v): no reply to %q from %s/%s\nagent=%s\nall messages so far:\n%s",
|
||||
timeout, content, userID, chatID, e.agentType,
|
||||
e.Platform.AllText(),
|
||||
)
|
||||
}
|
||||
return reply
|
||||
}
|
||||
|
||||
// SendComplete injects a message and waits for the full agent turn to finish
|
||||
// (i.e., the message stream is stable for idlePeriod). Returns all messages
|
||||
// sent during the turn. Use this instead of Send when you need the turn to be
|
||||
// fully done before sending the next message (multi-turn context tests).
|
||||
func (e *Env) SendComplete(content string) []*bbplatform.SentMessage {
|
||||
e.T.Helper()
|
||||
return e.SendCompleteAs(DefaultUser, DefaultChat, content, 5*time.Second, DefaultReplyTimeout)
|
||||
}
|
||||
|
||||
// SendCompleteAs is like SendComplete but for a specific user/chat.
|
||||
func (e *Env) SendCompleteAs(userID, chatID, content string, idlePeriod, timeout time.Duration) []*bbplatform.SentMessage {
|
||||
e.T.Helper()
|
||||
before := e.Platform.MessageCount()
|
||||
e.Platform.InjectMessage(userID, chatID, content)
|
||||
msgs := e.Platform.WaitForTurnComplete(before, idlePeriod, timeout)
|
||||
if msgs == nil {
|
||||
e.T.Fatalf(
|
||||
"blackbox timeout (%v): no complete turn for %q from %s/%s\nagent=%s\nall messages so far:\n%s",
|
||||
timeout, content, userID, chatID, e.agentType,
|
||||
e.Platform.AllText(),
|
||||
)
|
||||
}
|
||||
return msgs
|
||||
}
|
||||
|
||||
// LastText returns the text of the last message in a turn's message slice.
|
||||
// Convenient for context retention assertions.
|
||||
func LastText(msgs []*bbplatform.SentMessage) string {
|
||||
if len(msgs) == 0 {
|
||||
return ""
|
||||
}
|
||||
return msgs[len(msgs)-1].Text()
|
||||
}
|
||||
|
||||
// AnyText returns all texts joined, for searching across multi-message turns.
|
||||
func AnyText(msgs []*bbplatform.SentMessage) string {
|
||||
parts := make([]string, len(msgs))
|
||||
for i, m := range msgs {
|
||||
parts[i] = m.Text()
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
// SendNoWait injects a message but does not wait for a reply.
|
||||
// Use for /stop scenarios where you send a command mid-processing.
|
||||
func (e *Env) SendNoWait(content string) {
|
||||
e.Platform.InjectMessage(DefaultUser, DefaultChat, content)
|
||||
}
|
||||
|
||||
// SendNoWaitAs injects from a specific user/chat without waiting.
|
||||
func (e *Env) SendNoWaitAs(userID, chatID, content string) {
|
||||
e.Platform.InjectMessage(userID, chatID, content)
|
||||
}
|
||||
|
||||
// ExpectReply waits for the next reply (after the current message count).
|
||||
// Fails the test if nothing arrives before timeout.
|
||||
func (e *Env) ExpectReply(timeout time.Duration) *bbplatform.SentMessage {
|
||||
e.T.Helper()
|
||||
before := e.Platform.MessageCount()
|
||||
reply := e.Platform.WaitForReply(before, timeout)
|
||||
if reply == nil {
|
||||
e.T.Fatalf(
|
||||
"blackbox timeout (%v): expected a reply\nagent=%s\nall messages so far:\n%s",
|
||||
timeout, e.agentType, e.Platform.AllText(),
|
||||
)
|
||||
}
|
||||
return reply
|
||||
}
|
||||
|
||||
// WaitForMessageContaining waits for any message (from startIdx) containing
|
||||
// substr (case-insensitive). Fails on timeout.
|
||||
func (e *Env) WaitForMessageContaining(startIdx int, substr string, timeout time.Duration) *bbplatform.SentMessage {
|
||||
e.T.Helper()
|
||||
msg := e.Platform.WaitForMessageContaining(startIdx, substr, timeout)
|
||||
if msg == nil {
|
||||
e.T.Fatalf(
|
||||
"blackbox timeout (%v): no message containing %q\nagent=%s\nall messages:\n%s",
|
||||
timeout, substr, e.agentType, e.Platform.AllText(),
|
||||
)
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
// SessionKey returns the session key for the default user/chat, matching the
|
||||
// format cc-connect uses internally: "<platform>:<chat>:<user>".
|
||||
func (e *Env) SessionKey() string {
|
||||
return fmt.Sprintf("%s:%s:%s", e.Platform.Name(), DefaultChat, DefaultUser)
|
||||
}
|
||||
|
||||
// SessionKeyFor returns the session key for a specific user/chat.
|
||||
func (e *Env) SessionKeyFor(userID, chatID string) string {
|
||||
return fmt.Sprintf("%s:%s:%s", e.Platform.Name(), chatID, userID)
|
||||
}
|
||||
|
||||
// ── Skip guards ───────────────────────────────────────────────────────────────
|
||||
|
||||
// requireAgent skips (never fails) if the agent binary or API credentials are
|
||||
// unavailable. The distinction between skip and fail is intentional: missing
|
||||
// credentials mean the test simply cannot run in this environment; a logical
|
||||
// assertion failure inside the test is a real failure.
|
||||
func requireAgent(t *testing.T, agentType string) {
|
||||
t.Helper()
|
||||
|
||||
// agentBinName returns the primary CLI binary name for the agent type.
|
||||
// The cursor type uses "agent" (from @anthropic-ai/cursor-agent), not
|
||||
// the Cursor IDE "cursor" binary.
|
||||
bin := agentBinName(agentType)
|
||||
if bin == "" {
|
||||
t.Skipf("blackbox skip: unknown agent type %q", agentType)
|
||||
}
|
||||
if _, err := exec.LookPath(bin); err != nil {
|
||||
// For cursor, also accept if the "cursor" IDE binary is present
|
||||
// (some setups install it under that name instead).
|
||||
if agentType == "cursor" {
|
||||
if _, err2 := exec.LookPath("cursor"); err2 != nil {
|
||||
t.Skipf("blackbox skip: cursor agent binary %q not in PATH (also checked 'cursor')", bin)
|
||||
}
|
||||
} else {
|
||||
t.Skipf("blackbox skip: %s binary %q not in PATH", agentType, bin)
|
||||
}
|
||||
}
|
||||
|
||||
switch agentType {
|
||||
case "claudecode":
|
||||
if os.Getenv("ANTHROPIC_API_KEY") == "" && !hasProviderEnv("claudecode") {
|
||||
t.Skipf("blackbox skip: ANTHROPIC_API_KEY not set")
|
||||
}
|
||||
case "codex":
|
||||
if os.Getenv("OPENAI_API_KEY") == "" && !hasProviderEnv("codex") {
|
||||
t.Skipf("blackbox skip: OPENAI_API_KEY not set")
|
||||
}
|
||||
case "gemini":
|
||||
if os.Getenv("GEMINI_API_KEY") == "" && os.Getenv("GOOGLE_API_KEY") == "" && !hasProviderEnv("gemini") {
|
||||
t.Skipf("blackbox skip: GEMINI_API_KEY or GOOGLE_API_KEY not set")
|
||||
}
|
||||
case "cursor":
|
||||
// Cursor can run without an explicit API key if the user is already
|
||||
// authenticated via `cursor login`. Only skip when the binary is absent
|
||||
// (handled above). Let it fail naturally with an auth error if needed.
|
||||
case "opencode":
|
||||
if os.Getenv("ANTHROPIC_API_KEY") == "" && !hasProviderEnv("opencode") {
|
||||
t.Skipf("blackbox skip: ANTHROPIC_API_KEY not set for opencode")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// hasProviderEnv checks if a CC_BLACKBOX_<AGENT>_API_KEY override is set.
|
||||
// Allows CI to inject credentials specifically for blackbox tests without
|
||||
// polluting the main API key env vars.
|
||||
func hasProviderEnv(agentType string) bool {
|
||||
key := "CC_BLACKBOX_" + strings.ToUpper(agentType) + "_API_KEY"
|
||||
return os.Getenv(key) != ""
|
||||
}
|
||||
|
||||
// applyProviderFromEnv injects API credentials into agent opts.
|
||||
// Preference order:
|
||||
// 1. CC_BLACKBOX_<AGENT>_BASE_URL + CC_BLACKBOX_<AGENT>_API_KEY (test-specific)
|
||||
// 2. Standard env vars (ANTHROPIC_API_KEY, OPENAI_API_KEY, etc.)
|
||||
func applyProviderFromEnv(t *testing.T, agentType string, opts map[string]any) {
|
||||
t.Helper()
|
||||
prefix := "CC_BLACKBOX_" + strings.ToUpper(agentType) + "_"
|
||||
|
||||
apiKey := os.Getenv(prefix + "API_KEY")
|
||||
baseURL := os.Getenv(prefix + "BASE_URL")
|
||||
model := os.Getenv(prefix + "MODEL")
|
||||
|
||||
if apiKey == "" {
|
||||
switch agentType {
|
||||
case "claudecode":
|
||||
apiKey = os.Getenv("ANTHROPIC_API_KEY")
|
||||
baseURL = os.Getenv("ANTHROPIC_BASE_URL")
|
||||
case "codex":
|
||||
apiKey = os.Getenv("OPENAI_API_KEY")
|
||||
baseURL = os.Getenv("OPENAI_BASE_URL")
|
||||
case "gemini":
|
||||
apiKey = firstNonEmpty(os.Getenv("GEMINI_API_KEY"), os.Getenv("GOOGLE_API_KEY"))
|
||||
case "opencode":
|
||||
apiKey = os.Getenv("ANTHROPIC_API_KEY")
|
||||
baseURL = os.Getenv("ANTHROPIC_BASE_URL")
|
||||
case "cursor":
|
||||
apiKey = firstNonEmpty(os.Getenv("CURSOR_API_KEY"), os.Getenv("ANTHROPIC_API_KEY"))
|
||||
}
|
||||
}
|
||||
|
||||
if apiKey != "" {
|
||||
opts["api_key"] = apiKey
|
||||
}
|
||||
if baseURL != "" {
|
||||
opts["base_url"] = baseURL
|
||||
}
|
||||
if model != "" {
|
||||
opts["model"] = model
|
||||
}
|
||||
}
|
||||
|
||||
// wireProviders calls SetProviders on the agent when CC_BLACKBOX_<AGENT>_API_KEY
|
||||
// is set, giving agents (e.g. claudecode) their credentials via the provider
|
||||
// interface rather than relying solely on ANTHROPIC_API_KEY env vars.
|
||||
//
|
||||
// Supported env vars (prefix = CC_BLACKBOX_<AGENTTYPE>_):
|
||||
//
|
||||
// <prefix>API_KEY — required (except cursor which can use local login)
|
||||
// <prefix>BASE_URL — provider base URL / proxy endpoint
|
||||
// <prefix>MODEL — model override
|
||||
// <prefix>WIRE_API — codex wire API format, e.g. "chat" or "responses"
|
||||
//
|
||||
// Agent-specific base URL injection:
|
||||
// - claudecode: base_url → stored in ProviderConfig.BaseURL (provider sets ANTHROPIC_BASE_URL)
|
||||
// - opencode: base_url → injected as ANTHROPIC_BASE_URL env var via ProviderConfig.Env
|
||||
// - cursor: no base_url injection (uses CURSOR_API_KEY or local auth)
|
||||
func wireProviders(t *testing.T, agentType string, agent core.Agent) {
|
||||
t.Helper()
|
||||
ps, ok := agent.(core.ProviderSwitcher)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
prefix := "CC_BLACKBOX_" + strings.ToUpper(agentType) + "_"
|
||||
apiKey := os.Getenv(prefix + "API_KEY")
|
||||
baseURL := os.Getenv(prefix + "BASE_URL")
|
||||
model := os.Getenv(prefix + "MODEL")
|
||||
wireAPI := os.Getenv(prefix + "WIRE_API")
|
||||
|
||||
// Skip if no explicit API key override (opencode and cursor can fall back
|
||||
// to their native auth from env or local login).
|
||||
if apiKey == "" && agentType != "cursor" && agentType != "opencode" {
|
||||
return
|
||||
}
|
||||
// For cursor/opencode with no API key, still wire a provider if we have model.
|
||||
if apiKey == "" && model == "" {
|
||||
return
|
||||
}
|
||||
|
||||
provider := core.ProviderConfig{
|
||||
Name: "blackbox-test",
|
||||
APIKey: apiKey,
|
||||
BaseURL: baseURL,
|
||||
Model: model,
|
||||
CodexWireAPI: wireAPI,
|
||||
}
|
||||
|
||||
// opencode's providerEnvLocked only injects APIKey (as ANTHROPIC_API_KEY)
|
||||
// and the Env map. To use a custom proxy (e.g. minimax), inject BaseURL as
|
||||
// ANTHROPIC_BASE_URL via the Env map so opencode picks it up.
|
||||
if agentType == "opencode" && baseURL != "" {
|
||||
if provider.Env == nil {
|
||||
provider.Env = make(map[string]string)
|
||||
}
|
||||
provider.Env["ANTHROPIC_BASE_URL"] = baseURL
|
||||
}
|
||||
|
||||
ps.SetProviders([]core.ProviderConfig{provider})
|
||||
ps.SetActiveProvider("blackbox-test")
|
||||
t.Logf("blackbox: wired provider base_url=%s model=%s wire_api=%s", baseURL, model, wireAPI)
|
||||
}
|
||||
|
||||
func agentBinName(agentType string) string {
|
||||
switch agentType {
|
||||
case "claudecode":
|
||||
return "claude"
|
||||
case "codex":
|
||||
return "codex"
|
||||
case "gemini":
|
||||
return "gemini"
|
||||
case "cursor":
|
||||
// Cursor Agent CLI installed via npm: @anthropic-ai/cursor-agent.
|
||||
// The binary is named "agent" by default; some installations also link
|
||||
// it as "cursor". We try "agent" first.
|
||||
return "agent"
|
||||
case "opencode":
|
||||
return "opencode"
|
||||
case "qoder":
|
||||
return "qoder"
|
||||
case "kimi":
|
||||
return "kimi"
|
||||
default:
|
||||
return agentType
|
||||
}
|
||||
}
|
||||
|
||||
func firstNonEmpty(vals ...string) string {
|
||||
for _, v := range vals {
|
||||
if v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
//go:build blackbox
|
||||
|
||||
// Package p0 contains P0 blackbox tests — the hard release gate.
|
||||
//
|
||||
// These tests use real agents (Claude Code, Codex, etc.) and a MockPlatform.
|
||||
// They are skipped when agent binaries or API credentials are unavailable,
|
||||
// but FAIL on any logical assertion failure.
|
||||
//
|
||||
// Run all P0 tests:
|
||||
//
|
||||
// go test -tags blackbox ./tests/blackbox/p0/... -timeout 1800s -v
|
||||
//
|
||||
// Run against a specific agent:
|
||||
//
|
||||
// CC_BLACKBOX_CLAUDECODE_API_KEY=xxx go test -tags blackbox \
|
||||
// ./tests/blackbox/p0/... -run ClaudeCode -timeout 1800s -v
|
||||
package p0
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/chenhg5/cc-connect/tests/blackbox/helper"
|
||||
)
|
||||
|
||||
// ── P0-1: 基础消息收发 ────────────────────────────────────────────────────────
|
||||
|
||||
// TestP0_1_BasicMessageFlow_ClaudeCode verifies the core message loop:
|
||||
// user sends a message → cc-connect routes it to Claude Code → user receives
|
||||
// a non-empty reply.
|
||||
//
|
||||
// This is the most fundamental P0 test. If this fails, nothing else matters.
|
||||
func TestP0_1_BasicMessageFlow_ClaudeCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
testBasicMessageFlow(t, "claudecode")
|
||||
}
|
||||
|
||||
func TestP0_1_BasicMessageFlow_Codex(t *testing.T) {
|
||||
t.Parallel()
|
||||
testBasicMessageFlow(t, "codex")
|
||||
}
|
||||
|
||||
// TestP0_1_BasicMessageFlow_Cursor tests the cursor agent (agent binary,
|
||||
// @anthropic-ai/cursor-agent). Set CC_BLACKBOX_CURSOR_API_KEY + optionally
|
||||
// CC_BLACKBOX_CURSOR_MODEL (e.g. "claude-sonnet-4-5") to run.
|
||||
// Cursor supports composer-2-fast via the model name; use a cheap model
|
||||
// for CI (e.g. "claude-haiku-3-5-20241022").
|
||||
func TestP0_1_BasicMessageFlow_Cursor(t *testing.T) {
|
||||
t.Parallel()
|
||||
testBasicMessageFlow(t, "cursor")
|
||||
}
|
||||
|
||||
// TestP0_1_BasicMessageFlow_OpenCode tests the opencode agent (opencode binary).
|
||||
// Set CC_BLACKBOX_OPENCODE_API_KEY and CC_BLACKBOX_OPENCODE_BASE_URL to use a
|
||||
// custom proxy (e.g. minimax, dragoncode) or set ANTHROPIC_API_KEY for direct
|
||||
// Anthropic API access.
|
||||
func TestP0_1_BasicMessageFlow_OpenCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
testBasicMessageFlow(t, "opencode")
|
||||
}
|
||||
|
||||
func testBasicMessageFlow(t *testing.T, agentType string) {
|
||||
t.Helper()
|
||||
env := helper.NewEnv(t, agentType)
|
||||
|
||||
reply := env.Send("say hi briefly")
|
||||
|
||||
if reply == nil {
|
||||
t.Fatal("no reply received")
|
||||
}
|
||||
if strings.TrimSpace(reply.Text()) == "" {
|
||||
t.Fatalf("received empty reply; all messages:\n%s", env.Platform.AllText())
|
||||
}
|
||||
t.Logf("P0-1 OK: received reply (%d chars): %q", len(reply.Text()), truncate(reply.Text(), 200))
|
||||
}
|
||||
|
||||
// ── P0-2: /new 创建新会话 ─────────────────────────────────────────────────────
|
||||
|
||||
// TestP0_2_NewSession_ClaudeCode verifies /new creates a new session and
|
||||
// cc-connect sends a confirmation message.
|
||||
func TestP0_2_NewSession_ClaudeCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
testNewSession(t, "claudecode")
|
||||
}
|
||||
|
||||
func testNewSession(t *testing.T, agentType string) {
|
||||
t.Helper()
|
||||
env := helper.NewEnv(t, agentType)
|
||||
|
||||
// First, establish a session by sending a real message.
|
||||
env.Send("say hello briefly")
|
||||
|
||||
// Now create a new session.
|
||||
before := env.Platform.MessageCount()
|
||||
env.SendNoWait("/new")
|
||||
|
||||
reply := env.Platform.WaitForReply(before, 30*time.Second)
|
||||
if reply == nil {
|
||||
t.Fatal("/new: no confirmation message received within 30s")
|
||||
}
|
||||
t.Logf("P0-2 OK: /new replied: %q", truncate(reply.Text(), 200))
|
||||
}
|
||||
|
||||
// ── P0-3: /list 显示会话列表 ──────────────────────────────────────────────────
|
||||
|
||||
// TestP0_3_ListSessions_ClaudeCode verifies /list returns a message containing
|
||||
// session information after at least one session has been created.
|
||||
func TestP0_3_ListSessions_ClaudeCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
testListSessions(t, "claudecode")
|
||||
}
|
||||
|
||||
func testListSessions(t *testing.T, agentType string) {
|
||||
t.Helper()
|
||||
env := helper.NewEnv(t, agentType)
|
||||
|
||||
// Establish a session.
|
||||
env.Send("say hello briefly")
|
||||
|
||||
// /list should return session information.
|
||||
reply := env.Send("/list")
|
||||
|
||||
text := strings.ToLower(reply.Text())
|
||||
// The response should reference sessions in some form.
|
||||
hasSessionInfo := strings.Contains(text, "session") ||
|
||||
strings.Contains(text, "会话") ||
|
||||
strings.Contains(text, "#1") ||
|
||||
strings.Contains(text, "no sessions") ||
|
||||
strings.Contains(text, "没有会话")
|
||||
|
||||
if !hasSessionInfo {
|
||||
t.Errorf("/list response doesn't look like a session list\ngot: %q\nall messages:\n%s",
|
||||
reply.Text(), env.Platform.AllText())
|
||||
}
|
||||
t.Logf("P0-3 OK: /list replied: %q", truncate(reply.Text(), 300))
|
||||
}
|
||||
|
||||
// ── P0-5: /stop 停止当前任务 ──────────────────────────────────────────────────
|
||||
|
||||
// TestP0_5_StopCurrentTask_ClaudeCode verifies /stop causes cc-connect to
|
||||
// reply within a reasonable time even while a task is running.
|
||||
//
|
||||
// Strategy: we don't send a genuinely long-running task (too unreliable);
|
||||
// instead we verify /stop itself produces a reply when no task is active,
|
||||
// which still confirms the command is handled.
|
||||
func TestP0_5_StopCurrentTask_ClaudeCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
testStop(t, "claudecode")
|
||||
}
|
||||
|
||||
func testStop(t *testing.T, agentType string) {
|
||||
t.Helper()
|
||||
env := helper.NewEnv(t, agentType)
|
||||
|
||||
// Establish a session first so /stop has context.
|
||||
env.Send("say hello briefly")
|
||||
|
||||
// Issue /stop and expect a timely response.
|
||||
reply := env.SendWithTimeout("/stop", 30*time.Second)
|
||||
|
||||
if strings.TrimSpace(reply.Text()) == "" {
|
||||
t.Fatalf("/stop produced empty reply; all messages:\n%s", env.Platform.AllText())
|
||||
}
|
||||
t.Logf("P0-5 OK: /stop replied: %q", truncate(reply.Text(), 200))
|
||||
}
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
func truncate(s string, max int) string {
|
||||
if len(s) <= max {
|
||||
return s
|
||||
}
|
||||
return s[:max] + "…"
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
//go:build blackbox
|
||||
|
||||
package p0
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/chenhg5/cc-connect/tests/blackbox/helper"
|
||||
)
|
||||
|
||||
// ── P0-11: 上下文保持(最核心场景)──────────────────────────────────────────
|
||||
//
|
||||
// This is arguably the most important test in the suite. It validates that:
|
||||
// 1. cc-connect maintains a persistent session across multiple messages
|
||||
// 2. the real agent (Claude Code) retains conversation context
|
||||
// 3. the session key routing is correct end-to-end
|
||||
//
|
||||
// If context retention breaks, users cannot have multi-turn conversations —
|
||||
// the entire product stops working for its core use case.
|
||||
|
||||
// TestP0_11_ContextRetention_ClaudeCode sends a name in turn 1 and verifies
|
||||
// the agent recalls it in turn 2. This is only possible with a real persistent
|
||||
// session — a fake agent would need the name hardcoded.
|
||||
func TestP0_11_ContextRetention_ClaudeCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
testContextRetention(t, "claudecode")
|
||||
}
|
||||
|
||||
func TestP0_11_ContextRetention_Codex(t *testing.T) {
|
||||
t.Parallel()
|
||||
testContextRetention(t, "codex")
|
||||
}
|
||||
|
||||
func TestP0_11_ContextRetention_Cursor(t *testing.T) {
|
||||
t.Parallel()
|
||||
testContextRetention(t, "cursor")
|
||||
}
|
||||
|
||||
func TestP0_11_ContextRetention_OpenCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
testContextRetention(t, "opencode")
|
||||
}
|
||||
|
||||
func testContextRetention(t *testing.T, agentType string) {
|
||||
t.Helper()
|
||||
env := helper.NewEnv(t, agentType)
|
||||
|
||||
// Turn 1: plant a unique fact. Use SendComplete so we wait for the full
|
||||
// agent turn before sending turn 2 — otherwise the second message gets
|
||||
// queued while the agent is still processing and loses context.
|
||||
const uniqueName = "ContextTestMarker42"
|
||||
turn1 := env.SendComplete("My name is " + uniqueName + ". Just acknowledge it briefly.")
|
||||
|
||||
turn1Text := helper.AnyText(turn1)
|
||||
if strings.TrimSpace(turn1Text) == "" {
|
||||
t.Fatalf("turn 1: empty reply; all messages:\n%s", env.Platform.AllText())
|
||||
}
|
||||
t.Logf("turn 1 (%d msgs): %q", len(turn1), truncate(turn1Text, 200))
|
||||
|
||||
// Turn 2: ask the agent to recall the fact.
|
||||
turn2 := env.SendComplete("What is my name? Reply with just the name.")
|
||||
|
||||
turn2Text := helper.AnyText(turn2)
|
||||
if strings.TrimSpace(turn2Text) == "" {
|
||||
t.Fatalf("turn 2: empty reply; all messages:\n%s", env.Platform.AllText())
|
||||
}
|
||||
t.Logf("turn 2 (%d msgs): %q", len(turn2), truncate(turn2Text, 200))
|
||||
|
||||
// The agent must recall the name planted in turn 1.
|
||||
if !strings.Contains(strings.ToLower(turn2Text), strings.ToLower(uniqueName)) {
|
||||
t.Errorf(
|
||||
"context retention FAILED: turn 2 does not contain %q\n"+
|
||||
"turn 1: %q\n"+
|
||||
"turn 2: %q\n"+
|
||||
"all messages:\n%s",
|
||||
uniqueName, turn1Text, turn2Text, env.Platform.AllText(),
|
||||
)
|
||||
} else {
|
||||
t.Logf("P0-11 OK: agent recalled %q in turn 2", uniqueName)
|
||||
}
|
||||
}
|
||||
|
||||
// TestP0_11_ContextIsolation_ClaudeCode verifies that two different users
|
||||
// (different session keys) have completely independent sessions.
|
||||
// User A's context must NOT appear in User B's session.
|
||||
func TestP0_11_ContextIsolation_ClaudeCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
testContextIsolation(t, "claudecode")
|
||||
}
|
||||
|
||||
func testContextIsolation(t *testing.T, agentType string) {
|
||||
t.Helper()
|
||||
env := helper.NewEnv(t, agentType)
|
||||
|
||||
const nameA = "AliceIsolationTest"
|
||||
const nameB = "BobIsolationTest"
|
||||
|
||||
// User A plants their name (wait for full turn).
|
||||
msgsA1 := env.SendCompleteAs("userA", "chat1", "My name is "+nameA+". Just say ok.", 5*time.Second, helper.DefaultReplyTimeout)
|
||||
t.Logf("userA turn 1: %q", truncate(helper.AnyText(msgsA1), 150))
|
||||
|
||||
// User B (different session) plants their name (wait for full turn).
|
||||
msgsB1 := env.SendCompleteAs("userB", "chat1", "My name is "+nameB+". Just say ok.", 5*time.Second, helper.DefaultReplyTimeout)
|
||||
t.Logf("userB turn 1: %q", truncate(helper.AnyText(msgsB1), 150))
|
||||
|
||||
// User A asks — should see only their own name.
|
||||
msgsA2 := env.SendCompleteAs("userA", "chat1", "What is my name?", 5*time.Second, helper.DefaultReplyTimeout)
|
||||
textA2 := helper.AnyText(msgsA2)
|
||||
t.Logf("userA turn 2: %q", truncate(textA2, 150))
|
||||
|
||||
if !strings.Contains(strings.ToLower(textA2), strings.ToLower(nameA)) {
|
||||
t.Errorf("userA context lost: expected %q in reply, got: %q", nameA, textA2)
|
||||
}
|
||||
if strings.Contains(strings.ToLower(textA2), strings.ToLower(nameB)) {
|
||||
t.Errorf("session leak: userA reply contains userB's name %q: %q", nameB, textA2)
|
||||
}
|
||||
t.Logf("P0-11 isolation OK")
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
//go:build blackbox
|
||||
|
||||
package p0
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/chenhg5/cc-connect/tests/blackbox/helper"
|
||||
)
|
||||
|
||||
// ── P0-14: 工具调用 ───────────────────────────────────────────────────────────
|
||||
|
||||
// TestP0_14_ToolInvocation_ClaudeCode verifies that cc-connect routes a task
|
||||
// requiring tool use to the agent and the agent successfully executes it.
|
||||
// A real agent calling real tools is the only way to verify this end-to-end.
|
||||
func TestP0_14_ToolInvocation_ClaudeCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
testToolInvocation(t, "claudecode")
|
||||
}
|
||||
|
||||
func testToolInvocation(t *testing.T, agentType string) {
|
||||
t.Helper()
|
||||
env := helper.NewEnv(t, agentType)
|
||||
|
||||
// Ask something that requires a Bash/filesystem tool call.
|
||||
msgs := env.SendComplete("List the files in the current directory using a shell command. Just show me the output.")
|
||||
|
||||
combined := helper.AnyText(msgs)
|
||||
if strings.TrimSpace(combined) == "" {
|
||||
t.Fatalf("empty reply; all messages:\n%s", env.Platform.AllText())
|
||||
}
|
||||
|
||||
// The agent should have invoked a tool — expect a tool-call indicator or
|
||||
// file listing output. We check for the Tool indicator message or any
|
||||
// file/directory names in the response.
|
||||
hasToolEvidence := strings.Contains(combined, "🔧") ||
|
||||
strings.Contains(combined, "Tool") ||
|
||||
strings.Contains(combined, "Bash") ||
|
||||
strings.Contains(combined, ".go") ||
|
||||
strings.Contains(combined, "go.mod") ||
|
||||
strings.Contains(combined, "main") ||
|
||||
strings.Contains(combined, "ls") ||
|
||||
strings.Contains(strings.ToLower(combined), "file") ||
|
||||
strings.Contains(strings.ToLower(combined), "directory") ||
|
||||
strings.Contains(strings.ToLower(combined), "目录") ||
|
||||
strings.Contains(strings.ToLower(combined), "文件")
|
||||
|
||||
if !hasToolEvidence {
|
||||
t.Errorf("P0-14: no evidence of tool invocation in response\nall messages:\n%s", env.Platform.AllText())
|
||||
} else {
|
||||
t.Logf("P0-14 OK: tool invocation detected (%d msgs, %d chars)", len(msgs), len(combined))
|
||||
}
|
||||
}
|
||||
|
||||
// ── P0-15: 长消息流式输出 ─────────────────────────────────────────────────────
|
||||
|
||||
// TestP0_15_LongResponse_ClaudeCode verifies that cc-connect correctly handles
|
||||
// a long agent response without truncation or corruption. The agent must
|
||||
// produce a substantive response (≥ 200 chars total).
|
||||
func TestP0_15_LongResponse_ClaudeCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
testLongResponse(t, "claudecode")
|
||||
}
|
||||
|
||||
func testLongResponse(t *testing.T, agentType string) {
|
||||
t.Helper()
|
||||
env := helper.NewEnv(t, agentType)
|
||||
|
||||
msgs := env.SendComplete("Write a short paragraph (about 150 words) explaining what software testing is. Write in plain text.")
|
||||
|
||||
combined := helper.AnyText(msgs)
|
||||
if len(strings.TrimSpace(combined)) < 200 {
|
||||
t.Errorf("P0-15: response too short (%d chars); expected ≥ 200\nall messages:\n%s",
|
||||
len(combined), env.Platform.AllText())
|
||||
} else {
|
||||
t.Logf("P0-15 OK: long response received (%d msgs, %d chars)", len(msgs), len(combined))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
//go:build blackbox
|
||||
|
||||
package p1
|
||||
|
||||
import (
|
||||
_ "github.com/chenhg5/cc-connect/agent/claudecode"
|
||||
_ "github.com/chenhg5/cc-connect/agent/codex"
|
||||
_ "github.com/chenhg5/cc-connect/agent/cursor"
|
||||
_ "github.com/chenhg5/cc-connect/agent/gemini"
|
||||
_ "github.com/chenhg5/cc-connect/agent/opencode"
|
||||
_ "github.com/chenhg5/cc-connect/agent/qoder"
|
||||
)
|
||||
@@ -0,0 +1,209 @@
|
||||
//go:build blackbox
|
||||
|
||||
// Package p1 contains P1 blackbox tests.
|
||||
//
|
||||
// Session commands (/help, /current, /name, /switch, /delete, /status, /version)
|
||||
// are dispatched by cc-connect's engine directly, so they respond within
|
||||
// seconds regardless of agent speed.
|
||||
//
|
||||
// Run:
|
||||
//
|
||||
// CC_BLACKBOX_CLAUDECODE_API_KEY=xxx \
|
||||
// CC_BLACKBOX_CLAUDECODE_BASE_URL=https://... \
|
||||
// go test -tags blackbox ./tests/blackbox/p1/... -timeout 1200s -v
|
||||
package p1
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/chenhg5/cc-connect/core"
|
||||
"github.com/chenhg5/cc-connect/tests/blackbox/helper"
|
||||
bbplatform "github.com/chenhg5/cc-connect/tests/blackbox/platform"
|
||||
)
|
||||
|
||||
const cmdTimeout = 30 * time.Second // engine-handled commands are near-instant
|
||||
|
||||
// ── P1-1: /help ──────────────────────────────────────────────────────────────
|
||||
|
||||
func TestP1_1_Help_ClaudeCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := helper.NewEnv(t, "claudecode")
|
||||
reply := env.SendWithTimeout("/help", cmdTimeout)
|
||||
assertContainsAny(t, "P1-1 /help", reply.Text(),
|
||||
"/new", "/list", "/stop", "/help", "Available", "命令", "Command")
|
||||
t.Logf("P1-1 OK: %q", truncate(reply.Text(), 200))
|
||||
}
|
||||
|
||||
// ── P1-5: /current ───────────────────────────────────────────────────────────
|
||||
|
||||
func TestP1_5_Current_ClaudeCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := helper.NewEnv(t, "claudecode")
|
||||
env.Send("say hi briefly")
|
||||
|
||||
reply := env.SendWithTimeout("/current", cmdTimeout)
|
||||
assertContainsAny(t, "P1-5 /current", strings.ToLower(reply.Text()),
|
||||
"session", "会话", "#1", "s1")
|
||||
t.Logf("P1-5 OK: %q", truncate(reply.Text(), 200))
|
||||
}
|
||||
|
||||
// ── P1-7: /name ──────────────────────────────────────────────────────────────
|
||||
|
||||
func TestP1_7_Name_ClaudeCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := helper.NewEnv(t, "claudecode")
|
||||
env.Send("say hi briefly")
|
||||
|
||||
const newName = "blackbox-rename-test"
|
||||
env.SendWithTimeout("/name "+newName, cmdTimeout)
|
||||
listReply := env.SendWithTimeout("/list", cmdTimeout)
|
||||
|
||||
if !strings.Contains(listReply.Text(), newName) {
|
||||
t.Errorf("P1-7 /name: %q not in /list\n%q", newName, listReply.Text())
|
||||
}
|
||||
t.Logf("P1-7 OK: /list shows renamed session %q", newName)
|
||||
}
|
||||
|
||||
// ── P1-4: /switch ────────────────────────────────────────────────────────────
|
||||
|
||||
func TestP1_4_Switch_ClaudeCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := helper.NewEnv(t, "claudecode")
|
||||
|
||||
env.Send("say hi briefly")
|
||||
env.SendWithTimeout("/new", cmdTimeout)
|
||||
|
||||
listReply := env.SendWithTimeout("/list", cmdTimeout)
|
||||
if strings.Count(listReply.Text(), "msgs") < 2 {
|
||||
t.Skipf("could not establish 2 sessions; /list: %q", listReply.Text())
|
||||
}
|
||||
|
||||
switchReply := env.SendWithTimeout("/switch 1", cmdTimeout)
|
||||
t.Logf("/switch 1: %q", truncate(switchReply.Text(), 150))
|
||||
|
||||
currentReply := env.SendWithTimeout("/current", cmdTimeout)
|
||||
t.Logf("P1-4 OK: /current after switch: %q", truncate(currentReply.Text(), 150))
|
||||
}
|
||||
|
||||
// ── P1-6: /delete ────────────────────────────────────────────────────────────
|
||||
|
||||
func TestP1_6_Delete_ClaudeCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := helper.NewEnv(t, "claudecode")
|
||||
|
||||
env.Send("say hi briefly")
|
||||
env.SendWithTimeout("/new", cmdTimeout)
|
||||
env.Send("say hello briefly")
|
||||
env.SendWithTimeout("/new", cmdTimeout)
|
||||
|
||||
env.SendWithTimeout("/delete 2", cmdTimeout)
|
||||
|
||||
listReply := env.SendWithTimeout("/list", cmdTimeout)
|
||||
remaining := strings.Count(listReply.Text(), "msgs")
|
||||
if remaining != 2 {
|
||||
t.Errorf("P1-6 /delete: expected 2 sessions, got %d\n/list: %q", remaining, listReply.Text())
|
||||
}
|
||||
t.Logf("P1-6 OK: 2 sessions remain after deleting #2")
|
||||
}
|
||||
|
||||
// ── P1-9: /status ────────────────────────────────────────────────────────────
|
||||
|
||||
func TestP1_9_Status_ClaudeCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := helper.NewEnv(t, "claudecode")
|
||||
reply := env.SendWithTimeout("/status", cmdTimeout)
|
||||
assertContainsAny(t, "P1-9 /status", strings.ToLower(reply.Text()),
|
||||
"agent", "project", "session", "claudecode", "状态")
|
||||
t.Logf("P1-9 OK: %q", truncate(reply.Text(), 200))
|
||||
}
|
||||
|
||||
// ── P1-10: /version ──────────────────────────────────────────────────────────
|
||||
|
||||
func TestP1_10_Version_ClaudeCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := helper.NewEnv(t, "claudecode")
|
||||
reply := env.SendWithTimeout("/version", cmdTimeout)
|
||||
// VersionInfo is set by main at startup; in tests it's empty string "".
|
||||
// The command must still produce a reply (even if content is empty in tests),
|
||||
// confirming the command is dispatched without error.
|
||||
if reply == nil {
|
||||
t.Fatal("P1-10 /version: no reply received")
|
||||
}
|
||||
// In production, VersionInfo contains a version string like "v1.3.x".
|
||||
// In test environment it's empty — this is expected.
|
||||
t.Logf("P1-10 OK: /version replied (content: %q)", truncate(reply.Text(), 100))
|
||||
}
|
||||
|
||||
// ── P1-14: 消息排队 ───────────────────────────────────────────────────────────
|
||||
|
||||
func TestP1_14_MessageQueuing_ClaudeCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := helper.NewEnv(t, "claudecode")
|
||||
|
||||
// Fire 3 messages rapidly while the agent may still be starting.
|
||||
env.Platform.InjectMessage(helper.DefaultUser, helper.DefaultChat, "reply with only the word: FIRST")
|
||||
env.Platform.InjectMessage(helper.DefaultUser, helper.DefaultChat, "reply with only the word: SECOND")
|
||||
env.Platform.InjectMessage(helper.DefaultUser, helper.DefaultChat, "reply with only the word: THIRD")
|
||||
|
||||
// Wait until all 3 keywords appear in the combined output.
|
||||
// We can't predict exact message count due to thinking/queue notifications.
|
||||
deadline := time.Now().Add(3 * helper.DefaultReplyTimeout)
|
||||
for {
|
||||
combined := strings.ToUpper(env.Platform.AllText())
|
||||
allFound := strings.Contains(combined, "FIRST") &&
|
||||
strings.Contains(combined, "SECOND") &&
|
||||
strings.Contains(combined, "THIRD")
|
||||
if allFound {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("P1-14: timeout waiting for FIRST/SECOND/THIRD\nall messages:\n%s",
|
||||
env.Platform.AllText())
|
||||
}
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
t.Logf("P1-14 OK: all 3 queued messages processed\nall messages:\n%s", env.Platform.AllText())
|
||||
}
|
||||
|
||||
// ── shared helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
func assertContainsAny(t *testing.T, label, text string, candidates ...string) {
|
||||
t.Helper()
|
||||
lower := strings.ToLower(text)
|
||||
for _, c := range candidates {
|
||||
if strings.Contains(lower, strings.ToLower(c)) {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Errorf("%s: none of %v found\ngot: %q", label, candidates, text)
|
||||
}
|
||||
|
||||
func truncate(s string, max int) string {
|
||||
if len(s) <= max {
|
||||
return s
|
||||
}
|
||||
return s[:max] + "…"
|
||||
}
|
||||
|
||||
// restartedEngine stops env.Engine and returns a new Engine + MockPlatform
|
||||
// that share the same session store — simulating a service restart.
|
||||
func restartedEngine(t *testing.T, agentType, dataDir string) (*core.Engine, *bbplatform.MockPlatform) {
|
||||
t.Helper()
|
||||
opts := map[string]any{"work_dir": t.TempDir()}
|
||||
agent, err := core.CreateAgent(agentType, opts)
|
||||
if err != nil {
|
||||
t.Skipf("restart skip: cannot create %s agent: %v", agentType, err)
|
||||
}
|
||||
|
||||
mp := bbplatform.New(agentType + "-restarted")
|
||||
sessPath := filepath.Join(dataDir, "sessions.json")
|
||||
engine := core.NewEngine("blackbox-restarted", agent, []core.Platform{mp}, sessPath, core.LangEnglish)
|
||||
if err := engine.Start(); err != nil {
|
||||
t.Fatalf("restarted engine Start failed: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { engine.Stop(); agent.Stop() })
|
||||
return engine, mp
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
//go:build blackbox
|
||||
|
||||
package p1
|
||||
|
||||
// Session visibility regression suite — P1-30 to P1-40.
|
||||
//
|
||||
// These tests reproduce bugs from v1.3.1:
|
||||
// - /list lost sessions after /new
|
||||
// - /new xxx names didn't persist
|
||||
// - /switch caused sessions to disappear
|
||||
//
|
||||
// Per checklist: ALL P1-30..40 tests must run on BOTH claudecode AND codex.
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/chenhg5/cc-connect/tests/blackbox/helper"
|
||||
)
|
||||
|
||||
// ── P1-30: 历史会话全部可见 ───────────────────────────────────────────────────
|
||||
|
||||
func TestP1_30_HistorySessionsVisible_ClaudeCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
testHistorySessionsVisible(t, "claudecode")
|
||||
}
|
||||
|
||||
func TestP1_30_HistorySessionsVisible_Codex(t *testing.T) {
|
||||
t.Parallel()
|
||||
testHistorySessionsVisible(t, "codex")
|
||||
}
|
||||
|
||||
func testHistorySessionsVisible(t *testing.T, agentType string) {
|
||||
t.Helper()
|
||||
env := helper.NewEnv(t, agentType)
|
||||
|
||||
env.Send("say ALPHA briefly")
|
||||
env.SendWithTimeout("/new", cmdTimeout)
|
||||
env.Send("say BETA briefly")
|
||||
env.SendWithTimeout("/new", cmdTimeout)
|
||||
env.Send("say GAMMA briefly")
|
||||
|
||||
listReply := env.SendWithTimeout("/list", cmdTimeout)
|
||||
count := strings.Count(listReply.Text(), "msgs")
|
||||
if count < 3 {
|
||||
t.Errorf("P1-30: expected ≥3 sessions, got %d\n/list: %q", count, listReply.Text())
|
||||
}
|
||||
t.Logf("P1-30 OK: %d sessions visible in /list", count)
|
||||
}
|
||||
|
||||
// ── P1-31: /new 后 /list 完整 ─────────────────────────────────────────────────
|
||||
|
||||
func TestP1_31_NewThenListComplete_ClaudeCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
testNewThenListComplete(t, "claudecode")
|
||||
}
|
||||
|
||||
func TestP1_31_NewThenListComplete_Codex(t *testing.T) {
|
||||
t.Parallel()
|
||||
testNewThenListComplete(t, "codex")
|
||||
}
|
||||
|
||||
func testNewThenListComplete(t *testing.T, agentType string) {
|
||||
t.Helper()
|
||||
env := helper.NewEnv(t, agentType)
|
||||
|
||||
// Session 1: wait for full agent turn before creating session 2.
|
||||
env.SendComplete("say hi briefly")
|
||||
|
||||
env.SendWithTimeout("/new", cmdTimeout)
|
||||
env.SendComplete("say hello briefly")
|
||||
|
||||
listReply := env.SendWithTimeout("/list", cmdTimeout)
|
||||
count := strings.Count(listReply.Text(), "msgs")
|
||||
if count < 2 {
|
||||
t.Errorf("P1-31: expected ≥2 sessions after /new, got %d\n/list: %q", count, listReply.Text())
|
||||
}
|
||||
t.Logf("P1-31 OK: %d sessions after /new + message", count)
|
||||
}
|
||||
|
||||
// ── P1-32: /new xxx 命名生效 ─────────────────────────────────────────────────
|
||||
|
||||
func TestP1_32_NewWithNaming_ClaudeCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
testNewWithNaming(t, "claudecode")
|
||||
}
|
||||
|
||||
func TestP1_32_NewWithNaming_Codex(t *testing.T) {
|
||||
t.Parallel()
|
||||
testNewWithNaming(t, "codex")
|
||||
}
|
||||
|
||||
func testNewWithNaming(t *testing.T, agentType string) {
|
||||
t.Helper()
|
||||
env := helper.NewEnv(t, agentType)
|
||||
|
||||
const sessionName = "regression-name-test"
|
||||
env.SendWithTimeout("/new "+sessionName, cmdTimeout)
|
||||
env.SendComplete("say ok briefly")
|
||||
|
||||
listReply := env.SendWithTimeout("/list", cmdTimeout)
|
||||
if !strings.Contains(listReply.Text(), sessionName) {
|
||||
t.Errorf("P1-32: /new %q name missing in /list\n/list: %q", sessionName, listReply.Text())
|
||||
}
|
||||
t.Logf("P1-32 OK: session name %q appears in /list", sessionName)
|
||||
}
|
||||
|
||||
// ── P1-33: /name 重命名 ──────────────────────────────────────────────────────
|
||||
|
||||
func TestP1_33_NameRename_ClaudeCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
testNameRename(t, "claudecode")
|
||||
}
|
||||
|
||||
func TestP1_33_NameRename_Codex(t *testing.T) {
|
||||
t.Parallel()
|
||||
testNameRename(t, "codex")
|
||||
}
|
||||
|
||||
func testNameRename(t *testing.T, agentType string) {
|
||||
t.Helper()
|
||||
env := helper.NewEnv(t, agentType)
|
||||
|
||||
env.SendComplete("say hi briefly")
|
||||
|
||||
const newName = "renamed-42"
|
||||
env.SendWithTimeout("/name "+newName, cmdTimeout)
|
||||
|
||||
listReply := env.SendWithTimeout("/list", cmdTimeout)
|
||||
if !strings.Contains(listReply.Text(), newName) {
|
||||
t.Errorf("P1-33: /name %q not in /list\n/list: %q", newName, listReply.Text())
|
||||
}
|
||||
t.Logf("P1-33 OK: renamed session %q in /list", newName)
|
||||
}
|
||||
|
||||
// ── P1-34: /switch 后 /list 完整 ─────────────────────────────────────────────
|
||||
|
||||
func TestP1_34_SwitchPreservesAllSessions_ClaudeCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
testSwitchPreservesAllSessions(t, "claudecode")
|
||||
}
|
||||
|
||||
func TestP1_34_SwitchPreservesAllSessions_Codex(t *testing.T) {
|
||||
t.Parallel()
|
||||
testSwitchPreservesAllSessions(t, "codex")
|
||||
}
|
||||
|
||||
func testSwitchPreservesAllSessions(t *testing.T, agentType string) {
|
||||
t.Helper()
|
||||
env := helper.NewEnv(t, agentType)
|
||||
|
||||
env.SendComplete("say hi briefly")
|
||||
env.SendWithTimeout("/new", cmdTimeout)
|
||||
env.SendComplete("say hello briefly")
|
||||
|
||||
env.SendWithTimeout("/switch 1", cmdTimeout)
|
||||
|
||||
listReply := env.SendWithTimeout("/list", cmdTimeout)
|
||||
count := strings.Count(listReply.Text(), "msgs")
|
||||
if count < 2 {
|
||||
t.Errorf("P1-34: /list after /switch shows %d session(s), expected ≥2\n/list: %q",
|
||||
count, listReply.Text())
|
||||
}
|
||||
t.Logf("P1-34 OK: %d sessions preserved after /switch", count)
|
||||
}
|
||||
|
||||
// ── P1-35: /delete 只删目标 ──────────────────────────────────────────────────
|
||||
|
||||
func TestP1_35_DeleteOnlyRemovesTarget_ClaudeCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
testDeleteOnlyRemovesTarget(t, "claudecode")
|
||||
}
|
||||
|
||||
func TestP1_35_DeleteOnlyRemovesTarget_Codex(t *testing.T) {
|
||||
t.Parallel()
|
||||
testDeleteOnlyRemovesTarget(t, "codex")
|
||||
}
|
||||
|
||||
func testDeleteOnlyRemovesTarget(t *testing.T, agentType string) {
|
||||
t.Helper()
|
||||
env := helper.NewEnv(t, agentType)
|
||||
|
||||
env.SendComplete("say one briefly")
|
||||
env.SendWithTimeout("/new", cmdTimeout)
|
||||
env.SendComplete("say two briefly")
|
||||
env.SendWithTimeout("/new", cmdTimeout)
|
||||
env.SendComplete("say three briefly")
|
||||
|
||||
pre := env.SendWithTimeout("/list", cmdTimeout)
|
||||
if strings.Count(pre.Text(), "msgs") < 3 {
|
||||
t.Skipf("P1-35: could not establish 3 sessions; /list: %q", pre.Text())
|
||||
}
|
||||
|
||||
env.SendWithTimeout("/delete 2", cmdTimeout)
|
||||
|
||||
post := env.SendWithTimeout("/list", cmdTimeout)
|
||||
remaining := strings.Count(post.Text(), "msgs")
|
||||
if remaining != 2 {
|
||||
t.Errorf("P1-35: expected 2 sessions after /delete 2, got %d\n/list: %q",
|
||||
remaining, post.Text())
|
||||
}
|
||||
t.Logf("P1-35 OK: 2 sessions remain after deleting #2")
|
||||
}
|
||||
|
||||
// ── P1-37: Agent 回复后 /list 不减少 ─────────────────────────────────────────
|
||||
|
||||
func TestP1_37_ListAfterReplyStable_ClaudeCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
testListAfterReplyStable(t, "claudecode")
|
||||
}
|
||||
|
||||
func TestP1_37_ListAfterReplyStable_Codex(t *testing.T) {
|
||||
t.Parallel()
|
||||
testListAfterReplyStable(t, "codex")
|
||||
}
|
||||
|
||||
func testListAfterReplyStable(t *testing.T, agentType string) {
|
||||
t.Helper()
|
||||
env := helper.NewEnv(t, agentType)
|
||||
|
||||
env.SendComplete("say hi briefly")
|
||||
env.SendWithTimeout("/new", cmdTimeout)
|
||||
env.SendComplete("say hello briefly")
|
||||
|
||||
pre := env.SendWithTimeout("/list", cmdTimeout)
|
||||
preCnt := strings.Count(pre.Text(), "msgs")
|
||||
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
post := env.SendWithTimeout("/list", cmdTimeout)
|
||||
postCnt := strings.Count(post.Text(), "msgs")
|
||||
|
||||
if postCnt < preCnt {
|
||||
t.Errorf("P1-37: session count dropped %d → %d after agent reply\npre: %q\npost: %q",
|
||||
preCnt, postCnt, pre.Text(), post.Text())
|
||||
}
|
||||
t.Logf("P1-37 OK: session count stable (%d → %d)", preCnt, postCnt)
|
||||
}
|
||||
|
||||
// ── P1-39: 重启后名称保留 ─────────────────────────────────────────────────────
|
||||
|
||||
func TestP1_39_RestartPreservesNames_ClaudeCode(t *testing.T) {
|
||||
// Not parallel — involves engine stop/restart.
|
||||
testRestartPreservesNames(t, "claudecode")
|
||||
}
|
||||
|
||||
func testRestartPreservesNames(t *testing.T, agentType string) {
|
||||
t.Helper()
|
||||
env := helper.NewEnv(t, agentType)
|
||||
|
||||
const sessionName = "persisted-name-99"
|
||||
env.SendWithTimeout("/new "+sessionName, cmdTimeout)
|
||||
env.SendComplete("say ok briefly")
|
||||
|
||||
pre := env.SendWithTimeout("/list", cmdTimeout)
|
||||
if !strings.Contains(pre.Text(), sessionName) {
|
||||
t.Skipf("P1-39: name %q not established pre-restart (%q); skipping", sessionName, pre.Text())
|
||||
}
|
||||
|
||||
// Simulate restart: stop engine, start fresh engine with same session store.
|
||||
env.Engine.Stop()
|
||||
|
||||
_, newMP := restartedEngine(t, agentType, env.DataDir)
|
||||
|
||||
before := newMP.MessageCount()
|
||||
newMP.InjectMessage(helper.DefaultUser, helper.DefaultChat, "/list")
|
||||
listReply := newMP.WaitForReply(before, cmdTimeout)
|
||||
if listReply == nil {
|
||||
t.Fatal("P1-39: no /list response after restart")
|
||||
}
|
||||
if !strings.Contains(listReply.Text(), sessionName) {
|
||||
t.Errorf("P1-39: name %q lost after restart\n/list: %q", sessionName, listReply.Text())
|
||||
}
|
||||
t.Logf("P1-39 OK: name %q preserved after restart", sessionName)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
//go:build blackbox
|
||||
|
||||
package p2
|
||||
|
||||
import (
|
||||
_ "github.com/chenhg5/cc-connect/agent/claudecode"
|
||||
_ "github.com/chenhg5/cc-connect/agent/codex"
|
||||
_ "github.com/chenhg5/cc-connect/agent/cursor"
|
||||
_ "github.com/chenhg5/cc-connect/agent/gemini"
|
||||
_ "github.com/chenhg5/cc-connect/agent/opencode"
|
||||
_ "github.com/chenhg5/cc-connect/agent/qoder"
|
||||
)
|
||||
@@ -0,0 +1,199 @@
|
||||
//go:build blackbox
|
||||
|
||||
// Package p2 contains P2 blackbox tests — important features that don't block
|
||||
// release but failures must be recorded.
|
||||
//
|
||||
// This file covers engine-dispatched slash commands:
|
||||
//
|
||||
// /whoami, /agent-sid, /skills, /cron list, /quiet, /effort, /search
|
||||
//
|
||||
// and security guard: non-authorized user rejection.
|
||||
//
|
||||
// Run:
|
||||
//
|
||||
// CC_BLACKBOX_CLAUDECODE_API_KEY=xxx \
|
||||
// go test -tags blackbox ./tests/blackbox/p2/... -timeout 600s -v
|
||||
package p2
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/chenhg5/cc-connect/tests/blackbox/helper"
|
||||
bbplatform "github.com/chenhg5/cc-connect/tests/blackbox/platform"
|
||||
)
|
||||
|
||||
const p2CmdTimeout = 30 * time.Second
|
||||
|
||||
// ── P2-41: /whoami ────────────────────────────────────────────────────────────
|
||||
|
||||
func TestP2_41_Whoami_ClaudeCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := helper.NewEnv(t, "claudecode")
|
||||
reply := env.SendWithTimeout("/whoami", p2CmdTimeout)
|
||||
// Should contain the user ID we injected ("user1") or user name.
|
||||
assertContainsAny(t, "P2-41 /whoami", reply.Text(),
|
||||
"user1", "test-user", "user", "身份", "ID")
|
||||
t.Logf("P2-41 OK: %q", truncate(reply.Text(), 150))
|
||||
}
|
||||
|
||||
// ── P2-45: /agent-sid ────────────────────────────────────────────────────────
|
||||
|
||||
func TestP2_45_AgentSid_ClaudeCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := helper.NewEnv(t, "claudecode")
|
||||
|
||||
// /agent-sid requires an active session first.
|
||||
env.Send("say hi briefly")
|
||||
|
||||
reply := env.SendWithTimeout("/agent-sid", p2CmdTimeout)
|
||||
// Should contain a session ID of some kind.
|
||||
text := reply.Text()
|
||||
hasSessionID := strings.ContainsAny(text, "-") && len(text) > 5
|
||||
if !hasSessionID && !strings.Contains(strings.ToLower(text), "session") &&
|
||||
!strings.Contains(strings.ToLower(text), "no session") {
|
||||
t.Errorf("P2-45 /agent-sid: response doesn't look like a session ID\ngot: %q", text)
|
||||
}
|
||||
t.Logf("P2-45 OK: %q", truncate(text, 150))
|
||||
}
|
||||
|
||||
// ── P2-38: /skills ───────────────────────────────────────────────────────────
|
||||
|
||||
func TestP2_38_Skills_ClaudeCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := helper.NewEnv(t, "claudecode")
|
||||
reply := env.SendWithTimeout("/skills", p2CmdTimeout)
|
||||
assertContainsAny(t, "P2-38 /skills", strings.ToLower(reply.Text()),
|
||||
"skill", "no skill", "没有", "available", "技能")
|
||||
t.Logf("P2-38 OK: %q", truncate(reply.Text(), 200))
|
||||
}
|
||||
|
||||
// ── P2-56 + P2-57: /cron list and /cron add ──────────────────────────────────
|
||||
|
||||
func TestP2_56_CronList_ClaudeCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := helper.NewEnv(t, "claudecode")
|
||||
reply := env.SendWithTimeout("/cron list", p2CmdTimeout)
|
||||
assertContainsAny(t, "P2-56 /cron list", strings.ToLower(reply.Text()),
|
||||
"cron", "job", "task", "schedule", "no cron", "没有", "定时")
|
||||
t.Logf("P2-56 OK: %q", truncate(reply.Text(), 200))
|
||||
}
|
||||
|
||||
func TestP2_57_CronAdd_ClaudeCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := helper.NewEnv(t, "claudecode")
|
||||
|
||||
// Add a cron job.
|
||||
addReply := env.SendWithTimeout(
|
||||
`/cron add --cron "0 9 * * 1" --prompt "Weekly status summary" --desc "weekly-test"`,
|
||||
p2CmdTimeout,
|
||||
)
|
||||
t.Logf("/cron add reply: %q", truncate(addReply.Text(), 200))
|
||||
|
||||
// /cron list should now show the job.
|
||||
listReply := env.SendWithTimeout("/cron list", p2CmdTimeout)
|
||||
hasEntry := strings.Contains(listReply.Text(), "weekly-test") ||
|
||||
strings.Contains(listReply.Text(), "Weekly") ||
|
||||
strings.Contains(listReply.Text(), "0 9 * * 1")
|
||||
if !hasEntry {
|
||||
t.Errorf("P2-57 /cron add: job not found in /cron list\n/cron list: %q", listReply.Text())
|
||||
}
|
||||
t.Logf("P2-57 OK: cron job appears in /cron list")
|
||||
}
|
||||
|
||||
// ── P2-40: /quiet ────────────────────────────────────────────────────────────
|
||||
|
||||
func TestP2_40_Quiet_ClaudeCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := helper.NewEnv(t, "claudecode")
|
||||
reply := env.SendWithTimeout("/quiet", p2CmdTimeout)
|
||||
assertContainsAny(t, "P2-40 /quiet", strings.ToLower(reply.Text()),
|
||||
"quiet", "silent", "mode", "安静", "模式", "enabled", "disabled", "on", "off")
|
||||
t.Logf("P2-40 OK: %q", truncate(reply.Text(), 150))
|
||||
}
|
||||
|
||||
// ── P2-39: /effort ───────────────────────────────────────────────────────────
|
||||
|
||||
func TestP2_39_Effort_ClaudeCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := helper.NewEnv(t, "claudecode")
|
||||
reply := env.SendWithTimeout("/effort high", p2CmdTimeout)
|
||||
assertContainsAny(t, "P2-39 /effort high", strings.ToLower(reply.Text()),
|
||||
"effort", "high", "reasoning", "思考", "努力", "switched", "changed", "not supported", "支持")
|
||||
t.Logf("P2-39 OK: %q", truncate(reply.Text(), 150))
|
||||
}
|
||||
|
||||
// ── P2-46: /search ───────────────────────────────────────────────────────────
|
||||
|
||||
func TestP2_46_Search_ClaudeCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := helper.NewEnv(t, "claudecode")
|
||||
|
||||
// Create a session with a known keyword.
|
||||
env.Send("the keyword is XYZZY42")
|
||||
|
||||
// Search for the keyword.
|
||||
reply := env.SendWithTimeout("/search XYZZY42", p2CmdTimeout)
|
||||
assertContainsAny(t, "P2-46 /search", strings.ToLower(reply.Text()),
|
||||
"xyzzy42", "session", "result", "found", "no result", "没有", "搜索")
|
||||
t.Logf("P2-46 OK: %q", truncate(reply.Text(), 200))
|
||||
}
|
||||
|
||||
// ── P2-61: 非授权用户被拒绝 ──────────────────────────────────────────────────
|
||||
|
||||
// TestP2_61_UnauthorizedUserIgnored verifies that when a message arrives from
|
||||
// a user ID not in allow_from, cc-connect does NOT send a reply.
|
||||
//
|
||||
// The engine's allow_from filter is configured per-project. This test uses a
|
||||
// separate MockPlatform that injects a message from a user NOT in any
|
||||
// allow_from list. The platform should receive no reply.
|
||||
//
|
||||
// NOTE: This test requires allow_from to be configured. Since the blackbox
|
||||
// test engine is created without allow_from config, we CANNOT test rejection
|
||||
// here — this is a configuration-level feature. Marking as informational.
|
||||
func TestP2_61_AllowFromDocumented(t *testing.T) {
|
||||
t.Log("P2-61 (allow_from): requires platform-level allow_from config.")
|
||||
t.Log("Verified manually via IM with non-whitelisted Telegram user ID.")
|
||||
t.Log("Cannot automate without modifying engine config per-test.")
|
||||
// Not a failure — documenting the gap.
|
||||
}
|
||||
|
||||
// ── P2-47: /bind ─────────────────────────────────────────────────────────────
|
||||
|
||||
func TestP2_47_Bind_ClaudeCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := helper.NewEnv(t, "claudecode")
|
||||
reply := env.SendWithTimeout("/bind setup", p2CmdTimeout)
|
||||
assertContainsAny(t, "P2-47 /bind setup", strings.ToLower(reply.Text()),
|
||||
"bind", "relay", "setup", "绑定", "不支持", "not supported", "project")
|
||||
t.Logf("P2-47 OK: %q", truncate(reply.Text(), 200))
|
||||
}
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
func assertContainsAny(t *testing.T, label, text string, candidates ...string) {
|
||||
t.Helper()
|
||||
lower := strings.ToLower(text)
|
||||
for _, c := range candidates {
|
||||
if strings.Contains(lower, strings.ToLower(c)) {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Errorf("%s: none of %v found\ngot: %q", label, candidates, text)
|
||||
}
|
||||
|
||||
func truncate(s string, max int) string {
|
||||
if len(s) <= max {
|
||||
return s
|
||||
}
|
||||
return s[:max] + "…"
|
||||
}
|
||||
|
||||
// sendToNewPlatform injects a message from a fresh user to the existing engine
|
||||
// via a second mock platform. Used for multi-user test scenarios.
|
||||
func sendToNewPlatform(_ *bbplatform.MockPlatform, userID, content string) {
|
||||
// placeholder — used in custom command tests below
|
||||
_ = fmt.Sprintf("user %s: %s", userID, content)
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
//go:build blackbox
|
||||
|
||||
package p2
|
||||
|
||||
// Configuration switch tests — P2-71 to P2-86.
|
||||
//
|
||||
// These tests verify that engine-level config options produce the expected
|
||||
// observable output difference. They correspond to the checklist items that
|
||||
// require restarting with a different config (P2-71..P2-88).
|
||||
//
|
||||
// Unlike the manual checklist process (apply config → IM verify → revert),
|
||||
// these tests use NewEnvWithSetup to configure the engine BEFORE start,
|
||||
// making them fully automated.
|
||||
//
|
||||
// Tests are split into two tiers:
|
||||
//
|
||||
// FAST (no agent API call needed — engine handles before forwarding):
|
||||
// - P2-81: disabled_commands → blocked immediately by engine
|
||||
// - P2-82: banned_words → blocked immediately by engine
|
||||
// - P2-40b: rate_limit → throttled immediately by engine
|
||||
//
|
||||
// SLOW (agent API call required — verifying output format):
|
||||
// - P2-71: show_context_indicator = false → no [ctx: ~N%] in replies
|
||||
// - P2-77: display.mode = "compact" → no thinking/tool messages
|
||||
// - P2-78: thinking_messages = false → no 💭 thinking messages
|
||||
// - P2-79: tool_messages = false → no 🔧 tool messages
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/chenhg5/cc-connect/core"
|
||||
"github.com/chenhg5/cc-connect/tests/blackbox/helper"
|
||||
)
|
||||
|
||||
const cfgCmdTimeout = 30 * time.Second
|
||||
|
||||
// ── P2-81: disabled_commands ──────────────────────────────────────────────────
|
||||
|
||||
// TestP2_81_DisabledCommands verifies that a disabled command is blocked by the
|
||||
// engine before it reaches the agent. This test is FAST — no agent API call.
|
||||
func TestP2_81_DisabledCommands(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := helper.NewEnvWithSetup(t, "claudecode", func(e *core.Engine) {
|
||||
e.SetDisabledCommands([]string{"help", "restart", "shell"})
|
||||
})
|
||||
|
||||
// /help should be blocked.
|
||||
helpReply := env.SendWithTimeout("/help", cfgCmdTimeout)
|
||||
if !strings.Contains(helpReply.Text(), "disabled") && !strings.Contains(helpReply.Text(), "禁用") {
|
||||
t.Errorf("P2-81: /help not blocked; got: %q", helpReply.Text())
|
||||
}
|
||||
t.Logf("P2-81a /help blocked: %q", truncate(helpReply.Text(), 100))
|
||||
|
||||
// /restart should be blocked too.
|
||||
restartReply := env.SendWithTimeout("/restart", cfgCmdTimeout)
|
||||
if !strings.Contains(restartReply.Text(), "disabled") && !strings.Contains(restartReply.Text(), "禁用") {
|
||||
t.Errorf("P2-81: /restart not blocked; got: %q", restartReply.Text())
|
||||
}
|
||||
t.Logf("P2-81b /restart blocked: %q", truncate(restartReply.Text(), 100))
|
||||
|
||||
// /list should NOT be blocked (it's not in the disabled list).
|
||||
listReply := env.SendWithTimeout("/list", cfgCmdTimeout)
|
||||
if strings.Contains(listReply.Text(), "disabled") {
|
||||
t.Errorf("P2-81: /list incorrectly blocked; got: %q", listReply.Text())
|
||||
}
|
||||
t.Logf("P2-81 OK: /help and /restart disabled, /list still works")
|
||||
}
|
||||
|
||||
// ── P2-82: banned_words ───────────────────────────────────────────────────────
|
||||
|
||||
// TestP2_82_BannedWords verifies that messages containing a banned word are
|
||||
// intercepted by the engine before reaching the agent. FAST — no agent API call.
|
||||
func TestP2_82_BannedWords(t *testing.T) {
|
||||
t.Parallel()
|
||||
const bannedWord = "XBLACKBOXBANWORD"
|
||||
env := helper.NewEnvWithSetup(t, "claudecode", func(e *core.Engine) {
|
||||
e.SetBannedWords([]string{bannedWord})
|
||||
})
|
||||
|
||||
// Message with banned word → should be blocked.
|
||||
blockedReply := env.SendWithTimeout(
|
||||
"Please help me with "+bannedWord+" testing.",
|
||||
cfgCmdTimeout,
|
||||
)
|
||||
assertContainsAny(t, "P2-82 banned reply", blockedReply.Text(),
|
||||
"blocked", "prohibited", "违禁", "拦截", "⚠️")
|
||||
t.Logf("P2-82a banned word blocked: %q", truncate(blockedReply.Text(), 100))
|
||||
|
||||
// Normal message → should pass through (agent responds).
|
||||
normalReply := env.Send("say hi briefly")
|
||||
if strings.TrimSpace(normalReply.Text()) == "" {
|
||||
t.Errorf("P2-82: normal message got empty reply after banned-word config")
|
||||
}
|
||||
if strings.Contains(strings.ToLower(normalReply.Text()), "prohibited") ||
|
||||
strings.Contains(strings.ToLower(normalReply.Text()), "blocked") {
|
||||
t.Errorf("P2-82: normal message incorrectly blocked; got: %q", normalReply.Text())
|
||||
}
|
||||
t.Logf("P2-82 OK: banned word blocked, normal message passes")
|
||||
}
|
||||
|
||||
// ── P2-82b: banned_words case-insensitive ────────────────────────────────────
|
||||
|
||||
// TestP2_82b_BannedWordsCaseInsensitive checks that banned word matching is
|
||||
// case-insensitive (lowercase config word, uppercase in message).
|
||||
func TestP2_82b_BannedWordsCaseInsensitive(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := helper.NewEnvWithSetup(t, "claudecode", func(e *core.Engine) {
|
||||
// Register the word in lowercase.
|
||||
e.SetBannedWords([]string{"casetest_banned"})
|
||||
})
|
||||
|
||||
// Message uses UPPERCASE version.
|
||||
reply := env.SendWithTimeout("help with CASETEST_BANNED scenario", cfgCmdTimeout)
|
||||
assertContainsAny(t, "P2-82b case-insensitive ban", reply.Text(),
|
||||
"blocked", "prohibited", "违禁", "拦截", "⚠️")
|
||||
t.Logf("P2-82b OK: case-insensitive banned word detected")
|
||||
}
|
||||
|
||||
// ── P2-71: show_context_indicator = false ────────────────────────────────────
|
||||
|
||||
// TestP2_71_HideContextIndicator verifies that when show_context_indicator is
|
||||
// false, the [ctx: ~N%] suffix is absent from agent replies.
|
||||
//
|
||||
// This test is SLOW — it requires a real agent turn to produce final output.
|
||||
// The context indicator only appears when input_tokens >= 100, so we send
|
||||
// a few messages first to build up token history.
|
||||
func TestP2_71_HideContextIndicator_ClaudeCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := helper.NewEnvWithSetup(t, "claudecode", func(e *core.Engine) {
|
||||
e.SetShowContextIndicator(false)
|
||||
})
|
||||
|
||||
// Send several messages to accumulate token history (ctx indicator threshold).
|
||||
for _, msg := range []string{
|
||||
"My name is BlackboxConfigTest.",
|
||||
"I am testing context indicator behavior.",
|
||||
"say hi briefly", // this turn we check
|
||||
} {
|
||||
msgs := env.SendComplete(msg)
|
||||
combined := helper.AnyText(msgs)
|
||||
if strings.Contains(combined, "[ctx:") {
|
||||
t.Errorf("P2-71: [ctx: ~N%%] appeared when show_context_indicator=false\nreply: %q",
|
||||
combined)
|
||||
}
|
||||
}
|
||||
t.Logf("P2-71 OK: no [ctx:] suffix observed with show_context_indicator=false")
|
||||
}
|
||||
|
||||
// TestP2_71b_ShowContextIndicator verifies that with the DEFAULT setting
|
||||
// (show_context_indicator = true), the [ctx: ~N%] suffix eventually appears
|
||||
// after sufficient token accumulation.
|
||||
func TestP2_71b_ShowContextIndicator_ClaudeCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Default env: show_context_indicator = true
|
||||
env := helper.NewEnv(t, "claudecode")
|
||||
|
||||
// Send multiple turns to push token count above the 100-token threshold.
|
||||
// The indicator appears when input_tokens >= 100.
|
||||
const maxTurns = 5
|
||||
found := false
|
||||
for i := 0; i < maxTurns; i++ {
|
||||
msgs := env.SendComplete("say one sentence about software testing")
|
||||
combined := helper.AnyText(msgs)
|
||||
if strings.Contains(combined, "[ctx:") {
|
||||
found = true
|
||||
t.Logf("P2-71b: [ctx:] appeared at turn %d: %q", i+1, truncate(combined, 200))
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Logf("P2-71b: [ctx:] not seen in %d turns — token count may not have reached threshold yet", maxTurns)
|
||||
// Not a hard failure — threshold is token-count dependent.
|
||||
} else {
|
||||
t.Logf("P2-71b OK: [ctx:] appears with default show_context_indicator=true")
|
||||
}
|
||||
}
|
||||
|
||||
// ── P2-78: thinking_messages = false ─────────────────────────────────────────
|
||||
|
||||
// TestP2_78_HideThinkingMessages verifies that when thinking_messages=false,
|
||||
// no thinking indicators (💭 / thinking prefix) appear in the output.
|
||||
//
|
||||
// SLOW — requires agent with thinking enabled (complex question).
|
||||
func TestP2_78_HideThinkingMessages_ClaudeCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := helper.NewEnvWithSetup(t, "claudecode", func(e *core.Engine) {
|
||||
e.SetDisplayConfig(core.DisplayCfg{
|
||||
Mode: "full",
|
||||
CardMode: "legacy",
|
||||
ThinkingMessages: false, // ← key config
|
||||
ThinkingMaxLen: 300,
|
||||
ToolMessages: true,
|
||||
ToolMaxLen: 500,
|
||||
})
|
||||
})
|
||||
|
||||
// A question that might trigger thinking.
|
||||
msgs := env.SendComplete("What is 17 * 23? Show your reasoning.")
|
||||
combined := helper.AnyText(msgs)
|
||||
|
||||
// Thinking messages are prefixed with 💭 or contain "thinking" marker.
|
||||
// When ThinkingMessages=false, these should NOT appear.
|
||||
for _, msg := range msgs {
|
||||
text := msg.Text()
|
||||
if strings.HasPrefix(text, "💭") || strings.Contains(text, "\n💭") {
|
||||
t.Errorf("P2-78: thinking message appeared with ThinkingMessages=false\nmsg: %q", text)
|
||||
}
|
||||
}
|
||||
t.Logf("P2-78 OK: no thinking messages in %d msgs", len(msgs))
|
||||
t.Logf("combined reply: %q", truncate(combined, 300))
|
||||
}
|
||||
|
||||
// ── P2-79: tool_messages = false ─────────────────────────────────────────────
|
||||
|
||||
// TestP2_79_HideToolMessages verifies that when tool_messages=false, no tool
|
||||
// progress messages (🔧 / tool invocation indicators) appear.
|
||||
//
|
||||
// SLOW — requires a real agent that calls a tool.
|
||||
func TestP2_79_HideToolMessages_ClaudeCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := helper.NewEnvWithSetup(t, "claudecode", func(e *core.Engine) {
|
||||
e.SetDisplayConfig(core.DisplayCfg{
|
||||
Mode: "full",
|
||||
CardMode: "legacy",
|
||||
ThinkingMessages: true,
|
||||
ThinkingMaxLen: 300,
|
||||
ToolMessages: false, // ← key config
|
||||
ToolMaxLen: 500,
|
||||
})
|
||||
})
|
||||
|
||||
msgs := env.SendComplete("List the files in the current directory. Use a shell command.")
|
||||
combined := helper.AnyText(msgs)
|
||||
|
||||
// Tool messages are prefixed with 🔧 or contain "Tool" markers.
|
||||
for _, msg := range msgs {
|
||||
text := msg.Text()
|
||||
if strings.HasPrefix(text, "🔧") || strings.Contains(text, "\n🔧") {
|
||||
t.Errorf("P2-79: tool message appeared with ToolMessages=false\nmsg: %q", text)
|
||||
}
|
||||
}
|
||||
// Verify the agent still produced a useful final response (tool was called
|
||||
// but progress hidden).
|
||||
hasResult := strings.Contains(combined, ".go") ||
|
||||
strings.Contains(strings.ToLower(combined), "file") ||
|
||||
strings.Contains(strings.ToLower(combined), "directory") ||
|
||||
strings.Contains(strings.ToLower(combined), "no files")
|
||||
if !hasResult {
|
||||
t.Errorf("P2-79: agent didn't produce file listing result\ncombined: %q", combined)
|
||||
}
|
||||
t.Logf("P2-79 OK: no 🔧 tool messages; final result has file listing")
|
||||
}
|
||||
|
||||
// ── P2-77: display.mode = "compact" ──────────────────────────────────────────
|
||||
|
||||
// TestP2_77_DisplayModeCompact verifies that compact mode hides thinking/tool
|
||||
// messages. Unlike full mode, compact sends each text segment separately but
|
||||
// suppresses intermediate tool/thinking indicators.
|
||||
//
|
||||
// SLOW — requires agent turn.
|
||||
func TestP2_77_DisplayModeCompact_ClaudeCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := helper.NewEnvWithSetup(t, "claudecode", func(e *core.Engine) {
|
||||
e.SetDisplayConfig(core.DisplayCfg{
|
||||
Mode: "compact", // ← key config
|
||||
CardMode: "legacy",
|
||||
ThinkingMessages: true,
|
||||
ThinkingMaxLen: 300,
|
||||
ToolMessages: true,
|
||||
ToolMaxLen: 500,
|
||||
})
|
||||
})
|
||||
|
||||
msgs := env.SendComplete("List the files in the current directory. Use a shell command.")
|
||||
|
||||
// In compact mode, 💭 thinking and 🔧 tool messages should not appear.
|
||||
for _, msg := range msgs {
|
||||
text := msg.Text()
|
||||
if strings.HasPrefix(text, "💭") || strings.HasPrefix(text, "🔧") {
|
||||
t.Errorf("P2-77 compact mode: thinking/tool message appeared\nmsg: %q", text)
|
||||
}
|
||||
}
|
||||
t.Logf("P2-77 OK: no thinking/tool messages in compact mode (%d msgs)", len(msgs))
|
||||
}
|
||||
|
||||
// ── P2-80: stream_preview disabled ───────────────────────────────────────────
|
||||
|
||||
// TestP2_80_StreamPreviewDisabled verifies that disabling stream_preview does
|
||||
// not break message delivery. The agent's final response must still arrive.
|
||||
//
|
||||
// Note: MockPlatform does NOT implement MessageUpdater, so streaming preview
|
||||
// would not occur regardless. This test verifies that disabling stream_preview
|
||||
// does not suppress the final reply.
|
||||
func TestP2_80_StreamPreviewDisabled_ClaudeCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := helper.NewEnvWithSetup(t, "claudecode", func(e *core.Engine) {
|
||||
e.SetStreamPreviewCfg(core.StreamPreviewCfg{
|
||||
Enabled: false,
|
||||
IntervalMs: 1500,
|
||||
MinDeltaChars: 30,
|
||||
MaxChars: 2000,
|
||||
})
|
||||
})
|
||||
|
||||
msgs := env.SendComplete("say hi briefly")
|
||||
combined := helper.AnyText(msgs)
|
||||
|
||||
if strings.TrimSpace(combined) == "" {
|
||||
t.Errorf("P2-80: stream_preview=false suppressed final reply")
|
||||
}
|
||||
t.Logf("P2-80 OK: final reply still delivered with stream_preview=false: %q",
|
||||
truncate(combined, 150))
|
||||
}
|
||||
|
||||
// ── P2-85/86: reply_footer ────────────────────────────────────────────────────
|
||||
|
||||
// TestP2_86_HideReplyFooter verifies that with reply_footer=false, the
|
||||
// footer information does not appear in final replies.
|
||||
// When reply_footer is false AND show_context_indicator is false, the
|
||||
// assistant reply should have no trailing metadata line.
|
||||
func TestP2_86_HideReplyFooter_ClaudeCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := helper.NewEnvWithSetup(t, "claudecode", func(e *core.Engine) {
|
||||
e.SetReplyFooterEnabled(false)
|
||||
e.SetShowContextIndicator(false) // also hide ctx
|
||||
})
|
||||
|
||||
msgs := env.SendComplete("say hi briefly")
|
||||
|
||||
for _, msg := range msgs {
|
||||
text := msg.Text()
|
||||
// Footer would contain model name or work dir info.
|
||||
// With both disabled, last line should not look like a footer.
|
||||
lines := strings.Split(strings.TrimSpace(text), "\n")
|
||||
if len(lines) > 0 {
|
||||
lastLine := strings.TrimSpace(lines[len(lines)-1])
|
||||
// Footer typically contains: "model · /path/to/dir" or "[ctx: ~N%]"
|
||||
if strings.Contains(lastLine, "[ctx:") {
|
||||
t.Errorf("P2-86: [ctx:] appeared with show_context_indicator=false\nlast line: %q", lastLine)
|
||||
}
|
||||
}
|
||||
}
|
||||
t.Logf("P2-86 OK: no footer/ctx in replies with reply_footer=false, show_context_indicator=false")
|
||||
}
|
||||
|
||||
// ── P1-40: filter_external_sessions = false (default) ─────────────────────────
|
||||
|
||||
// TestP1_40_FilterExternalSessionsDefault verifies that the default behavior
|
||||
// (filter_external_sessions=false) shows ALL sessions including any created
|
||||
// externally (not via cc-connect).
|
||||
//
|
||||
// Since our MockPlatform tests don't have external sessions, we verify that
|
||||
// the flag doesn't hide sessions WE created.
|
||||
func TestP1_40_FilterExternalSessions_Default_ClaudeCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := helper.NewEnvWithSetup(t, "claudecode", func(e *core.Engine) {
|
||||
e.SetFilterExternalSessions(false) // explicit default
|
||||
})
|
||||
|
||||
env.SendComplete("say hi briefly")
|
||||
env.SendWithTimeout("/new", cfgCmdTimeout)
|
||||
env.SendComplete("say hello briefly")
|
||||
|
||||
listReply := env.SendWithTimeout("/list", cfgCmdTimeout)
|
||||
count := strings.Count(listReply.Text(), "msgs")
|
||||
if count < 2 {
|
||||
t.Errorf("P1-40: filter_external_sessions=false still hides sessions (got %d)\n/list: %q",
|
||||
count, listReply.Text())
|
||||
}
|
||||
t.Logf("P1-40 OK: %d sessions visible with filter_external_sessions=false", count)
|
||||
}
|
||||
|
||||
// ── Instant reply ─────────────────────────────────────────────────────────────
|
||||
|
||||
// TestInstantReply verifies that when instant_reply is enabled, the engine
|
||||
// sends an immediate confirmation before the agent processes the message.
|
||||
func TestInstantReply_ClaudeCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
const marker = "🤔 Working on it..."
|
||||
env := helper.NewEnvWithSetup(t, "claudecode", func(e *core.Engine) {
|
||||
e.SetInstantReply(core.InstantReplyCfg{
|
||||
Enabled: true,
|
||||
Content: marker,
|
||||
})
|
||||
})
|
||||
|
||||
before := env.Platform.MessageCount()
|
||||
env.Platform.InjectMessage(helper.DefaultUser, helper.DefaultChat, "say hi briefly")
|
||||
|
||||
// The instant reply should arrive VERY quickly (within 2s), before the agent responds.
|
||||
instant := env.Platform.WaitForReply(before, 2*time.Second)
|
||||
if instant == nil {
|
||||
// Could be that the agent replied faster than 2s — check all messages.
|
||||
t.Logf("InstantReply: no reply in 2s (agent may have been faster)")
|
||||
} else if instant.Text() == marker {
|
||||
t.Logf("InstantReply OK: instant confirmation received before agent: %q", marker)
|
||||
}
|
||||
|
||||
// Either way, we must eventually get a final substantive reply.
|
||||
msgs, ok := env.Platform.WaitForN(1, helper.DefaultReplyTimeout)
|
||||
if !ok {
|
||||
t.Fatalf("InstantReply: no final reply received\nall messages:\n%s", env.Platform.AllText())
|
||||
}
|
||||
t.Logf("InstantReply OK: %d total messages received", len(msgs))
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
//go:build blackbox
|
||||
|
||||
package p2
|
||||
|
||||
// Custom commands and aliases — P2-63 to P2-70.
|
||||
//
|
||||
// These tests exercise the /commands and /alias engine features:
|
||||
// P2-63: /commands add <name> <prompt>
|
||||
// P2-64: invoke the custom command
|
||||
// P2-65: /commands list
|
||||
// P2-66: /commands del <name>
|
||||
// P2-67: /commands addexec <name> <shell-cmd>
|
||||
// P2-68: invoke the exec command
|
||||
// P2-69: /alias add <alias> <target>
|
||||
// P2-70: /alias del <alias>
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/chenhg5/cc-connect/tests/blackbox/helper"
|
||||
)
|
||||
|
||||
// TestP2_63_65_66_CustomCommandLifecycle tests the full custom prompt command
|
||||
// lifecycle: add → list → invoke → delete → verify deleted.
|
||||
func TestP2_63_65_66_CustomCommandLifecycle_ClaudeCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := helper.NewEnv(t, "claudecode")
|
||||
|
||||
const cmdName = "bb-test-cmd"
|
||||
const cmdPrompt = "Say the word BLACKBOX_MARKER"
|
||||
|
||||
// P2-63: add.
|
||||
addReply := env.SendWithTimeout("/commands add "+cmdName+" "+cmdPrompt, p2CmdTimeout)
|
||||
t.Logf("P2-63 /commands add: %q", truncate(addReply.Text(), 150))
|
||||
assertContainsAny(t, "P2-63 /commands add", strings.ToLower(addReply.Text()),
|
||||
"created", "added", "success", "成功", cmdName)
|
||||
|
||||
// P2-65: list — new command should appear.
|
||||
listReply := env.SendWithTimeout("/commands", p2CmdTimeout)
|
||||
t.Logf("P2-65 /commands: %q", truncate(listReply.Text(), 200))
|
||||
if !strings.Contains(listReply.Text(), cmdName) {
|
||||
t.Errorf("P2-65: %q not in /commands list\n%q", cmdName, listReply.Text())
|
||||
}
|
||||
|
||||
// P2-64: invoke the custom command.
|
||||
invokeReply := env.Send("/" + cmdName)
|
||||
t.Logf("P2-64 /%s: %q", cmdName, truncate(invokeReply.Text(), 200))
|
||||
// The agent should say something in response to the prompt.
|
||||
if strings.TrimSpace(invokeReply.Text()) == "" {
|
||||
t.Errorf("P2-64: /%s produced empty reply", cmdName)
|
||||
}
|
||||
|
||||
// P2-66: delete.
|
||||
delReply := env.SendWithTimeout("/commands del "+cmdName, p2CmdTimeout)
|
||||
t.Logf("P2-66 /commands del: %q", truncate(delReply.Text(), 150))
|
||||
|
||||
// Verify it's gone from the list.
|
||||
listAfter := env.SendWithTimeout("/commands", p2CmdTimeout)
|
||||
if strings.Contains(listAfter.Text(), cmdName) {
|
||||
t.Errorf("P2-66: %q still in /commands list after del\n%q", cmdName, listAfter.Text())
|
||||
}
|
||||
t.Logf("P2-63..66 OK: custom command lifecycle complete")
|
||||
}
|
||||
|
||||
// TestP2_67_68_ExecCommandLifecycle tests adding a shell exec command and
|
||||
// verifying its output arrives correctly.
|
||||
func TestP2_67_68_ExecCommandLifecycle_ClaudeCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := helper.NewEnv(t, "claudecode")
|
||||
|
||||
const execName = "bb-exec-test"
|
||||
|
||||
// P2-67: add exec command (echo a unique marker).
|
||||
addReply := env.SendWithTimeout(
|
||||
"/commands addexec "+execName+" echo EXEC_MARKER_77",
|
||||
p2CmdTimeout,
|
||||
)
|
||||
t.Logf("P2-67 /commands addexec: %q", truncate(addReply.Text(), 150))
|
||||
assertContainsAny(t, "P2-67 addexec", strings.ToLower(addReply.Text()),
|
||||
"created", "added", "success", "成功", execName)
|
||||
|
||||
// P2-68: invoke exec command.
|
||||
execReply := env.SendWithTimeout("/"+execName, p2CmdTimeout)
|
||||
t.Logf("P2-68 /%s: %q", execName, truncate(execReply.Text(), 200))
|
||||
|
||||
if !strings.Contains(execReply.Text(), "EXEC_MARKER_77") {
|
||||
t.Errorf("P2-68: /%s output missing EXEC_MARKER_77\ngot: %q", execName, execReply.Text())
|
||||
}
|
||||
|
||||
// Cleanup.
|
||||
env.SendWithTimeout("/commands del "+execName, p2CmdTimeout)
|
||||
t.Logf("P2-67..68 OK: exec command invoked, output correct")
|
||||
}
|
||||
|
||||
// TestP2_69_70_AliasLifecycle tests adding and removing an alias.
|
||||
func TestP2_69_70_AliasLifecycle_ClaudeCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := helper.NewEnv(t, "claudecode")
|
||||
|
||||
// P2-69: add alias "bbhelp" → "/help".
|
||||
addReply := env.SendWithTimeout("/alias add bbhelp /help", p2CmdTimeout)
|
||||
t.Logf("P2-69 /alias add: %q", truncate(addReply.Text(), 150))
|
||||
assertContainsAny(t, "P2-69 /alias add", strings.ToLower(addReply.Text()),
|
||||
"added", "created", "success", "成功", "alias", "bbhelp")
|
||||
|
||||
// Trigger the alias — should behave like /help.
|
||||
aliasReply := env.SendWithTimeout("bbhelp", p2CmdTimeout)
|
||||
t.Logf("alias bbhelp reply: %q", truncate(aliasReply.Text(), 200))
|
||||
assertContainsAny(t, "P2-69 alias invocation", aliasReply.Text(),
|
||||
"/new", "/list", "/stop", "/help", "Command", "命令", "Available")
|
||||
|
||||
// P2-70: delete alias.
|
||||
delReply := env.SendWithTimeout("/alias del bbhelp", p2CmdTimeout)
|
||||
t.Logf("P2-70 /alias del: %q", truncate(delReply.Text(), 150))
|
||||
|
||||
// Verify alias is gone — sending "bbhelp" should NOT trigger /help.
|
||||
afterReply := env.Send("bbhelp")
|
||||
afterText := strings.ToLower(afterReply.Text())
|
||||
// After deletion, "bbhelp" is just a plain message passed to the agent.
|
||||
// It should NOT produce a command list (which /help would).
|
||||
triggeredHelp := strings.Contains(afterText, "/new") && strings.Contains(afterText, "/list") &&
|
||||
strings.Contains(afterText, "/stop")
|
||||
if triggeredHelp {
|
||||
t.Errorf("P2-70: alias bbhelp still active after /alias del\ngot: %q", afterReply.Text())
|
||||
}
|
||||
t.Logf("P2-69..70 OK: alias lifecycle complete")
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
// Package platform provides the MockPlatform used in blackbox tests.
|
||||
//
|
||||
// MockPlatform implements core.Platform without connecting to any real IM
|
||||
// service. Tests inject messages via InjectMessage and verify cc-connect's
|
||||
// output via WaitForReply / GetSentMessages.
|
||||
package platform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/chenhg5/cc-connect/core"
|
||||
)
|
||||
|
||||
// SentMessage captures a single outbound call from cc-connect to the platform.
|
||||
type SentMessage struct {
|
||||
Content string
|
||||
Card *core.Card // non-nil when SendCard/ReplyCard was called
|
||||
ReplyCtx any
|
||||
At time.Time
|
||||
}
|
||||
|
||||
// Text returns the displayable text of the message (card fallback if needed).
|
||||
func (m *SentMessage) Text() string {
|
||||
if m.Content != "" {
|
||||
return m.Content
|
||||
}
|
||||
if m.Card != nil {
|
||||
return m.Card.RenderText()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// MockReplyCtx is a simple reply context stored in injected messages.
|
||||
type MockReplyCtx struct {
|
||||
Platform string
|
||||
ChatID string
|
||||
UserID string
|
||||
}
|
||||
|
||||
// MockPlatform implements core.Platform for blackbox tests.
|
||||
// Messages are injected via InjectMessage; outbound calls (Reply, Send,
|
||||
// SendCard, etc.) are recorded and retrievable for assertions.
|
||||
type MockPlatform struct {
|
||||
mu sync.Mutex
|
||||
name string
|
||||
handler core.MessageHandler
|
||||
sent []*SentMessage
|
||||
notify chan struct{}
|
||||
}
|
||||
|
||||
// New creates a named MockPlatform ready for use in tests.
|
||||
func New(name string) *MockPlatform {
|
||||
return &MockPlatform{
|
||||
name: name,
|
||||
notify: make(chan struct{}, 1),
|
||||
}
|
||||
}
|
||||
|
||||
// ── core.Platform interface ──────────────────────────────────────────────────
|
||||
|
||||
func (p *MockPlatform) Name() string { return p.name }
|
||||
|
||||
func (p *MockPlatform) Start(handler core.MessageHandler) error {
|
||||
p.handler = handler
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *MockPlatform) Stop() error { return nil }
|
||||
|
||||
func (p *MockPlatform) Reply(ctx context.Context, replyCtx any, content string) error {
|
||||
p.record(&SentMessage{Content: content, ReplyCtx: replyCtx, At: time.Now()})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *MockPlatform) Send(ctx context.Context, replyCtx any, content string) error {
|
||||
return p.Reply(ctx, replyCtx, content)
|
||||
}
|
||||
|
||||
// ── Optional platform interfaces ─────────────────────────────────────────────
|
||||
|
||||
func (p *MockPlatform) SendCard(ctx context.Context, replyCtx any, card *core.Card) error {
|
||||
p.record(&SentMessage{Content: card.RenderText(), Card: card, ReplyCtx: replyCtx, At: time.Now()})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *MockPlatform) ReplyCard(ctx context.Context, replyCtx any, card *core.Card) error {
|
||||
return p.SendCard(ctx, replyCtx, card)
|
||||
}
|
||||
|
||||
func (p *MockPlatform) SendWithButtons(ctx context.Context, replyCtx any, content string, buttons [][]core.ButtonOption) error {
|
||||
return p.Reply(ctx, replyCtx, content)
|
||||
}
|
||||
|
||||
func (p *MockPlatform) SendImage(ctx context.Context, replyCtx any, img core.ImageAttachment) error {
|
||||
p.record(&SentMessage{Content: "[image]", ReplyCtx: replyCtx, At: time.Now()})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *MockPlatform) SendFile(ctx context.Context, replyCtx any, file core.FileAttachment) error {
|
||||
p.record(&SentMessage{Content: fmt.Sprintf("[file: %s]", file.FileName), ReplyCtx: replyCtx, At: time.Now()})
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── Message injection ────────────────────────────────────────────────────────
|
||||
|
||||
// InjectMessage simulates a user sending a plain-text message to cc-connect.
|
||||
// userID and chatID form the session key; each unique (userID, chatID) pair is
|
||||
// an independent session.
|
||||
func (p *MockPlatform) InjectMessage(userID, chatID, content string) {
|
||||
p.InjectMessageWithAttachments(userID, chatID, content, nil, nil)
|
||||
}
|
||||
|
||||
// InjectMessageWithAttachments simulates a user sending a message with attachments.
|
||||
func (p *MockPlatform) InjectMessageWithAttachments(
|
||||
userID, chatID, content string,
|
||||
images []core.ImageAttachment,
|
||||
files []core.FileAttachment,
|
||||
) {
|
||||
msg := &core.Message{
|
||||
Platform: p.name,
|
||||
SessionKey: fmt.Sprintf("%s:%s:%s", p.name, chatID, userID),
|
||||
MessageID: fmt.Sprintf("msg_%d_%s", time.Now().UnixNano(), userID),
|
||||
UserID: userID,
|
||||
UserName: "test-user-" + userID,
|
||||
ChatName: chatID,
|
||||
Content: content,
|
||||
Images: images,
|
||||
Files: files,
|
||||
ReplyCtx: &MockReplyCtx{Platform: p.name, ChatID: chatID, UserID: userID},
|
||||
}
|
||||
// Run in goroutine, matching how real platforms deliver messages.
|
||||
go p.handler(p, msg)
|
||||
}
|
||||
|
||||
// ── Assertion helpers ────────────────────────────────────────────────────────
|
||||
|
||||
// WaitForTurnComplete waits until the message stream has been "stable" —
|
||||
// no new messages — for at least idlePeriod, then returns all messages
|
||||
// received since startIdx. This is the right way to wait for a full agent
|
||||
// turn when cc-connect may send multiple messages per turn (thinking indicators,
|
||||
// progress updates, then the final reply).
|
||||
//
|
||||
// A good idlePeriod for real agents is 3-5s; set timeout to your test deadline.
|
||||
func (p *MockPlatform) WaitForTurnComplete(startIdx int, idlePeriod, timeout time.Duration) []*SentMessage {
|
||||
deadline := time.Now().Add(timeout)
|
||||
|
||||
// We must first receive at least one message after startIdx.
|
||||
if !func() bool {
|
||||
for {
|
||||
p.mu.Lock()
|
||||
if len(p.sent) > startIdx {
|
||||
p.mu.Unlock()
|
||||
return true
|
||||
}
|
||||
p.mu.Unlock()
|
||||
remaining := time.Until(deadline)
|
||||
if remaining <= 0 {
|
||||
return false
|
||||
}
|
||||
select {
|
||||
case <-p.notify:
|
||||
case <-time.After(remaining):
|
||||
}
|
||||
}
|
||||
}() {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Now wait for stability: no new messages for idlePeriod.
|
||||
for {
|
||||
lastCount := p.MessageCount()
|
||||
wait := idlePeriod
|
||||
if time.Until(deadline) < wait {
|
||||
wait = time.Until(deadline)
|
||||
}
|
||||
if wait <= 0 {
|
||||
break
|
||||
}
|
||||
|
||||
select {
|
||||
case <-p.notify:
|
||||
// New message arrived — reset stability window.
|
||||
continue
|
||||
case <-time.After(wait):
|
||||
}
|
||||
|
||||
// Check if count changed during the wait (notify is non-blocking so
|
||||
// we might have missed a signal).
|
||||
if p.MessageCount() == lastCount {
|
||||
break // stable
|
||||
}
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
out := append([]*SentMessage(nil), p.sent[startIdx:]...)
|
||||
p.mu.Unlock()
|
||||
return out
|
||||
}
|
||||
|
||||
// WaitForReply waits until at least one new message is sent after startIdx,
|
||||
// then returns it. Returns nil on timeout.
|
||||
func (p *MockPlatform) WaitForReply(startIdx int, timeout time.Duration) *SentMessage {
|
||||
deadline := time.Now().Add(timeout)
|
||||
for {
|
||||
p.mu.Lock()
|
||||
if len(p.sent) > startIdx {
|
||||
msg := p.sent[startIdx]
|
||||
p.mu.Unlock()
|
||||
return msg
|
||||
}
|
||||
p.mu.Unlock()
|
||||
|
||||
remaining := time.Until(deadline)
|
||||
if remaining <= 0 {
|
||||
return nil
|
||||
}
|
||||
// Block until a new message arrives or timeout.
|
||||
select {
|
||||
case <-p.notify:
|
||||
case <-time.After(remaining):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WaitForN waits until at least n messages have been sent in total.
|
||||
// Returns the current slice when satisfied, or times out.
|
||||
func (p *MockPlatform) WaitForN(n int, timeout time.Duration) ([]*SentMessage, bool) {
|
||||
deadline := time.Now().Add(timeout)
|
||||
for {
|
||||
p.mu.Lock()
|
||||
if len(p.sent) >= n {
|
||||
out := append([]*SentMessage(nil), p.sent...)
|
||||
p.mu.Unlock()
|
||||
return out, true
|
||||
}
|
||||
p.mu.Unlock()
|
||||
|
||||
remaining := time.Until(deadline)
|
||||
if remaining <= 0 {
|
||||
return p.GetSentMessages(), false
|
||||
}
|
||||
select {
|
||||
case <-p.notify:
|
||||
case <-time.After(remaining):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WaitForMessageContaining waits until any message (starting from startIdx)
|
||||
// contains the given substring (case-insensitive). Returns the matching message
|
||||
// or nil on timeout.
|
||||
func (p *MockPlatform) WaitForMessageContaining(startIdx int, substr string, timeout time.Duration) *SentMessage {
|
||||
deadline := time.Now().Add(timeout)
|
||||
for {
|
||||
p.mu.Lock()
|
||||
for i := startIdx; i < len(p.sent); i++ {
|
||||
if strings.Contains(strings.ToLower(p.sent[i].Text()), strings.ToLower(substr)) {
|
||||
msg := p.sent[i]
|
||||
p.mu.Unlock()
|
||||
return msg
|
||||
}
|
||||
}
|
||||
p.mu.Unlock()
|
||||
|
||||
remaining := time.Until(deadline)
|
||||
if remaining <= 0 {
|
||||
return nil
|
||||
}
|
||||
select {
|
||||
case <-p.notify:
|
||||
case <-time.After(remaining):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MessageCount returns the current number of sent messages.
|
||||
func (p *MockPlatform) MessageCount() int {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return len(p.sent)
|
||||
}
|
||||
|
||||
// GetSentMessages returns a snapshot of all sent messages.
|
||||
func (p *MockPlatform) GetSentMessages() []*SentMessage {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return append([]*SentMessage(nil), p.sent...)
|
||||
}
|
||||
|
||||
// GetLastMessage returns the most recent sent message, or nil if none.
|
||||
func (p *MockPlatform) GetLastMessage() *SentMessage {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if len(p.sent) == 0 {
|
||||
return nil
|
||||
}
|
||||
return p.sent[len(p.sent)-1]
|
||||
}
|
||||
|
||||
// AllText returns all sent message texts joined by newline, useful for
|
||||
// debugging failed assertions.
|
||||
func (p *MockPlatform) AllText() string {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
parts := make([]string, len(p.sent))
|
||||
for i, m := range p.sent {
|
||||
parts[i] = m.Text()
|
||||
}
|
||||
return strings.Join(parts, "\n---\n")
|
||||
}
|
||||
|
||||
// Reset clears all recorded messages. Use between sub-scenarios within one
|
||||
// test where you need a clean slate.
|
||||
func (p *MockPlatform) Reset() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.sent = nil
|
||||
}
|
||||
|
||||
// ── internal ─────────────────────────────────────────────────────────────────
|
||||
|
||||
func (p *MockPlatform) record(m *SentMessage) {
|
||||
p.mu.Lock()
|
||||
p.sent = append(p.sent, m)
|
||||
p.mu.Unlock()
|
||||
// Non-blocking notify: if a waiter is already queued, it will pick up the
|
||||
// message on its next iteration.
|
||||
select {
|
||||
case p.notify <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,561 @@
|
||||
//go:build smoke
|
||||
|
||||
// Package e2e contains smoke and regression tests for cc-connect.
|
||||
// These tests verify core functionality using real components where possible,
|
||||
// and mocks where necessary (e.g., platform network calls).
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/chenhg5/cc-connect/config"
|
||||
"github.com/chenhg5/cc-connect/core"
|
||||
"github.com/chenhg5/cc-connect/tests/mocks"
|
||||
"github.com/chenhg5/cc-connect/tests/mocks/fake"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// T-100: Config Loading Smoke Test
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestSmoke_ConfigLoading(t *testing.T) {
|
||||
// Create a temporary config file
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.toml")
|
||||
|
||||
configContent := `
|
||||
data_dir = "~/.cc-connect"
|
||||
|
||||
[[projects]]
|
||||
name = "test-project"
|
||||
|
||||
[projects.agent]
|
||||
type = "claudecode"
|
||||
|
||||
[[projects.platforms]]
|
||||
type = "feishu"
|
||||
|
||||
[log]
|
||||
level = "info"
|
||||
`
|
||||
err := os.WriteFile(configPath, []byte(configContent), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Load the config
|
||||
config.ConfigPath = configPath
|
||||
cfg, err := config.Load(configPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify basic config fields
|
||||
assert.Equal(t, "~/.cc-connect", cfg.DataDir)
|
||||
assert.Len(t, cfg.Projects, 1)
|
||||
assert.Equal(t, "test-project", cfg.Projects[0].Name)
|
||||
assert.Equal(t, "claudecode", cfg.Projects[0].Agent.Type)
|
||||
|
||||
t.Log("Config loading: PASS")
|
||||
}
|
||||
|
||||
func TestSmoke_ConfigLoadingInvalid(t *testing.T) {
|
||||
// Test that invalid config is properly rejected
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "invalid.toml")
|
||||
|
||||
// Write invalid TOML
|
||||
err := os.WriteFile(configPath, []byte("invalid toml content ="), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = config.Load(configPath)
|
||||
assert.Error(t, err)
|
||||
|
||||
t.Log("Config loading (invalid): PASS")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// T-101: All Agents Initialization Smoke Test
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestSmoke_AllAgentsInit(t *testing.T) {
|
||||
// Verify all registered agents can be listed
|
||||
ctx := context.Background()
|
||||
|
||||
// Get list of registered agent factories
|
||||
// Note: This tests the factory registration, not actual CLI existence
|
||||
t.Log("Registered agents via factory:", listRegisteredAgents())
|
||||
|
||||
// Verify we have agents registered (at least the ones in the codebase)
|
||||
assert.NotEmpty(t, listRegisteredAgents(), "should have at least one agent registered")
|
||||
|
||||
// Try to create each registered agent with minimal opts
|
||||
for _, agentName := range listRegisteredAgents() {
|
||||
opts := map[string]any{
|
||||
"project_dir": t.TempDir(),
|
||||
}
|
||||
agent, err := core.CreateAgent(agentName, opts)
|
||||
if err != nil {
|
||||
t.Logf("Agent %s creation: %v (may require CLI)", agentName, err)
|
||||
continue
|
||||
}
|
||||
assert.NotNil(t, agent)
|
||||
assert.Equal(t, agentName, agent.Name())
|
||||
|
||||
// Clean up
|
||||
agent.Stop()
|
||||
t.Logf("Agent %s: initialized successfully", agentName)
|
||||
}
|
||||
|
||||
// Use ctx to avoid compiler warning
|
||||
assert.NotNil(t, ctx)
|
||||
|
||||
t.Log("All agents init: PASS")
|
||||
}
|
||||
|
||||
func listRegisteredAgents() []string {
|
||||
// This requires access to the internal registry
|
||||
// We'll test via the factory pattern
|
||||
agents := []string{
|
||||
"claudecode", "codex", "cursor", "gemini",
|
||||
"iflow", "opencode", "pi", "qoder",
|
||||
}
|
||||
return agents
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// T-102: All Platforms Initialization Smoke Test
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestSmoke_AllPlatformsInit(t *testing.T) {
|
||||
// Verify all registered platforms can be listed
|
||||
t.Log("Registered platforms:", listRegisteredPlatforms())
|
||||
|
||||
// Verify we have platforms registered
|
||||
assert.NotEmpty(t, listRegisteredPlatforms(), "should have at least one platform registered")
|
||||
|
||||
// Try to create each registered platform with minimal opts
|
||||
for _, platformName := range listRegisteredPlatforms() {
|
||||
opts := map[string]any{
|
||||
"enabled": true,
|
||||
}
|
||||
platform, err := core.CreatePlatform(platformName, opts)
|
||||
if err != nil {
|
||||
t.Logf("Platform %s creation: %v", platformName, err)
|
||||
continue
|
||||
}
|
||||
assert.NotNil(t, platform)
|
||||
assert.Equal(t, platformName, platform.Name())
|
||||
|
||||
// Clean up - don't actually start, just verify creation
|
||||
t.Logf("Platform %s: created successfully", platformName)
|
||||
}
|
||||
|
||||
t.Log("All platforms init: PASS")
|
||||
}
|
||||
|
||||
func listRegisteredPlatforms() []string {
|
||||
platforms := []string{
|
||||
"feishu", "telegram", "discord", "slack",
|
||||
"dingtalk", "wecom", "qq", "qqbot", "line",
|
||||
}
|
||||
return platforms
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// T-103: Session Management Smoke Test
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestSmoke_SessionManagement(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a fake agent for session testing
|
||||
agent := fake.NewFakeAgent("test-agent")
|
||||
|
||||
// Test session creation
|
||||
session, err := agent.StartSession(ctx, "test-session-001")
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, session)
|
||||
assert.Equal(t, "test-session-001", session.CurrentSessionID())
|
||||
assert.True(t, session.Alive())
|
||||
|
||||
// Test session list
|
||||
sessions, err := agent.ListSessions(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, sessions)
|
||||
|
||||
// Test sending a message
|
||||
err = session.Send("Hello", nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Test session is still alive
|
||||
assert.True(t, session.Alive())
|
||||
|
||||
// Test session close
|
||||
err = session.Close()
|
||||
require.NoError(t, err)
|
||||
assert.False(t, session.Alive())
|
||||
|
||||
t.Log("Session management: PASS")
|
||||
}
|
||||
|
||||
func TestSmoke_SessionMessageFlow(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a fake agent session with predefined responses
|
||||
session := fake.NewFakeAgentSession("test-session-002")
|
||||
session.AddTextEvent("Thinking...").AddResultEvent("Final answer.")
|
||||
|
||||
agent := fake.NewFakeAgentWithSession("test-agent", "test-session-002", session)
|
||||
|
||||
// Start session
|
||||
started, err := agent.StartSession(ctx, "test-session-002")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "test-session-002", started.CurrentSessionID())
|
||||
|
||||
// Send message
|
||||
err = started.Send("What is 2+2?", nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Collect events
|
||||
var events []core.Event
|
||||
for e := range started.Events() {
|
||||
events = append(events, e)
|
||||
if e.Done {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Verify events
|
||||
assert.NotEmpty(t, events)
|
||||
assert.Equal(t, core.EventType("text"), events[0].Type)
|
||||
|
||||
t.Log("Session message flow: PASS")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// T-104: Command Parsing Smoke Test
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestSmoke_CommandParsing(t *testing.T) {
|
||||
registry := core.NewCommandRegistry()
|
||||
|
||||
// Add some test commands
|
||||
registry.Add("test", "Test command", "echo {{1}}", "", "", "test")
|
||||
|
||||
// Test command resolution
|
||||
cmd, ok := registry.Resolve("test")
|
||||
require.True(t, ok)
|
||||
assert.NotNil(t, cmd)
|
||||
assert.Equal(t, "test", cmd.Name)
|
||||
assert.Equal(t, "Test command", cmd.Description)
|
||||
|
||||
// Test built-in commands
|
||||
registry.Add("help", "Show help", "help prompt", "", "", "builtin")
|
||||
cmd, ok = registry.Resolve("help")
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "help", cmd.Name)
|
||||
|
||||
// Test command not found
|
||||
cmd, ok = registry.Resolve("nonexistent")
|
||||
assert.False(t, ok)
|
||||
assert.Nil(t, cmd)
|
||||
|
||||
t.Log("Command parsing: PASS")
|
||||
}
|
||||
|
||||
func TestSmoke_CommandRegistryList(t *testing.T) {
|
||||
registry := core.NewCommandRegistry()
|
||||
|
||||
// Add multiple commands
|
||||
registry.Add("cmd1", "Command 1", "{{1}}", "", "", "test")
|
||||
registry.Add("cmd2", "Command 2", "{{2}}", "", "", "test")
|
||||
registry.Add("alias", "Alias for cmd1", "", "", "", "alias")
|
||||
|
||||
// List all commands
|
||||
commands := registry.ListAll()
|
||||
assert.Len(t, commands, 3)
|
||||
|
||||
// Test clear by source
|
||||
registry.ClearSource("test")
|
||||
commands = registry.ListAll()
|
||||
assert.Len(t, commands, 1) // only alias should remain
|
||||
|
||||
t.Log("Command registry list: PASS")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// T-105: Agent ↔ Platform Message Flow Smoke Test
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestSmoke_MessageFlow(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Create mock platform
|
||||
mockPlatform := new(mocks.MockPlatform)
|
||||
mockPlatform.On("Name").Return("test-platform")
|
||||
mockPlatform.On("Start", mock.Anything).Return(nil)
|
||||
mockPlatform.On("Stop").Return(nil)
|
||||
|
||||
// Create mock agent session with events
|
||||
events := []core.Event{
|
||||
{Type: core.EventThinking, Content: "Processing..."},
|
||||
{Type: core.EventText, Content: "I am thinking..."},
|
||||
{Type: core.EventResult, Content: "Final response.", Done: true},
|
||||
}
|
||||
mockSession := mocks.NewMockAgentSessionWithEvents("session-001", events)
|
||||
|
||||
// Create mock agent
|
||||
mockAgent := new(mocks.MockAgent)
|
||||
mockAgent.On("StartSession", ctx, "session-001").Return(mockSession, nil)
|
||||
|
||||
// Test message handler
|
||||
handler := fake.NewTestMessageHandler()
|
||||
|
||||
// Start platform with handler
|
||||
err := mockPlatform.Start(handler.Handle)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Simulate message flow: platform receives message → routed to agent
|
||||
// (This is a simplified test - real flow goes through Engine)
|
||||
incomingMessage := fake.TestMessageWithContent("Hello agent")
|
||||
|
||||
// Simulate platform passing message to handler
|
||||
handler.Handle(mockPlatform, incomingMessage)
|
||||
|
||||
// Verify message was received
|
||||
messages := handler.GetMessages()
|
||||
require.Len(t, messages, 1)
|
||||
assert.Equal(t, "Hello agent", messages[0].Content)
|
||||
|
||||
// Test agent session responds
|
||||
session, err := mockAgent.StartSession(ctx, "session-001")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Send message to agent
|
||||
err = session.Send("Hello agent", nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify mock calls happened (only agent, not platform since we didn't call Name/Stop)
|
||||
mockAgent.AssertExpectations(t)
|
||||
|
||||
t.Log("Message flow: PASS")
|
||||
}
|
||||
|
||||
func TestSmoke_PlatformReply(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Create mock platform that records replies
|
||||
mockPlatform := new(mocks.MockPlatform)
|
||||
|
||||
// Expect Reply call
|
||||
mockPlatform.On("Reply", ctx, mock.Anything, "Hello from agent").Return(nil)
|
||||
|
||||
// Simulate platform reply
|
||||
err := mockPlatform.Reply(ctx, nil, "Hello from agent")
|
||||
require.NoError(t, err)
|
||||
|
||||
mockPlatform.AssertExpectations(t)
|
||||
|
||||
t.Log("Platform reply: PASS")
|
||||
}
|
||||
|
||||
func TestSmoke_EventTypes(t *testing.T) {
|
||||
// Test all event types can be created
|
||||
events := []core.Event{
|
||||
{Type: core.EventText, Content: "text content"},
|
||||
{Type: core.EventThinking, Content: "thinking..."},
|
||||
{Type: core.EventToolUse, ToolName: "Bash", ToolInput: "ls"},
|
||||
{Type: core.EventToolResult, ToolResult: "file1.go"},
|
||||
{Type: core.EventResult, Content: "final result", Done: true},
|
||||
{Type: core.EventError, Error: context.DeadlineExceeded, Done: true},
|
||||
{
|
||||
Type: core.EventPermissionRequest,
|
||||
ToolName: "Bash",
|
||||
ToolInput: "rm -rf /",
|
||||
RequestID: "req-001",
|
||||
},
|
||||
}
|
||||
|
||||
for _, e := range events {
|
||||
assert.NotEmpty(t, string(e.Type))
|
||||
}
|
||||
|
||||
t.Log("Event types: PASS")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// T-107: Multi-Workspace Switch (P1, but quick to add)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestSmoke_WorkspaceSwitch(t *testing.T) {
|
||||
// Create simple workspace state maps to verify isolation concept
|
||||
ws1 := map[string]string{
|
||||
"id": "workspace-1",
|
||||
"session": "session-A",
|
||||
"agent": "claudecode",
|
||||
}
|
||||
ws2 := map[string]string{
|
||||
"id": "workspace-2",
|
||||
"session": "session-B",
|
||||
"agent": "gemini",
|
||||
}
|
||||
|
||||
assert.Equal(t, "workspace-1", ws1["id"])
|
||||
assert.Equal(t, "workspace-2", ws2["id"])
|
||||
assert.NotEqual(t, ws1["session"], ws2["session"], "workspaces should have independent sessions")
|
||||
|
||||
t.Log("Workspace switch: PASS")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// T-108: Rate Limiter Basic (P1, but quick to add)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestSmoke_RateLimiter(t *testing.T) {
|
||||
// Create a rate limiter: 5 messages per 60 seconds
|
||||
rl := core.NewRateLimiter(5, 60*time.Second)
|
||||
defer rl.Stop()
|
||||
|
||||
// Should allow messages up to limit
|
||||
for i := 0; i < 5; i++ {
|
||||
allowed := rl.Allow("user1")
|
||||
assert.True(t, allowed, "message %d should be allowed", i+1)
|
||||
}
|
||||
|
||||
// Should block after limit
|
||||
allowed := rl.Allow("user1")
|
||||
assert.False(t, allowed, "6th message should be blocked")
|
||||
|
||||
// Different user should be allowed
|
||||
allowed = rl.Allow("user2")
|
||||
assert.True(t, allowed, "different user should be allowed")
|
||||
|
||||
t.Log("Rate limiter: PASS")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// T-111: Markdown 渲染冒烟测试
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestSmoke_MarkdownRender(t *testing.T) {
|
||||
// Test basic card with markdown rendering
|
||||
card := core.NewCard().
|
||||
Title("Test Card", "blue").
|
||||
Markdown("## Heading\nThis is **bold** and *italic* text.").
|
||||
Markdown("- Item 1\n- Item 2\n- Item 3").
|
||||
Markdown("1. Numbered item\n2. Another item").
|
||||
Markdown("> Blockquote text").
|
||||
Markdown("`inline code` and ```code block```").
|
||||
Build()
|
||||
|
||||
// Verify card structure
|
||||
assert.NotNil(t, card)
|
||||
assert.NotNil(t, card.Header)
|
||||
assert.Equal(t, "Test Card", card.Header.Title)
|
||||
assert.Equal(t, "blue", card.Header.Color)
|
||||
|
||||
// Count markdown elements (should be 5)
|
||||
var markdownCount int
|
||||
for _, elem := range card.Elements {
|
||||
if _, ok := elem.(core.CardMarkdown); ok {
|
||||
markdownCount++
|
||||
}
|
||||
}
|
||||
assert.Equal(t, 5, markdownCount, "should have 5 markdown elements")
|
||||
|
||||
// Test text fallback rendering
|
||||
text := card.RenderText()
|
||||
assert.Contains(t, text, "Test Card")
|
||||
assert.Contains(t, text, "Heading")
|
||||
assert.Contains(t, text, "bold")
|
||||
assert.Contains(t, text, "italic")
|
||||
assert.Contains(t, text, "Item 1")
|
||||
assert.Contains(t, text, "Blockquote")
|
||||
assert.Contains(t, text, "inline code")
|
||||
|
||||
// Test card with only divider
|
||||
card2 := core.NewCard().
|
||||
Markdown("Before divider").
|
||||
Divider().
|
||||
Markdown("After divider").
|
||||
Build()
|
||||
|
||||
text2 := card2.RenderText()
|
||||
assert.Contains(t, text2, "Before divider")
|
||||
assert.Contains(t, text2, "---")
|
||||
assert.Contains(t, text2, "After divider")
|
||||
|
||||
// Test card with select element
|
||||
card3 := core.NewCard().
|
||||
Title("Select Card", "green").
|
||||
Select("Choose an option", []core.CardSelectOption{
|
||||
{Text: "Option A", Value: "a"},
|
||||
{Text: "Option B", Value: "b"},
|
||||
}, "a").
|
||||
Build()
|
||||
|
||||
text3 := card3.RenderText()
|
||||
assert.Contains(t, text3, "Choose an option")
|
||||
assert.Contains(t, text3, "Option A")
|
||||
assert.Contains(t, text3, "Option B")
|
||||
|
||||
// Test HasButtons
|
||||
assert.False(t, card.HasButtons())
|
||||
assert.True(t, core.NewCard().Buttons(core.DefaultBtn("Test", "act:/test")).Build().HasButtons())
|
||||
|
||||
t.Log("Markdown render: PASS")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// T-112: Webhook 注册和回调冒烟测试
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestSmoke_WebhookCallback(t *testing.T) {
|
||||
// Test webhook callback data structure
|
||||
type webhookCallback struct {
|
||||
Action string `json:"action"`
|
||||
SessionID string `json:"session_id"`
|
||||
Data map[string]string `json:"data"`
|
||||
}
|
||||
|
||||
// Simulate callback parsing
|
||||
callbackData := `{"action":"callback","session_id":"test-001","data":{"button":"confirm"}}`
|
||||
|
||||
// Verify callback structure can be marshaled
|
||||
cb := struct {
|
||||
Action string `json:"action"`
|
||||
SessionID string `json:"session_id"`
|
||||
Data map[string]string `json:"data"`
|
||||
}{
|
||||
Action: "callback",
|
||||
SessionID: "test-001",
|
||||
Data: map[string]string{"button": "confirm"},
|
||||
}
|
||||
|
||||
assert.Equal(t, "callback", cb.Action)
|
||||
assert.Equal(t, "test-001", cb.SessionID)
|
||||
assert.Equal(t, "confirm", cb.Data["button"])
|
||||
|
||||
// Verify the raw data can be parsed
|
||||
assert.Contains(t, callbackData, "callback")
|
||||
assert.Contains(t, callbackData, "test-001")
|
||||
|
||||
// Test action routing
|
||||
actions := []string{"callback", "act:/confirm", "act:/cancel", "act:/delete"}
|
||||
for _, action := range actions {
|
||||
assert.NotEmpty(t, action)
|
||||
// Action should be either callback or act:/ prefix
|
||||
isValid := action == "callback" || strings.HasPrefix(action, "act:/")
|
||||
assert.True(t, isValid, "action %s should be valid", action)
|
||||
}
|
||||
|
||||
// Use the callbackData to avoid compiler warning
|
||||
assert.NotEmpty(t, callbackData)
|
||||
|
||||
t.Log("Webhook callback: PASS")
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,33 @@
|
||||
//go:build integration
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/chenhg5/cc-connect/config"
|
||||
"github.com/chenhg5/cc-connect/core"
|
||||
)
|
||||
|
||||
func joinMsgContent(msgs []mockMessage) string {
|
||||
var parts []string
|
||||
for _, m := range msgs {
|
||||
parts = append(parts, m.Content)
|
||||
}
|
||||
return strings.Join(parts, "\n")
|
||||
}
|
||||
|
||||
func configProviderToCore(p config.ProviderConfig) core.ProviderConfig {
|
||||
c := core.ProviderConfig{
|
||||
Name: p.Name, APIKey: p.APIKey, BaseURL: p.BaseURL,
|
||||
Model: p.Model, Thinking: p.Thinking, Env: p.Env,
|
||||
}
|
||||
for _, m := range p.Models {
|
||||
c.Models = append(c.Models, core.ModelOption{Name: m.Model, Alias: m.Alias})
|
||||
}
|
||||
if p.Codex != nil {
|
||||
c.CodexWireAPI = p.Codex.WireAPI
|
||||
c.CodexHTTPHeaders = p.Codex.HTTPHeaders
|
||||
}
|
||||
return c
|
||||
}
|
||||
@@ -0,0 +1,450 @@
|
||||
//go:build integration
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/chenhg5/cc-connect/config"
|
||||
"github.com/chenhg5/cc-connect/core"
|
||||
)
|
||||
|
||||
// testConfigPath returns the path to config.test.toml co-located with this
|
||||
// test file. Override via CC_TEST_CONFIG env var.
|
||||
func testConfigPath(t *testing.T) string {
|
||||
t.Helper()
|
||||
if p := os.Getenv("CC_TEST_CONFIG"); p != "" {
|
||||
return p
|
||||
}
|
||||
_, thisFile, _, _ := runtime.Caller(0)
|
||||
return filepath.Join(filepath.Dir(thisFile), "config.test.toml")
|
||||
}
|
||||
|
||||
// setupE2E loads config.test.toml, finds the named project, creates a real
|
||||
// agent with provider wiring, and returns Engine + mockPlatform. All work_dir
|
||||
// and session state use t.TempDir() for full isolation.
|
||||
func setupE2E(t *testing.T, projectName string) (*core.Engine, *mockPlatform, func()) {
|
||||
t.Helper()
|
||||
|
||||
cfgPath := testConfigPath(t)
|
||||
if _, err := os.Stat(cfgPath); err != nil {
|
||||
t.Skipf("skip: test config %s not found (copy config.test.toml.example and fill in credentials)", cfgPath)
|
||||
}
|
||||
|
||||
cfg, err := config.Load(cfgPath)
|
||||
if err != nil {
|
||||
t.Fatalf("cannot load test config: %v", err)
|
||||
}
|
||||
|
||||
var proj *config.ProjectConfig
|
||||
for i := range cfg.Projects {
|
||||
if cfg.Projects[i].Name == projectName {
|
||||
proj = &cfg.Projects[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if proj == nil {
|
||||
t.Fatalf("project %q not found in test config", projectName)
|
||||
}
|
||||
|
||||
agentType := proj.Agent.Type
|
||||
bin, err := findAgentBin(agentType)
|
||||
if err != nil {
|
||||
t.Skipf("skip: %v", err)
|
||||
}
|
||||
if _, err := exec.LookPath(bin); err != nil {
|
||||
t.Skipf("skip: %s binary not in PATH", bin)
|
||||
}
|
||||
|
||||
workDir := t.TempDir()
|
||||
opts := make(map[string]any)
|
||||
for k, v := range proj.Agent.Options {
|
||||
opts[k] = v
|
||||
}
|
||||
opts["work_dir"] = workDir
|
||||
|
||||
agent, err := core.CreateAgent(agentType, opts)
|
||||
if err != nil {
|
||||
t.Skipf("skip: cannot create agent: %v", err)
|
||||
}
|
||||
|
||||
if ps, ok := agent.(core.ProviderSwitcher); ok {
|
||||
var providers []core.ProviderConfig
|
||||
for _, ref := range proj.Agent.ProviderRefs {
|
||||
for _, gp := range cfg.Providers {
|
||||
if gp.Name == ref {
|
||||
providers = append(providers, configProviderToCore(gp))
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(providers) > 0 {
|
||||
ps.SetProviders(providers)
|
||||
if provName, _ := opts["provider"].(string); provName != "" {
|
||||
ps.SetActiveProvider(provName)
|
||||
} else {
|
||||
ps.SetActiveProvider(providers[0].Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mp := &mockPlatform{agent: agent}
|
||||
sessPath := filepath.Join(workDir, "sessions.json")
|
||||
e := core.NewEngine("e2e-test", agent, []core.Platform{mp}, sessPath, core.LangEnglish)
|
||||
|
||||
return e, mp, func() {
|
||||
agent.Stop()
|
||||
e.Stop()
|
||||
}
|
||||
}
|
||||
|
||||
type e2eHelper struct {
|
||||
t *testing.T
|
||||
e *core.Engine
|
||||
mp *mockPlatform
|
||||
uk string
|
||||
}
|
||||
|
||||
func (h *e2eHelper) send(content string) {
|
||||
h.mp.clear()
|
||||
h.e.ReceiveMessage(h.mp, &core.Message{
|
||||
SessionKey: h.uk, Platform: "mock", UserID: "e2e-user",
|
||||
UserName: "tester", Content: content, ReplyCtx: "ctx",
|
||||
})
|
||||
}
|
||||
|
||||
func (h *e2eHelper) waitReply(timeout time.Duration) string {
|
||||
msgs, ok := waitForMessages(h.mp, 1, timeout)
|
||||
if !ok {
|
||||
h.t.Fatalf("timeout waiting for reply; sent so far: %v", h.mp.getSent())
|
||||
}
|
||||
reply := joinMsgContent(msgs)
|
||||
if strings.Contains(strings.ToLower(reply), "auth") && strings.Contains(strings.ToLower(reply), "balance") {
|
||||
h.t.Skipf("skip: provider auth/balance error: %s", reply)
|
||||
}
|
||||
return reply
|
||||
}
|
||||
|
||||
func (h *e2eHelper) waitCmd(timeout time.Duration) string {
|
||||
return h.waitReply(timeout)
|
||||
}
|
||||
|
||||
func (h *e2eHelper) sendAndWait(content string, timeout time.Duration) string {
|
||||
h.send(content)
|
||||
return h.waitReply(timeout)
|
||||
}
|
||||
|
||||
// countSessions counts the "msgs" markers in /list output (each session line
|
||||
// contains "N msgs"), giving us the session count.
|
||||
func countSessions(listOutput string) int {
|
||||
return strings.Count(listOutput, "msgs")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Comprehensive E2E: covers /list /new /name /switch /delete /current /stop
|
||||
// Each test function runs against ONE agent type. We define two entry points
|
||||
// (Codex, ClaudeCode) so both are exercised. Skips gracefully if binary or
|
||||
// config is missing.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func runE2E_FullSessionCommands(t *testing.T, project string) {
|
||||
e, mp, cleanup := setupE2E(t, project)
|
||||
defer cleanup()
|
||||
|
||||
h := &e2eHelper{t: t, e: e, mp: mp, uk: sessionKey("e2e-cmds")}
|
||||
|
||||
// ────── 1. First message → agent replies ──────
|
||||
t.Log("[1] sending first message")
|
||||
reply := h.sendAndWait("respond with exactly: E2E_PING", 90*time.Second)
|
||||
t.Logf("[1] reply: %.120s", reply)
|
||||
|
||||
// ────── 2. /list → 1 session ──────
|
||||
t.Log("[2] /list")
|
||||
list1 := h.sendAndWait("/list", 10*time.Second)
|
||||
c1 := countSessions(list1)
|
||||
if c1 < 1 {
|
||||
t.Fatalf("[2] /list should show >= 1 session, got %d\n%s", c1, list1)
|
||||
}
|
||||
t.Logf("[2] /list → %d session(s)", c1)
|
||||
|
||||
// ────── 3. /current ──────
|
||||
t.Log("[3] /current")
|
||||
cur := h.sendAndWait("/current", 10*time.Second)
|
||||
if !strings.Contains(strings.ToLower(cur), "session") && !strings.Contains(strings.ToLower(cur), "s1") {
|
||||
t.Logf("[3] /current output: %s", cur)
|
||||
}
|
||||
t.Logf("[3] /current OK")
|
||||
|
||||
// ────── 4. /new with custom name ──────
|
||||
t.Log("[4] /new my-named-session")
|
||||
h.sendAndWait("/new my-named-session", 10*time.Second)
|
||||
t.Log("[4] /new done")
|
||||
|
||||
// ────── 5. Chat in new session → agent replies ──────
|
||||
t.Log("[5] message in new session")
|
||||
reply5 := h.sendAndWait("respond with exactly: E2E_PONG", 90*time.Second)
|
||||
t.Logf("[5] reply: %.120s", reply5)
|
||||
|
||||
// ────── 6. /list → 2 sessions, name visible ──────
|
||||
t.Log("[6] /list (should show 2 sessions)")
|
||||
list2 := h.sendAndWait("/list", 10*time.Second)
|
||||
c2 := countSessions(list2)
|
||||
if c2 < 2 {
|
||||
t.Fatalf("[6] /list should show >= 2 sessions, got %d\n%s", c2, list2)
|
||||
}
|
||||
if !strings.Contains(list2, "my-named-session") {
|
||||
t.Errorf("[6] /list should show session name 'my-named-session'\n%s", list2)
|
||||
}
|
||||
t.Logf("[6] /list → %d sessions, name OK", c2)
|
||||
|
||||
// ────── 7. /name rename current session ──────
|
||||
t.Log("[7] /name renamed-session")
|
||||
nameReply := h.sendAndWait("/name renamed-session", 10*time.Second)
|
||||
t.Logf("[7] /name reply: %.80s", nameReply)
|
||||
|
||||
// ────── 8. /list → verify renamed ──────
|
||||
t.Log("[8] /list after rename")
|
||||
list3 := h.sendAndWait("/list", 10*time.Second)
|
||||
if !strings.Contains(list3, "renamed-session") {
|
||||
t.Errorf("[8] /list should show 'renamed-session' after rename\n%s", list3)
|
||||
}
|
||||
t.Log("[8] rename confirmed in /list")
|
||||
|
||||
// ────── 9. /switch back to session 1 ──────
|
||||
t.Log("[9] /switch 1")
|
||||
switchReply := h.sendAndWait("/switch 1", 10*time.Second)
|
||||
t.Logf("[9] /switch: %.80s", switchReply)
|
||||
|
||||
// ────── 10. Send message in session 1 → verify context ──────
|
||||
t.Log("[10] message in session 1")
|
||||
reply10 := h.sendAndWait("respond with exactly: BACK_TO_S1", 90*time.Second)
|
||||
t.Logf("[10] reply: %.120s", reply10)
|
||||
|
||||
// ────── 11. /list → still 2 sessions ──────
|
||||
t.Log("[11] /list (still 2 sessions after /switch)")
|
||||
list4 := h.sendAndWait("/list", 10*time.Second)
|
||||
c4 := countSessions(list4)
|
||||
if c4 < 2 {
|
||||
t.Errorf("[11] /list should still show >= 2 sessions, got %d\n%s", c4, list4)
|
||||
}
|
||||
t.Logf("[11] /list → %d sessions", c4)
|
||||
|
||||
// ────── 12. /new → third session ──────
|
||||
t.Log("[12] /new third-session")
|
||||
h.sendAndWait("/new third-session", 10*time.Second)
|
||||
t.Log("[12] /new done")
|
||||
|
||||
// ────── 13. Chat in third session ──────
|
||||
t.Log("[13] message in third session")
|
||||
h.sendAndWait("respond with exactly: THIRD_OK", 90*time.Second)
|
||||
t.Log("[13] reply received")
|
||||
|
||||
// ────── 14. /delete third session ──────
|
||||
t.Log("[14] /delete 3")
|
||||
delReply := h.sendAndWait("/delete 3", 10*time.Second)
|
||||
t.Logf("[14] /delete: %.80s", delReply)
|
||||
|
||||
// ────── 15. /list → session deleted ──────
|
||||
t.Log("[15] /list after /delete")
|
||||
list5 := h.sendAndWait("/list", 10*time.Second)
|
||||
c5 := countSessions(list5)
|
||||
if c5 >= c4+1 {
|
||||
t.Errorf("[15] /list should have fewer sessions after delete, got %d (was %d+1)\n%s", c5, c4, list5)
|
||||
}
|
||||
t.Logf("[15] /list → %d sessions (after delete)", c5)
|
||||
|
||||
// ────── 16. /stop ──────
|
||||
t.Log("[16] /stop")
|
||||
h.send("/stop")
|
||||
time.Sleep(2 * time.Second)
|
||||
t.Log("[16] /stop sent, no crash")
|
||||
|
||||
// ────── 17. /status ──────
|
||||
t.Log("[17] /status")
|
||||
statusReply := h.sendAndWait("/status", 10*time.Second)
|
||||
t.Logf("[17] /status: %.120s", statusReply)
|
||||
|
||||
t.Log("=== ALL STEPS PASSED ===")
|
||||
}
|
||||
|
||||
func TestE2E_Codex_FullSessionCommands(t *testing.T) {
|
||||
runE2E_FullSessionCommands(t, "e2e-codex")
|
||||
}
|
||||
|
||||
func TestE2E_ClaudeCode_FullSessionCommands(t *testing.T) {
|
||||
runE2E_FullSessionCommands(t, "e2e-claudecode")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// E2E: /provider switch (requires multiple providers in config)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func runE2E_ProviderSwitch(t *testing.T, project string) {
|
||||
e, mp, cleanup := setupE2E(t, project)
|
||||
defer cleanup()
|
||||
|
||||
h := &e2eHelper{t: t, e: e, mp: mp, uk: sessionKey("e2e-provider")}
|
||||
|
||||
// Start a session
|
||||
t.Log("[1] initial message")
|
||||
h.sendAndWait("respond with exactly: PROV_INIT", 90*time.Second)
|
||||
|
||||
// /provider list
|
||||
t.Log("[2] /provider list")
|
||||
provList := h.sendAndWait("/provider list", 10*time.Second)
|
||||
t.Logf("[2] providers: %.200s", provList)
|
||||
|
||||
// /provider switch to a different provider
|
||||
t.Log("[3] /provider switch shengsuanyun")
|
||||
switchReply := h.sendAndWait("/provider switch shengsuanyun", 10*time.Second)
|
||||
t.Logf("[3] switch: %.120s", switchReply)
|
||||
|
||||
// Verify new provider works
|
||||
t.Log("[4] message after switch")
|
||||
reply4 := h.sendAndWait("respond with exactly: PROV_SWITCHED", 90*time.Second)
|
||||
t.Logf("[4] reply: %.120s", reply4)
|
||||
|
||||
// /list should still work
|
||||
t.Log("[5] /list after provider switch")
|
||||
list := h.sendAndWait("/list", 10*time.Second)
|
||||
if countSessions(list) < 1 {
|
||||
t.Errorf("[5] /list broken after provider switch\n%s", list)
|
||||
}
|
||||
t.Log("[5] /list OK after provider switch")
|
||||
|
||||
t.Log("=== PROVIDER SWITCH PASSED ===")
|
||||
}
|
||||
|
||||
func TestE2E_Codex_ProviderSwitch(t *testing.T) {
|
||||
runE2E_ProviderSwitch(t, "e2e-codex")
|
||||
}
|
||||
|
||||
func TestE2E_ClaudeCode_ProviderSwitch(t *testing.T) {
|
||||
runE2E_ProviderSwitch(t, "e2e-claudecode")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// E2E: Session persistence across restart (simulate by recreating engine)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func runE2E_SessionPersistence(t *testing.T, project string) {
|
||||
cfgPath := testConfigPath(t)
|
||||
if _, err := os.Stat(cfgPath); err != nil {
|
||||
t.Skipf("skip: test config not found")
|
||||
}
|
||||
cfg, err := config.Load(cfgPath)
|
||||
if err != nil {
|
||||
t.Fatalf("cannot load config: %v", err)
|
||||
}
|
||||
|
||||
var proj *config.ProjectConfig
|
||||
for i := range cfg.Projects {
|
||||
if cfg.Projects[i].Name == project {
|
||||
proj = &cfg.Projects[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if proj == nil {
|
||||
t.Fatalf("project %q not found", project)
|
||||
}
|
||||
|
||||
agentType := proj.Agent.Type
|
||||
bin, _ := findAgentBin(agentType)
|
||||
if _, err := exec.LookPath(bin); err != nil {
|
||||
t.Skipf("skip: %s not in PATH", bin)
|
||||
}
|
||||
|
||||
workDir := t.TempDir()
|
||||
sessPath := filepath.Join(workDir, "sessions.json")
|
||||
|
||||
createAgent := func() core.Agent {
|
||||
opts := make(map[string]any)
|
||||
for k, v := range proj.Agent.Options {
|
||||
opts[k] = v
|
||||
}
|
||||
opts["work_dir"] = workDir
|
||||
agent, err := core.CreateAgent(agentType, opts)
|
||||
if err != nil {
|
||||
t.Skipf("skip: %v", err)
|
||||
}
|
||||
if ps, ok := agent.(core.ProviderSwitcher); ok {
|
||||
var providers []core.ProviderConfig
|
||||
for _, ref := range proj.Agent.ProviderRefs {
|
||||
for _, gp := range cfg.Providers {
|
||||
if gp.Name == ref {
|
||||
providers = append(providers, configProviderToCore(gp))
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(providers) > 0 {
|
||||
ps.SetProviders(providers)
|
||||
if pn, _ := opts["provider"].(string); pn != "" {
|
||||
ps.SetActiveProvider(pn)
|
||||
} else {
|
||||
ps.SetActiveProvider(providers[0].Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
return agent
|
||||
}
|
||||
|
||||
// Phase 1: create session, send message, /new, send message
|
||||
agent1 := createAgent()
|
||||
mp1 := &mockPlatform{agent: agent1}
|
||||
e1 := core.NewEngine("persist-test", agent1, []core.Platform{mp1}, sessPath, core.LangEnglish)
|
||||
h1 := &e2eHelper{t: t, e: e1, mp: mp1, uk: sessionKey("persist-user")}
|
||||
|
||||
t.Log("[phase1] sending first message")
|
||||
h1.sendAndWait("respond with exactly: PERSIST_S1", 90*time.Second)
|
||||
|
||||
t.Log("[phase1] /new persist-session-2")
|
||||
h1.sendAndWait("/new persist-session-2", 10*time.Second)
|
||||
|
||||
t.Log("[phase1] message in session 2")
|
||||
h1.sendAndWait("respond with exactly: PERSIST_S2", 90*time.Second)
|
||||
|
||||
t.Log("[phase1] /list")
|
||||
list1 := h1.sendAndWait("/list", 10*time.Second)
|
||||
c1 := countSessions(list1)
|
||||
t.Logf("[phase1] %d sessions", c1)
|
||||
|
||||
// Simulate restart
|
||||
agent1.Stop()
|
||||
e1.Stop()
|
||||
|
||||
// Phase 2: new engine, same sessPath
|
||||
agent2 := createAgent()
|
||||
mp2 := &mockPlatform{agent: agent2}
|
||||
e2 := core.NewEngine("persist-test", agent2, []core.Platform{mp2}, sessPath, core.LangEnglish)
|
||||
defer func() { agent2.Stop(); e2.Stop() }()
|
||||
|
||||
h2 := &e2eHelper{t: t, e: e2, mp: mp2, uk: sessionKey("persist-user")}
|
||||
|
||||
t.Log("[phase2] /list after restart")
|
||||
list2 := h2.sendAndWait("/list", 10*time.Second)
|
||||
c2 := countSessions(list2)
|
||||
if c2 < c1 {
|
||||
t.Errorf("[phase2] sessions lost after restart: had %d, now %d\n%s", c1, c2, list2)
|
||||
}
|
||||
if !strings.Contains(list2, "persist-session-2") {
|
||||
t.Errorf("[phase2] session name 'persist-session-2' lost after restart\n%s", list2)
|
||||
}
|
||||
t.Logf("[phase2] %d sessions survived restart, name preserved", c2)
|
||||
|
||||
t.Log("=== PERSISTENCE PASSED ===")
|
||||
}
|
||||
|
||||
func TestE2E_Codex_SessionPersistence(t *testing.T) {
|
||||
runE2E_SessionPersistence(t, "e2e-codex")
|
||||
}
|
||||
|
||||
func TestE2E_ClaudeCode_SessionPersistence(t *testing.T) {
|
||||
runE2E_SessionPersistence(t, "e2e-claudecode")
|
||||
}
|
||||
@@ -0,0 +1,497 @@
|
||||
//go:build integration
|
||||
|
||||
// Package integration contains integration tests for cc-connect.
|
||||
// These tests verify component interactions and require specific setup.
|
||||
// Run with: go test -tags=integration ./tests/integration/...
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/chenhg5/cc-connect/core"
|
||||
"github.com/chenhg5/cc-connect/tests/mocks"
|
||||
"github.com/chenhg5/cc-connect/tests/mocks/fake"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// T-300: Engine + Platform Integration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestIntegration_EnginePlatformMessageFlow(t *testing.T) {
|
||||
// This test verifies the message flow between platform and engine
|
||||
// using mock components to simulate real behavior
|
||||
|
||||
// Create mock platform
|
||||
platform := new(mocks.MockPlatform)
|
||||
platform.On("Name").Return("test-integration")
|
||||
platform.On("Start", mock.Anything).Return(nil)
|
||||
platform.On("Stop").Return(nil)
|
||||
|
||||
// Create message handler to capture messages
|
||||
var receivedMessages []*core.Message
|
||||
var mu sync.Mutex
|
||||
|
||||
handler := func(p core.Platform, msg *core.Message) {
|
||||
mu.Lock()
|
||||
receivedMessages = append(receivedMessages, msg)
|
||||
mu.Unlock()
|
||||
}
|
||||
|
||||
// Start platform
|
||||
err := platform.Start(handler)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Simulate platform receiving a message
|
||||
testMsg := fake.TestMessageWithContent("Integration test message")
|
||||
handler(platform, testMsg)
|
||||
|
||||
// Verify message was captured
|
||||
mu.Lock()
|
||||
require.Len(t, receivedMessages, 1)
|
||||
assert.Equal(t, "Integration test message", receivedMessages[0].Content)
|
||||
mu.Unlock()
|
||||
|
||||
// Stop platform
|
||||
platform.Stop()
|
||||
|
||||
t.Log("Engine-Platform message flow: PASS")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// T-301: Multi-Agent Session Coordination
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestIntegration_MultiAgentSessionCoordination(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Create fake agents
|
||||
agent1 := fake.NewFakeAgent("agent-1")
|
||||
agent2 := fake.NewFakeAgent("agent-2")
|
||||
|
||||
// Start sessions on both agents
|
||||
sess1, err := agent1.StartSession(ctx, "coord-session-1")
|
||||
require.NoError(t, err)
|
||||
|
||||
sess2, err := agent2.StartSession(ctx, "coord-session-2")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify both sessions are independent
|
||||
assert.NotEqual(t, sess1.CurrentSessionID(), sess2.CurrentSessionID())
|
||||
assert.True(t, sess1.Alive())
|
||||
assert.True(t, sess2.Alive())
|
||||
|
||||
// Send message to agent 1
|
||||
err = sess1.Send("Message to agent 1", nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Send message to agent 2
|
||||
err = sess2.Send("Message to agent 2", nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify both agents received their messages
|
||||
prompts1 := agent1.GetSession().GetPrompts()
|
||||
prompts2 := agent2.GetSession().GetPrompts()
|
||||
|
||||
assert.Len(t, prompts1, 1)
|
||||
assert.Contains(t, prompts1[0], "Message to agent 1")
|
||||
assert.Len(t, prompts2, 1)
|
||||
assert.Contains(t, prompts2[0], "Message to agent 2")
|
||||
|
||||
t.Log("Multi-agent session coordination: PASS")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// T-302: Session Persistence Simulation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestIntegration_SessionPersistenceSimulation(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
agent := fake.NewFakeAgent("persist-agent")
|
||||
|
||||
// Start session
|
||||
sess1, err := agent.StartSession(ctx, "persist-session")
|
||||
require.NoError(t, err)
|
||||
originalSessionID := sess1.CurrentSessionID()
|
||||
|
||||
// Simulate session close (as would happen on restart)
|
||||
err = sess1.Close()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Simulate new session with same ID (as would happen on restore)
|
||||
sess2, err := agent.StartSession(ctx, "persist-session")
|
||||
require.NoError(t, err)
|
||||
restoredSessionID := sess2.CurrentSessionID()
|
||||
|
||||
// Verify session was "restored"
|
||||
assert.Equal(t, originalSessionID, restoredSessionID)
|
||||
assert.True(t, sess2.Alive())
|
||||
|
||||
t.Log("Session persistence simulation: PASS")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// T-303: Rate Limiter Integration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestIntegration_RateLimiterIntegration(t *testing.T) {
|
||||
// Create rate limiter: 2 messages per second
|
||||
rl := core.NewRateLimiter(2, time.Second)
|
||||
defer rl.Stop()
|
||||
|
||||
// First two should succeed
|
||||
assert.True(t, rl.Allow("user-rate"))
|
||||
assert.True(t, rl.Allow("user-rate"))
|
||||
|
||||
// Third should fail
|
||||
assert.False(t, rl.Allow("user-rate"))
|
||||
|
||||
// Different user should succeed
|
||||
assert.True(t, rl.Allow("other-user"))
|
||||
|
||||
// Wait for window to reset
|
||||
time.Sleep(1100 * time.Millisecond)
|
||||
|
||||
// Should be allowed again
|
||||
assert.True(t, rl.Allow("user-rate"))
|
||||
|
||||
t.Log("Rate limiter integration: PASS")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// T-304: Message Dedup Integration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestIntegration_MessageDedupIntegration(t *testing.T) {
|
||||
dedup := &core.MessageDedup{}
|
||||
|
||||
// Process a message
|
||||
msgID := "dedup-test-msg-001"
|
||||
isDup := dedup.IsDuplicate(msgID)
|
||||
assert.False(t, isDup, "first occurrence should not be duplicate")
|
||||
|
||||
// Same message again should be duplicate
|
||||
isDup = dedup.IsDuplicate(msgID)
|
||||
assert.True(t, isDup, "second occurrence should be duplicate")
|
||||
|
||||
// Different message should not be duplicate
|
||||
isDup = dedup.IsDuplicate("different-msg")
|
||||
assert.False(t, isDup)
|
||||
|
||||
// Empty should never be duplicate
|
||||
isDup = dedup.IsDuplicate("")
|
||||
assert.False(t, isDup)
|
||||
|
||||
t.Log("Message dedup integration: PASS")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// T-305: Command Registry Integration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestIntegration_CommandRegistryIntegration(t *testing.T) {
|
||||
registry := core.NewCommandRegistry()
|
||||
|
||||
// Add multiple commands
|
||||
registry.Add("start", "Start the process", "Starting...", "", "", "builtin")
|
||||
registry.Add("stop", "Stop the process", "Stopping...", "", "", "builtin")
|
||||
registry.Add("status", "Check status", "Status: running", "", "", "builtin")
|
||||
|
||||
// Verify all commands are registered
|
||||
commands := registry.ListAll()
|
||||
assert.Len(t, commands, 3)
|
||||
|
||||
// Resolve each command
|
||||
cmd, ok := registry.Resolve("start")
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "start", cmd.Name)
|
||||
|
||||
cmd, ok = registry.Resolve("stop")
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "stop", cmd.Name)
|
||||
|
||||
cmd, ok = registry.Resolve("status")
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "status", cmd.Name)
|
||||
|
||||
// Hyphen/underscore normalization
|
||||
registry.Add("my-cmd", "My command", "Running...", "", "", "builtin")
|
||||
cmd, ok = registry.Resolve("my_cmd") // Telegram sanitizes hyphens to underscores
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "my-cmd", cmd.Name)
|
||||
|
||||
t.Log("Command registry integration: PASS")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// T-306: Role Manager Integration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestIntegration_RoleManagerIntegration(t *testing.T) {
|
||||
manager := core.NewUserRoleManager()
|
||||
|
||||
manager.Configure("viewer", []core.RoleInput{
|
||||
{
|
||||
Name: "admin",
|
||||
UserIDs: []string{"admin1", "admin2"},
|
||||
},
|
||||
{
|
||||
Name: "developer",
|
||||
UserIDs: []string{"dev1", "dev2"},
|
||||
},
|
||||
{
|
||||
Name: "viewer",
|
||||
UserIDs: []string{"*"}, // wildcard - everyone else
|
||||
},
|
||||
})
|
||||
|
||||
// Admin users
|
||||
assert.Equal(t, "admin", manager.ResolveRole("admin1").Name)
|
||||
assert.Equal(t, "admin", manager.ResolveRole("admin2").Name)
|
||||
|
||||
// Developer users
|
||||
assert.Equal(t, "developer", manager.ResolveRole("dev1").Name)
|
||||
assert.Equal(t, "developer", manager.ResolveRole("dev2").Name)
|
||||
|
||||
// Unknown user gets viewer (wildcard)
|
||||
assert.Equal(t, "viewer", manager.ResolveRole("random-user").Name)
|
||||
|
||||
t.Log("Role manager integration: PASS")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// T-307: Platform Reply Integration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestIntegration_PlatformReplyIntegration(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
platform := new(mocks.MockPlatform)
|
||||
|
||||
// Expect specific reply
|
||||
expectedContent := "This is a reply from the platform"
|
||||
platform.On("Reply", ctx, mock.Anything, expectedContent).Return(nil)
|
||||
|
||||
// Simulate engine sending reply
|
||||
err := platform.Reply(ctx, nil, expectedContent)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify reply was called correctly
|
||||
platform.AssertExpectations(t)
|
||||
|
||||
t.Log("Platform reply integration: PASS")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// T-308: Agent Permission Flow
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestIntegration_AgentPermissionFlow(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Create mock agent session
|
||||
session := fake.NewFakeAgentSession("perm-session")
|
||||
session.AddPermissionRequest("req-001", "Bash", "rm -rf /")
|
||||
session.AddResultEvent("Request handled")
|
||||
|
||||
agent := fake.NewFakeAgentWithSession("perm-agent", "perm-session", session)
|
||||
|
||||
// Start session
|
||||
sess, err := agent.StartSession(ctx, "perm-session")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Send message
|
||||
err = sess.Send("Run dangerous command", nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Collect events
|
||||
var events []core.Event
|
||||
for e := range sess.Events() {
|
||||
events = append(events, e)
|
||||
if e.Done {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Should have permission request and result
|
||||
assert.GreaterOrEqual(t, len(events), 2)
|
||||
|
||||
// Find permission request event
|
||||
var permRequest core.Event
|
||||
for _, e := range events {
|
||||
if e.Type == core.EventPermissionRequest {
|
||||
permRequest = e
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.Equal(t, "Bash", permRequest.ToolName)
|
||||
assert.Equal(t, "req-001", permRequest.RequestID)
|
||||
|
||||
t.Log("Agent permission flow: PASS")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// T-310: Cron Store Integration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestIntegration_CronStoreIntegration(t *testing.T) {
|
||||
store, err := core.NewCronStore(t.TempDir())
|
||||
require.NoError(t, err)
|
||||
|
||||
// Add cron job
|
||||
job := &core.CronJob{
|
||||
ID: "integration-cron",
|
||||
Description: "Integration test cron",
|
||||
Prompt: "Run test",
|
||||
CronExpr: "0 9 * * *",
|
||||
Enabled: true,
|
||||
}
|
||||
err = store.Add(job)
|
||||
require.NoError(t, err)
|
||||
|
||||
// List jobs
|
||||
jobs := store.List()
|
||||
assert.Len(t, jobs, 1)
|
||||
|
||||
// Disable job
|
||||
ok := store.SetEnabled("integration-cron", false)
|
||||
assert.True(t, ok)
|
||||
|
||||
// Verify disabled
|
||||
job = store.Get("integration-cron")
|
||||
assert.False(t, job.Enabled)
|
||||
|
||||
// Toggle mute
|
||||
store.SetMute("integration-cron", true)
|
||||
muted, ok := store.ToggleMute("integration-cron")
|
||||
assert.True(t, ok)
|
||||
assert.False(t, muted) // toggled from true to false
|
||||
|
||||
// Remove job
|
||||
ok = store.Remove("integration-cron")
|
||||
assert.True(t, ok)
|
||||
|
||||
jobs = store.List()
|
||||
assert.Empty(t, jobs)
|
||||
|
||||
t.Log("Cron store integration: PASS")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// T-311: Card Rendering Integration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestIntegration_CardRenderingIntegration(t *testing.T) {
|
||||
// Create a complex card
|
||||
card := core.NewCard().
|
||||
Title("Integration Test Card", "green").
|
||||
Markdown("## Welcome\nThis is a **test** card.").
|
||||
Markdown("- Item 1\n- Item 2\n- Item 3").
|
||||
Divider().
|
||||
Buttons(
|
||||
core.PrimaryBtn("Confirm", "act:/confirm"),
|
||||
core.DefaultBtn("Cancel", "act:/cancel"),
|
||||
core.DangerBtn("Delete", "act:/delete"),
|
||||
).
|
||||
Note("This is a footnote").
|
||||
Build()
|
||||
|
||||
// Verify card structure
|
||||
assert.NotNil(t, card)
|
||||
assert.NotNil(t, card.Header)
|
||||
assert.Equal(t, "Integration Test Card", card.Header.Title)
|
||||
assert.Equal(t, "green", card.Header.Color)
|
||||
assert.GreaterOrEqual(t, len(card.Elements), 5) // markdown + markdown + divider + buttons + note
|
||||
|
||||
// Verify text fallback
|
||||
text := card.RenderText()
|
||||
assert.Contains(t, text, "Integration Test Card")
|
||||
assert.Contains(t, text, "Welcome")
|
||||
assert.Contains(t, text, "Confirm")
|
||||
assert.Contains(t, text, "Delete")
|
||||
|
||||
// Verify button collection
|
||||
buttons := card.CollectButtons()
|
||||
assert.Len(t, buttons, 1)
|
||||
assert.Len(t, buttons[0], 3)
|
||||
|
||||
// Verify HasButtons
|
||||
assert.True(t, card.HasButtons())
|
||||
|
||||
t.Log("Card rendering integration: PASS")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// T-312: Env Merge Integration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestIntegration_EnvMergeIntegration(t *testing.T) {
|
||||
base := []string{"PATH=/usr/bin", "HOME=/home/user", "VAR=value"}
|
||||
|
||||
// Merge with overlapping keys
|
||||
extra := []string{"VAR=newvalue", "ADD=new", "HOME=/different"}
|
||||
merged := core.MergeEnv(base, extra)
|
||||
|
||||
// Should have: PATH (from base), HOME (overridden), VAR (overridden), ADD (from extra)
|
||||
assert.Len(t, merged, 4)
|
||||
|
||||
// Find VAR - should be new value
|
||||
for _, env := range merged {
|
||||
if len(env) > 3 && env[:3] == "VAR" {
|
||||
assert.Equal(t, "VAR=newvalue", env)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Find ADD - should be new
|
||||
found := false
|
||||
for _, env := range merged {
|
||||
if len(env) > 3 && env[:3] == "ADD" {
|
||||
assert.Equal(t, "ADD=new", env)
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, found)
|
||||
|
||||
t.Log("Env merge integration: PASS")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// T-313: Message Attachment Handling
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestIntegration_MessageAttachmentHandling(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Create session
|
||||
session := fake.NewFakeAgentSession("attach-session")
|
||||
agent := fake.NewFakeAgentWithSession("attach-agent", "attach-session", session)
|
||||
|
||||
sess, err := agent.StartSession(ctx, "attach-session")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create message with attachments
|
||||
images := []core.ImageAttachment{
|
||||
{MimeType: "image/png", Data: []byte("fake png data"), FileName: "screenshot.png"},
|
||||
}
|
||||
files := []core.FileAttachment{
|
||||
{MimeType: "application/pdf", Data: []byte("fake pdf data"), FileName: "doc.pdf"},
|
||||
}
|
||||
|
||||
// Send with attachments
|
||||
err = sess.Send("Analyze this", images, files)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify prompts captured
|
||||
prompts := session.GetPrompts()
|
||||
assert.Len(t, prompts, 1)
|
||||
|
||||
t.Log("Message attachment handling: PASS")
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
//go:build integration
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/chenhg5/cc-connect/core"
|
||||
)
|
||||
|
||||
// skipUnlessBinaryAvailable skips if the agent binary is not in PATH.
|
||||
// Unlike skipUnlessAgentReady, it does NOT require API keys, since these
|
||||
// tests only exercise ListSessions (file reading), not StartSession.
|
||||
func skipUnlessBinaryAvailable(t *testing.T, agentType string) {
|
||||
t.Helper()
|
||||
bin, err := findAgentBin(agentType)
|
||||
if err != nil {
|
||||
t.Skipf("skip %s: %v", agentType, err)
|
||||
}
|
||||
if _, err := exec.LookPath(bin); err != nil {
|
||||
t.Skipf("skip %s: binary %q not in PATH", agentType, bin)
|
||||
}
|
||||
}
|
||||
|
||||
// writeCodexSessionFixture creates a realistic Codex JSONL session file.
|
||||
func writeCodexSessionFixture(t *testing.T, sessionsDir, threadID, workDir, userPrompt string) {
|
||||
t.Helper()
|
||||
dir := filepath.Join(sessionsDir, threadID[:8])
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
meta := map[string]any{
|
||||
"type": "session_meta",
|
||||
"timestamp": time.Now().Format(time.RFC3339Nano),
|
||||
"payload": map[string]any{
|
||||
"id": threadID,
|
||||
"cwd": workDir,
|
||||
"source": "cli",
|
||||
"originator": "codex_cli_rs",
|
||||
},
|
||||
}
|
||||
userMsg := map[string]any{
|
||||
"type": "response_item",
|
||||
"timestamp": time.Now().Format(time.RFC3339Nano),
|
||||
"payload": map[string]any{
|
||||
"role": "user",
|
||||
"content": []map[string]any{
|
||||
{"type": "input_text", "text": userPrompt},
|
||||
},
|
||||
},
|
||||
}
|
||||
assistantMsg := map[string]any{
|
||||
"type": "response_item",
|
||||
"timestamp": time.Now().Format(time.RFC3339Nano),
|
||||
"payload": map[string]any{
|
||||
"role": "assistant",
|
||||
"content": []map[string]any{
|
||||
{"type": "output_text", "text": "Done."},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
f, err := os.Create(filepath.Join(dir, threadID+".jsonl"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
enc := json.NewEncoder(f)
|
||||
for _, entry := range []any{meta, userMsg, assistantMsg} {
|
||||
if err := enc.Encode(entry); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// writeClaudeCodeSessionFixture creates a realistic Claude Code JSONL session file.
|
||||
func writeClaudeCodeSessionFixture(t *testing.T, projectDir, sessionID, userPrompt string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(projectDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
userEntry := map[string]any{
|
||||
"type": "user",
|
||||
"timestamp": time.Now().Format(time.RFC3339Nano),
|
||||
"message": map[string]any{"content": userPrompt},
|
||||
}
|
||||
assistantEntry := map[string]any{
|
||||
"type": "assistant",
|
||||
"timestamp": time.Now().Format(time.RFC3339Nano),
|
||||
"message": map[string]any{"content": "OK, done."},
|
||||
}
|
||||
|
||||
f, err := os.Create(filepath.Join(projectDir, sessionID+".jsonl"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
enc := json.NewEncoder(f)
|
||||
for _, entry := range []any{userEntry, assistantEntry} {
|
||||
if err := enc.Encode(entry); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// setupFilterSessionTest creates a real agent with fixture session files and
|
||||
// wires it into a real Engine. Some sessions are tracked by cc-connect (via
|
||||
// SessionManager), others are "external" (exist on disk but not tracked).
|
||||
// This tests the full pipeline: real agent adapter → ListSessions → Engine filtering.
|
||||
func setupFilterSessionTest(t *testing.T, agentType string, filterEnabled bool) (
|
||||
engine *core.Engine, platform *mockPlatform, userKey string, trackedIDs, externalIDs []string,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
workDir := t.TempDir()
|
||||
sessPath := filepath.Join(workDir, "cc-sessions.json")
|
||||
|
||||
trackedIDs = []string{
|
||||
"019d0001-aaaa-7000-8000-000000000001",
|
||||
"019d0002-bbbb-7000-8000-000000000002",
|
||||
"019d0003-cccc-7000-8000-000000000003",
|
||||
}
|
||||
externalIDs = []string{
|
||||
"019dff01-dddd-7000-8000-000000000011",
|
||||
"019dff02-eeee-7000-8000-000000000012",
|
||||
}
|
||||
|
||||
opts := map[string]any{"work_dir": workDir}
|
||||
allIDs := append(append([]string{}, trackedIDs...), externalIDs...)
|
||||
|
||||
switch agentType {
|
||||
case "codex":
|
||||
codexHome := filepath.Join(workDir, ".codex-test")
|
||||
opts["codex_home"] = codexHome
|
||||
sessionsDir := filepath.Join(codexHome, "sessions")
|
||||
for i, id := range allIDs {
|
||||
writeCodexSessionFixture(t, sessionsDir, id, workDir, fmt.Sprintf("Prompt for session %d", i+1))
|
||||
time.Sleep(10 * time.Millisecond) // ensure different mod times
|
||||
}
|
||||
|
||||
case "claudecode":
|
||||
homeDir, _ := os.UserHomeDir()
|
||||
absWorkDir, _ := filepath.Abs(workDir)
|
||||
projectKey := strings.ReplaceAll(absWorkDir, string(filepath.Separator), "-")
|
||||
projectDir := filepath.Join(homeDir, ".claude", "projects", projectKey)
|
||||
t.Cleanup(func() { os.RemoveAll(projectDir) })
|
||||
for i, id := range allIDs {
|
||||
writeClaudeCodeSessionFixture(t, projectDir, id, fmt.Sprintf("Prompt for session %d", i+1))
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
agent, err := core.CreateAgent(agentType, opts)
|
||||
if err != nil {
|
||||
t.Skipf("skip: cannot create %s agent: %v", agentType, err)
|
||||
}
|
||||
|
||||
listed, err := agent.ListSessions(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("agent.ListSessions failed: %v", err)
|
||||
}
|
||||
if len(listed) < len(allIDs) {
|
||||
t.Fatalf("agent.ListSessions returned %d sessions, want >= %d (fixture broken)", len(listed), len(allIDs))
|
||||
}
|
||||
|
||||
mp := &mockPlatform{}
|
||||
e := core.NewEngine("test", agent, []core.Platform{mp}, sessPath, core.LangEnglish)
|
||||
e.SetFilterExternalSessions(filterEnabled)
|
||||
|
||||
userKey = "mock:test-filter:user1"
|
||||
for _, id := range trackedIDs {
|
||||
s := e.GetSessions().NewSession(userKey, "")
|
||||
s.SetAgentSessionID(id, agentType)
|
||||
}
|
||||
e.GetSessions().Save()
|
||||
|
||||
return e, mp, userKey, trackedIDs, externalIDs
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Codex: real agent adapter + Engine filter integration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestRealCodex_FilterDisabled_ListShowsAll(t *testing.T) {
|
||||
skipUnlessBinaryAvailable(t, "codex")
|
||||
e, mp, userKey, _, _ := setupFilterSessionTest(t, "codex", false)
|
||||
defer e.Stop()
|
||||
|
||||
mp.clear()
|
||||
e.ReceiveMessage(mp, &core.Message{
|
||||
SessionKey: userKey, Platform: "mock", UserID: "user1", Content: "/list", ReplyCtx: "ctx",
|
||||
})
|
||||
|
||||
msgs, ok := waitForMessages(mp, 1, 5*time.Second)
|
||||
if !ok {
|
||||
t.Fatal("timeout waiting for /list reply")
|
||||
}
|
||||
reply := joinMsgContent(msgs)
|
||||
|
||||
// All 5 sessions (3 tracked + 2 external) should be visible
|
||||
count := strings.Count(reply, "msgs")
|
||||
if count != 5 {
|
||||
t.Errorf("filter OFF: /list should show 5 sessions, got %d\n%s", count, reply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRealCodex_FilterEnabled_ListHidesExternal(t *testing.T) {
|
||||
skipUnlessBinaryAvailable(t, "codex")
|
||||
e, mp, userKey, _, _ := setupFilterSessionTest(t, "codex", true)
|
||||
defer e.Stop()
|
||||
|
||||
mp.clear()
|
||||
e.ReceiveMessage(mp, &core.Message{
|
||||
SessionKey: userKey, Platform: "mock", UserID: "user1", Content: "/list", ReplyCtx: "ctx",
|
||||
})
|
||||
|
||||
msgs, ok := waitForMessages(mp, 1, 5*time.Second)
|
||||
if !ok {
|
||||
t.Fatal("timeout waiting for /list reply")
|
||||
}
|
||||
reply := joinMsgContent(msgs)
|
||||
|
||||
// Only 3 tracked sessions should be visible
|
||||
count := strings.Count(reply, "msgs")
|
||||
if count != 3 {
|
||||
t.Errorf("filter ON: /list should show 3 tracked sessions, got %d\n%s", count, reply)
|
||||
}
|
||||
// External sessions (session 4, session 5) should not appear
|
||||
if strings.Contains(reply, "session 4") || strings.Contains(reply, "session 5") {
|
||||
t.Errorf("filter ON: /list should NOT show external sessions\n%s", reply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRealCodex_FilterEnabled_SwitchExternal_Rejected(t *testing.T) {
|
||||
skipUnlessBinaryAvailable(t, "codex")
|
||||
e, mp, userKey, _, externalIDs := setupFilterSessionTest(t, "codex", true)
|
||||
defer e.Stop()
|
||||
|
||||
mp.clear()
|
||||
e.ReceiveMessage(mp, &core.Message{
|
||||
SessionKey: userKey, Platform: "mock", UserID: "user1",
|
||||
Content: "/switch " + externalIDs[0][:8], ReplyCtx: "ctx",
|
||||
})
|
||||
|
||||
msgs, ok := waitForMessages(mp, 1, 5*time.Second)
|
||||
if !ok {
|
||||
t.Fatal("timeout waiting for /switch reply")
|
||||
}
|
||||
reply := joinMsgContent(msgs)
|
||||
if strings.Contains(reply, externalIDs[0]) && !strings.Contains(strings.ToLower(reply), "no") {
|
||||
t.Errorf("filter ON: /switch to external session should fail:\n%s", reply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRealCodex_FilterDisabled_SwitchExternal_Allowed(t *testing.T) {
|
||||
skipUnlessBinaryAvailable(t, "codex")
|
||||
e, mp, userKey, _, externalIDs := setupFilterSessionTest(t, "codex", false)
|
||||
defer e.Stop()
|
||||
|
||||
mp.clear()
|
||||
e.ReceiveMessage(mp, &core.Message{
|
||||
SessionKey: userKey, Platform: "mock", UserID: "user1",
|
||||
Content: "/switch " + externalIDs[0][:8], ReplyCtx: "ctx",
|
||||
})
|
||||
|
||||
msgs, ok := waitForMessages(mp, 1, 5*time.Second)
|
||||
if !ok {
|
||||
t.Fatal("timeout waiting for /switch reply")
|
||||
}
|
||||
reply := joinMsgContent(msgs)
|
||||
if strings.Contains(strings.ToLower(reply), "no match") || strings.Contains(strings.ToLower(reply), "not found") {
|
||||
t.Errorf("filter OFF: /switch to external session should succeed:\n%s", reply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRealCodex_FilterEnabled_DeleteExternal_Rejected(t *testing.T) {
|
||||
skipUnlessBinaryAvailable(t, "codex")
|
||||
e, mp, userKey, _, externalIDs := setupFilterSessionTest(t, "codex", true)
|
||||
defer e.Stop()
|
||||
|
||||
mp.clear()
|
||||
e.ReceiveMessage(mp, &core.Message{
|
||||
SessionKey: userKey, Platform: "mock", UserID: "user1",
|
||||
Content: "/delete " + externalIDs[0][:8], ReplyCtx: "ctx",
|
||||
})
|
||||
|
||||
msgs, ok := waitForMessages(mp, 1, 5*time.Second)
|
||||
if !ok {
|
||||
t.Fatal("timeout waiting for /delete reply")
|
||||
}
|
||||
reply := joinMsgContent(msgs)
|
||||
lowerReply := strings.ToLower(reply)
|
||||
// The delete should be rejected — either "no session matching" or "not found"
|
||||
if !strings.Contains(lowerReply, "no session") && !strings.Contains(lowerReply, "not found") && !strings.Contains(lowerReply, "no match") {
|
||||
t.Errorf("filter ON: /delete external session should be rejected, got:\n%s", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Claude Code: real agent adapter + Engine filter integration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestRealClaudeCode_FilterDisabled_ListShowsAll(t *testing.T) {
|
||||
skipUnlessBinaryAvailable(t, "claudecode")
|
||||
e, mp, userKey, _, _ := setupFilterSessionTest(t, "claudecode", false)
|
||||
defer e.Stop()
|
||||
|
||||
mp.clear()
|
||||
e.ReceiveMessage(mp, &core.Message{
|
||||
SessionKey: userKey, Platform: "mock", UserID: "user1", Content: "/list", ReplyCtx: "ctx",
|
||||
})
|
||||
|
||||
msgs, ok := waitForMessages(mp, 1, 5*time.Second)
|
||||
if !ok {
|
||||
t.Fatal("timeout waiting for /list reply")
|
||||
}
|
||||
reply := joinMsgContent(msgs)
|
||||
|
||||
count := strings.Count(reply, "msgs")
|
||||
if count != 5 {
|
||||
t.Errorf("filter OFF: /list should show 5 sessions, got %d\n%s", count, reply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRealClaudeCode_FilterEnabled_ListHidesExternal(t *testing.T) {
|
||||
skipUnlessBinaryAvailable(t, "claudecode")
|
||||
e, mp, userKey, _, _ := setupFilterSessionTest(t, "claudecode", true)
|
||||
defer e.Stop()
|
||||
|
||||
mp.clear()
|
||||
e.ReceiveMessage(mp, &core.Message{
|
||||
SessionKey: userKey, Platform: "mock", UserID: "user1", Content: "/list", ReplyCtx: "ctx",
|
||||
})
|
||||
|
||||
msgs, ok := waitForMessages(mp, 1, 5*time.Second)
|
||||
if !ok {
|
||||
t.Fatal("timeout waiting for /list reply")
|
||||
}
|
||||
reply := joinMsgContent(msgs)
|
||||
|
||||
count := strings.Count(reply, "msgs")
|
||||
if count != 3 {
|
||||
t.Errorf("filter ON: /list should show 3 tracked sessions, got %d\n%s", count, reply)
|
||||
}
|
||||
if strings.Contains(reply, "session 4") || strings.Contains(reply, "session 5") {
|
||||
t.Errorf("filter ON: /list should NOT show external sessions\n%s", reply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRealClaudeCode_FilterEnabled_SwitchExternal_Rejected(t *testing.T) {
|
||||
skipUnlessBinaryAvailable(t, "claudecode")
|
||||
e, mp, userKey, _, externalIDs := setupFilterSessionTest(t, "claudecode", true)
|
||||
defer e.Stop()
|
||||
|
||||
mp.clear()
|
||||
e.ReceiveMessage(mp, &core.Message{
|
||||
SessionKey: userKey, Platform: "mock", UserID: "user1",
|
||||
Content: "/switch " + externalIDs[0][:8], ReplyCtx: "ctx",
|
||||
})
|
||||
|
||||
msgs, ok := waitForMessages(mp, 1, 5*time.Second)
|
||||
if !ok {
|
||||
t.Fatal("timeout waiting for /switch reply")
|
||||
}
|
||||
reply := joinMsgContent(msgs)
|
||||
if strings.Contains(reply, externalIDs[0]) && !strings.Contains(strings.ToLower(reply), "no") {
|
||||
t.Errorf("filter ON: /switch to external session should fail:\n%s", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dynamic toggle: switch filter at runtime
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestRealCodex_DynamicFilterToggle(t *testing.T) {
|
||||
skipUnlessBinaryAvailable(t, "codex")
|
||||
e, mp, userKey, _, _ := setupFilterSessionTest(t, "codex", false)
|
||||
defer e.Stop()
|
||||
msg := &core.Message{SessionKey: userKey, Platform: "mock", UserID: "user1", Content: "/list", ReplyCtx: "ctx"}
|
||||
|
||||
// Phase 1: filter OFF → 5 sessions
|
||||
mp.clear()
|
||||
e.ReceiveMessage(mp, msg)
|
||||
msgs, _ := waitForMessages(mp, 1, 5*time.Second)
|
||||
reply1 := joinMsgContent(msgs)
|
||||
count1 := strings.Count(reply1, "msgs")
|
||||
if count1 != 5 {
|
||||
t.Fatalf("before toggle: expected 5 sessions, got %d\n%s", count1, reply1)
|
||||
}
|
||||
|
||||
// Phase 2: filter ON → 3 sessions
|
||||
e.SetFilterExternalSessions(true)
|
||||
mp.clear()
|
||||
e.ReceiveMessage(mp, msg)
|
||||
msgs, _ = waitForMessages(mp, 1, 5*time.Second)
|
||||
reply2 := joinMsgContent(msgs)
|
||||
count2 := strings.Count(reply2, "msgs")
|
||||
if count2 != 3 {
|
||||
t.Fatalf("after enabling filter: expected 3 sessions, got %d\n%s", count2, reply2)
|
||||
}
|
||||
|
||||
// Phase 3: filter OFF → 5 sessions again
|
||||
e.SetFilterExternalSessions(false)
|
||||
mp.clear()
|
||||
e.ReceiveMessage(mp, msg)
|
||||
msgs, _ = waitForMessages(mp, 1, 5*time.Second)
|
||||
reply3 := joinMsgContent(msgs)
|
||||
count3 := strings.Count(reply3, "msgs")
|
||||
if count3 != 5 {
|
||||
t.Fatalf("after disabling filter: expected 5 sessions, got %d\n%s", count3, reply3)
|
||||
}
|
||||
}
|
||||
|
||||
// Full E2E tests (real agent conversations, /list /new /switch /delete etc.)
|
||||
// have been moved to e2e_session_test.go which uses config.test.toml for
|
||||
// full isolation from production config.
|
||||
@@ -0,0 +1,360 @@
|
||||
//go:build integration
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/chenhg5/cc-connect/core"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const integrationSharedAgentName = "integration-shared-routing-agent"
|
||||
|
||||
var (
|
||||
registerIntegrationSharedAgentOnce sync.Once
|
||||
integrationMessageSeq uint64
|
||||
)
|
||||
|
||||
type integrationRoutingAgent struct {
|
||||
workDir string
|
||||
}
|
||||
|
||||
func (a *integrationRoutingAgent) Name() string { return integrationSharedAgentName }
|
||||
|
||||
func (a *integrationRoutingAgent) StartSession(_ context.Context, sessionID string) (core.AgentSession, error) {
|
||||
return newIntegrationRoutingSession(sessionID, a.workDir), nil
|
||||
}
|
||||
|
||||
func (a *integrationRoutingAgent) ListSessions(_ context.Context) ([]core.AgentSessionInfo, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (a *integrationRoutingAgent) Stop() error { return nil }
|
||||
|
||||
type integrationRoutingSession struct {
|
||||
mu sync.RWMutex
|
||||
sessionID string
|
||||
workDir string
|
||||
alive bool
|
||||
events chan core.Event
|
||||
}
|
||||
|
||||
func newIntegrationRoutingSession(sessionID, workDir string) *integrationRoutingSession {
|
||||
return &integrationRoutingSession{
|
||||
sessionID: sessionID,
|
||||
workDir: workDir,
|
||||
alive: true,
|
||||
events: make(chan core.Event, 8),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *integrationRoutingSession) Send(prompt string, _ []core.ImageAttachment, _ []core.FileAttachment) error {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
if !s.alive {
|
||||
return io.ErrClosedPipe
|
||||
}
|
||||
|
||||
s.events <- core.Event{
|
||||
Type: core.EventResult,
|
||||
Content: fmt.Sprintf("workspace=%s prompt=%s", s.workDir, prompt),
|
||||
Done: true,
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *integrationRoutingSession) RespondPermission(string, core.PermissionResult) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *integrationRoutingSession) Events() <-chan core.Event {
|
||||
return s.events
|
||||
}
|
||||
|
||||
func (s *integrationRoutingSession) CurrentSessionID() string {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.sessionID
|
||||
}
|
||||
|
||||
func (s *integrationRoutingSession) Alive() bool {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.alive
|
||||
}
|
||||
|
||||
func (s *integrationRoutingSession) Close() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.alive {
|
||||
s.alive = false
|
||||
close(s.events)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type integrationPlatform struct {
|
||||
mu sync.Mutex
|
||||
name string
|
||||
channelNames map[string]string
|
||||
handler core.MessageHandler
|
||||
outputs []string
|
||||
}
|
||||
|
||||
func newIntegrationPlatform(name string, channelNames map[string]string) *integrationPlatform {
|
||||
return &integrationPlatform{
|
||||
name: name,
|
||||
channelNames: channelNames,
|
||||
outputs: make([]string, 0),
|
||||
}
|
||||
}
|
||||
|
||||
func (p *integrationPlatform) Name() string { return p.name }
|
||||
|
||||
func (p *integrationPlatform) Start(handler core.MessageHandler) error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.handler = handler
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *integrationPlatform) Reply(_ context.Context, _ any, content string) error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.outputs = append(p.outputs, content)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *integrationPlatform) Send(_ context.Context, _ any, content string) error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.outputs = append(p.outputs, content)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *integrationPlatform) Stop() error { return nil }
|
||||
|
||||
func (p *integrationPlatform) ResolveChannelName(channelID string) (string, error) {
|
||||
if name, ok := p.channelNames[channelID]; ok {
|
||||
return name, nil
|
||||
}
|
||||
return "", fmt.Errorf("unknown channel %q", channelID)
|
||||
}
|
||||
|
||||
func (p *integrationPlatform) Emit(msg *core.Message) {
|
||||
p.mu.Lock()
|
||||
handler := p.handler
|
||||
p.mu.Unlock()
|
||||
if handler == nil {
|
||||
panic("integrationPlatform.Emit called before Start")
|
||||
}
|
||||
handler(p, msg)
|
||||
}
|
||||
|
||||
func (p *integrationPlatform) ClearOutputs() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.outputs = p.outputs[:0]
|
||||
}
|
||||
|
||||
func (p *integrationPlatform) Outputs() []string {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
cp := make([]string, len(p.outputs))
|
||||
copy(cp, p.outputs)
|
||||
return cp
|
||||
}
|
||||
|
||||
func (p *integrationPlatform) WaitForOutputContaining(t *testing.T, needle string) string {
|
||||
t.Helper()
|
||||
var matched string
|
||||
require.Eventually(t, func() bool {
|
||||
for _, output := range p.Outputs() {
|
||||
if strings.Contains(output, needle) {
|
||||
matched = output
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}, 3*time.Second, 20*time.Millisecond)
|
||||
return matched
|
||||
}
|
||||
|
||||
func registerIntegrationSharedAgent() {
|
||||
registerIntegrationSharedAgentOnce.Do(func() {
|
||||
core.RegisterAgent(integrationSharedAgentName, func(opts map[string]any) (core.Agent, error) {
|
||||
workDir, _ := opts["work_dir"].(string)
|
||||
return &integrationRoutingAgent{workDir: workDir}, nil
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func newIntegrationEngine(t *testing.T, projectName string, platform *integrationPlatform, baseDir, bindingStore, sessionStore string) *core.Engine {
|
||||
t.Helper()
|
||||
registerIntegrationSharedAgent()
|
||||
|
||||
engine := core.NewEngine(
|
||||
projectName,
|
||||
&integrationRoutingAgent{workDir: baseDir},
|
||||
[]core.Platform{platform},
|
||||
sessionStore,
|
||||
core.LangEnglish,
|
||||
)
|
||||
engine.SetAdminFrom("admin")
|
||||
engine.SetMultiWorkspace(baseDir, bindingStore)
|
||||
require.NoError(t, engine.Start())
|
||||
t.Cleanup(func() {
|
||||
_ = engine.Stop()
|
||||
})
|
||||
return engine
|
||||
}
|
||||
|
||||
func integrationMessage(platformName, channelID, userID, content string) *core.Message {
|
||||
seq := atomic.AddUint64(&integrationMessageSeq, 1)
|
||||
chatName := channelID
|
||||
return &core.Message{
|
||||
SessionKey: fmt.Sprintf("%s:%s:%s", platformName, channelID, userID),
|
||||
Platform: platformName,
|
||||
MessageID: fmt.Sprintf("msg-%d", seq),
|
||||
UserID: userID,
|
||||
UserName: userID,
|
||||
ChatName: chatName,
|
||||
Content: content,
|
||||
ReplyCtx: fmt.Sprintf("reply-%d", seq),
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegration_SharedWorkspaceBindingLiveSyncAcrossProjects(t *testing.T) {
|
||||
baseDir := t.TempDir()
|
||||
bindingStore := filepath.Join(t.TempDir(), "workspace_bindings.json")
|
||||
channelID := "shared-channel"
|
||||
channelNames := map[string]string{channelID: "shared-channel"}
|
||||
platformName := "shared-platform"
|
||||
|
||||
sharedDir := filepath.Join(baseDir, "shared-workspace")
|
||||
require.NoError(t, os.MkdirAll(sharedDir, 0o755))
|
||||
|
||||
platformA := newIntegrationPlatform(platformName, channelNames)
|
||||
engineA := newIntegrationEngine(t, "project-a", platformA, baseDir, bindingStore, filepath.Join(t.TempDir(), "project-a-sessions.json"))
|
||||
_ = engineA
|
||||
|
||||
platformB := newIntegrationPlatform(platformName, channelNames)
|
||||
engineB := newIntegrationEngine(t, "project-b", platformB, baseDir, bindingStore, filepath.Join(t.TempDir(), "project-b-sessions.json"))
|
||||
_ = engineB
|
||||
|
||||
platformA.Emit(integrationMessage(platformA.Name(), channelID, "user-a", "/workspace shared bind shared-workspace"))
|
||||
platformA.WaitForOutputContaining(t, "Shared workspace bound")
|
||||
|
||||
platformA.ClearOutputs()
|
||||
platformA.Emit(integrationMessage(platformA.Name(), channelID, "user-a", "hello from project a"))
|
||||
platformA.WaitForOutputContaining(t, sharedDir)
|
||||
|
||||
platformB.ClearOutputs()
|
||||
platformB.Emit(integrationMessage(platformB.Name(), channelID, "user-b", "hello from project b"))
|
||||
platformB.WaitForOutputContaining(t, sharedDir)
|
||||
|
||||
platformA.ClearOutputs()
|
||||
platformA.Emit(integrationMessage(platformA.Name(), channelID, "user-a", "/workspace shared unbind"))
|
||||
platformA.WaitForOutputContaining(t, "Shared workspace unbound")
|
||||
|
||||
platformB.ClearOutputs()
|
||||
platformB.Emit(integrationMessage(platformB.Name(), channelID, "user-b", "after shared unbind"))
|
||||
platformB.WaitForOutputContaining(t, "No workspace found for this channel")
|
||||
}
|
||||
|
||||
func TestIntegration_ProjectWorkspaceOverridesSharedAcrossProjects(t *testing.T) {
|
||||
baseDir := t.TempDir()
|
||||
bindingStore := filepath.Join(t.TempDir(), "workspace_bindings.json")
|
||||
channelID := "override-channel"
|
||||
channelNames := map[string]string{channelID: "override-channel"}
|
||||
platformName := "shared-platform"
|
||||
|
||||
sharedDir := filepath.Join(baseDir, "shared-workspace")
|
||||
projectBDir := filepath.Join(baseDir, "project-b-workspace")
|
||||
require.NoError(t, os.MkdirAll(sharedDir, 0o755))
|
||||
require.NoError(t, os.MkdirAll(projectBDir, 0o755))
|
||||
|
||||
platformA := newIntegrationPlatform(platformName, channelNames)
|
||||
engineA := newIntegrationEngine(t, "project-a", platformA, baseDir, bindingStore, filepath.Join(t.TempDir(), "project-a-sessions.json"))
|
||||
_ = engineA
|
||||
|
||||
platformB := newIntegrationPlatform(platformName, channelNames)
|
||||
engineB := newIntegrationEngine(t, "project-b", platformB, baseDir, bindingStore, filepath.Join(t.TempDir(), "project-b-sessions.json"))
|
||||
_ = engineB
|
||||
|
||||
platformA.Emit(integrationMessage(platformA.Name(), channelID, "user-a", "/workspace shared bind shared-workspace"))
|
||||
platformA.WaitForOutputContaining(t, "Shared workspace bound")
|
||||
|
||||
platformB.ClearOutputs()
|
||||
platformB.Emit(integrationMessage(platformB.Name(), channelID, "user-b", "/workspace bind project-b-workspace"))
|
||||
platformB.WaitForOutputContaining(t, "Workspace bound")
|
||||
|
||||
platformA.ClearOutputs()
|
||||
platformA.Emit(integrationMessage(platformA.Name(), channelID, "user-a", "route project a"))
|
||||
platformA.WaitForOutputContaining(t, sharedDir)
|
||||
|
||||
platformB.ClearOutputs()
|
||||
platformB.Emit(integrationMessage(platformB.Name(), channelID, "user-b", "route project b"))
|
||||
platformB.WaitForOutputContaining(t, projectBDir)
|
||||
}
|
||||
|
||||
func TestIntegration_ProjectWorkspaceRouteUsesAbsolutePath(t *testing.T) {
|
||||
baseDir := t.TempDir()
|
||||
bindingStore := filepath.Join(t.TempDir(), "workspace_bindings.json")
|
||||
channelID := "route-channel"
|
||||
channelNames := map[string]string{channelID: "route-channel"}
|
||||
|
||||
routedDir := filepath.Join(t.TempDir(), "routed workspace")
|
||||
require.NoError(t, os.MkdirAll(routedDir, 0o755))
|
||||
|
||||
platform := newIntegrationPlatform("proj-route-platform", channelNames)
|
||||
engine := newIntegrationEngine(t, "project-route", platform, baseDir, bindingStore, filepath.Join(t.TempDir(), "project-route-sessions.json"))
|
||||
_ = engine
|
||||
|
||||
platform.Emit(integrationMessage(platform.Name(), channelID, "user-route", "/workspace route "+routedDir))
|
||||
platform.WaitForOutputContaining(t, "Workspace routed")
|
||||
|
||||
platform.ClearOutputs()
|
||||
platform.Emit(integrationMessage(platform.Name(), channelID, "user-route", "hello routed workspace"))
|
||||
platform.WaitForOutputContaining(t, routedDir)
|
||||
}
|
||||
|
||||
func TestIntegration_SharedWorkspaceRouteLiveSyncAcrossProjects(t *testing.T) {
|
||||
baseDir := t.TempDir()
|
||||
bindingStore := filepath.Join(t.TempDir(), "workspace_bindings.json")
|
||||
channelID := "shared-route-channel"
|
||||
channelNames := map[string]string{channelID: "shared-route-channel"}
|
||||
platformName := "shared-platform"
|
||||
|
||||
routedDir := filepath.Join(t.TempDir(), "shared routed workspace")
|
||||
require.NoError(t, os.MkdirAll(routedDir, 0o755))
|
||||
|
||||
platformA := newIntegrationPlatform(platformName, channelNames)
|
||||
engineA := newIntegrationEngine(t, "project-shared-route-a", platformA, baseDir, bindingStore, filepath.Join(t.TempDir(), "project-shared-route-a-sessions.json"))
|
||||
_ = engineA
|
||||
|
||||
platformB := newIntegrationPlatform(platformName, channelNames)
|
||||
engineB := newIntegrationEngine(t, "project-shared-route-b", platformB, baseDir, bindingStore, filepath.Join(t.TempDir(), "project-shared-route-b-sessions.json"))
|
||||
_ = engineB
|
||||
|
||||
platformA.Emit(integrationMessage(platformA.Name(), channelID, "user-a", "/workspace shared route "+routedDir))
|
||||
platformA.WaitForOutputContaining(t, "Shared workspace routed")
|
||||
|
||||
platformA.ClearOutputs()
|
||||
platformA.Emit(integrationMessage(platformA.Name(), channelID, "user-a", "hello from shared route a"))
|
||||
platformA.WaitForOutputContaining(t, routedDir)
|
||||
|
||||
platformB.ClearOutputs()
|
||||
platformB.Emit(integrationMessage(platformB.Name(), channelID, "user-b", "hello from shared route b"))
|
||||
platformB.WaitForOutputContaining(t, routedDir)
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
//go:build integration
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/chenhg5/cc-connect/core"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Integration tests: unsolicited agent events (background task completion)
|
||||
//
|
||||
// Covers the end-to-end flow for events that an agent emits AFTER a user's
|
||||
// turn has completed — e.g. a Claude Code `run_in_background` bash task
|
||||
// finishing minutes later. Without the unsolicited reader, those events
|
||||
// pile up in the buffered channel and get discarded by drainEvents() on
|
||||
// the next user message.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// persistentEventsSession is an AgentSession with a long-lived events channel
|
||||
// that stays open across turns. Unlike FakeAgentSession (which returns a new
|
||||
// closed channel per Events() call), this is required to model the real
|
||||
// Claude Code behavior where one channel spans multiple turns.
|
||||
type persistentEventsSession struct {
|
||||
mu sync.Mutex
|
||||
sessionID string
|
||||
alive bool
|
||||
events chan core.Event
|
||||
prompts []string
|
||||
closed chan struct{}
|
||||
}
|
||||
|
||||
func newPersistentEventsSession(id string) *persistentEventsSession {
|
||||
return &persistentEventsSession{
|
||||
sessionID: id,
|
||||
alive: true,
|
||||
events: make(chan core.Event, 64),
|
||||
closed: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *persistentEventsSession) Send(prompt string, _ []core.ImageAttachment, _ []core.FileAttachment) error {
|
||||
s.mu.Lock()
|
||||
s.prompts = append(s.prompts, prompt)
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *persistentEventsSession) RespondPermission(_ string, _ core.PermissionResult) error {
|
||||
return nil
|
||||
}
|
||||
func (s *persistentEventsSession) Events() <-chan core.Event { return s.events }
|
||||
func (s *persistentEventsSession) CurrentSessionID() string { return s.sessionID }
|
||||
func (s *persistentEventsSession) Alive() bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.alive
|
||||
}
|
||||
func (s *persistentEventsSession) Close() error {
|
||||
s.mu.Lock()
|
||||
if !s.alive {
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
s.alive = false
|
||||
close(s.events)
|
||||
s.mu.Unlock()
|
||||
select {
|
||||
case <-s.closed:
|
||||
default:
|
||||
close(s.closed)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// emit pushes an event into the channel.
|
||||
func (s *persistentEventsSession) emit(ev core.Event) {
|
||||
s.events <- ev
|
||||
}
|
||||
|
||||
// promptCount returns how many Send calls have occurred (indicates how many
|
||||
// foreground turns the engine has dispatched).
|
||||
func (s *persistentEventsSession) promptCount() int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return len(s.prompts)
|
||||
}
|
||||
|
||||
type persistentEventsAgent struct {
|
||||
name string
|
||||
session *persistentEventsSession
|
||||
}
|
||||
|
||||
func newPersistentEventsAgent(name string, session *persistentEventsSession) *persistentEventsAgent {
|
||||
return &persistentEventsAgent{name: name, session: session}
|
||||
}
|
||||
|
||||
func (a *persistentEventsAgent) Name() string { return a.name }
|
||||
func (a *persistentEventsAgent) StartSession(_ context.Context, _ string) (core.AgentSession, error) {
|
||||
return a.session, nil
|
||||
}
|
||||
func (a *persistentEventsAgent) ListSessions(_ context.Context) ([]core.AgentSessionInfo, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (a *persistentEventsAgent) Stop() error { return nil }
|
||||
|
||||
// capturingPlatform records all messages sent via Send/Reply and captures
|
||||
// the handler passed to Start so tests can inject messages directly.
|
||||
type capturingPlatform struct {
|
||||
mu sync.Mutex
|
||||
sent []string
|
||||
handler core.MessageHandler
|
||||
started chan struct{}
|
||||
}
|
||||
|
||||
func newCapturingPlatform() *capturingPlatform {
|
||||
return &capturingPlatform{started: make(chan struct{})}
|
||||
}
|
||||
|
||||
func (p *capturingPlatform) Name() string { return "capture" }
|
||||
func (p *capturingPlatform) Start(h core.MessageHandler) error {
|
||||
p.mu.Lock()
|
||||
p.handler = h
|
||||
p.mu.Unlock()
|
||||
select {
|
||||
case <-p.started:
|
||||
default:
|
||||
close(p.started)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (p *capturingPlatform) Reply(_ context.Context, _ any, c string) error {
|
||||
p.mu.Lock()
|
||||
p.sent = append(p.sent, c)
|
||||
p.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
func (p *capturingPlatform) Send(_ context.Context, _ any, c string) error {
|
||||
p.mu.Lock()
|
||||
p.sent = append(p.sent, c)
|
||||
p.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
func (p *capturingPlatform) Stop() error { return nil }
|
||||
|
||||
func (p *capturingPlatform) messages() []string {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
cp := make([]string, len(p.sent))
|
||||
copy(cp, p.sent)
|
||||
return cp
|
||||
}
|
||||
|
||||
func (p *capturingPlatform) dispatch(msg *core.Message) {
|
||||
p.mu.Lock()
|
||||
h := p.handler
|
||||
p.mu.Unlock()
|
||||
if h == nil {
|
||||
panic("platform handler not set — Start must be called first")
|
||||
}
|
||||
h(p, msg)
|
||||
}
|
||||
|
||||
// waitForMessage polls until a message containing substr is relayed or
|
||||
// the timeout expires. Returns true if found. Event-driven, no fixed sleeps.
|
||||
func waitForMessage(t *testing.T, p *capturingPlatform, substr string, timeout time.Duration) bool {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
for _, m := range p.messages() {
|
||||
if strings.Contains(m, substr) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// waitForPromptCount polls until the session has received at least n prompts
|
||||
// (i.e. n foreground turns have been dispatched).
|
||||
func waitForPromptCount(t *testing.T, s *persistentEventsSession, n int, timeout time.Duration) bool {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
if s.promptCount() >= n {
|
||||
return true
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// TestIntegration_UnsolicitedEventsEndToEnd verifies the full happy-path:
|
||||
// 1. User sends msg → agent completes turn → foreground response delivered
|
||||
// 2. Agent later emits new events (simulating background task completion)
|
||||
// → unsolicited reader relays them to the platform
|
||||
// 3. User sends a SECOND msg → unsolicited events are NOT re-delivered and
|
||||
// are NOT drained (eventsNeedResync=false after the clean unsolicited
|
||||
// turn), and the new foreground response is delivered correctly
|
||||
func TestIntegration_UnsolicitedEventsEndToEnd(t *testing.T) {
|
||||
sess := newPersistentEventsSession("unsol-e2e")
|
||||
agent := newPersistentEventsAgent("fake-claude", sess)
|
||||
platform := newCapturingPlatform()
|
||||
|
||||
engine := core.NewEngine("test", agent, []core.Platform{platform}, "", core.LangEnglish)
|
||||
defer engine.Stop()
|
||||
require.NoError(t, engine.Start())
|
||||
|
||||
// Platform.Start must have been called by engine — handler is now available.
|
||||
<-platform.started
|
||||
|
||||
// ─── Phase 1: user sends first message ──────────────────────
|
||||
userMsg1 := &core.Message{
|
||||
SessionKey: "test:ch1:u1",
|
||||
Platform: "capture",
|
||||
MessageID: "msg-001",
|
||||
UserID: "u1",
|
||||
Content: "run 5 campaigns in background",
|
||||
ReplyCtx: "rctx-1",
|
||||
}
|
||||
go platform.dispatch(userMsg1)
|
||||
|
||||
// Synchronize on the agent receiving the prompt (not on wall-clock time).
|
||||
require.True(t, waitForPromptCount(t, sess, 1, 3*time.Second),
|
||||
"agent never received the first prompt")
|
||||
|
||||
// Feed the foreground turn events.
|
||||
sess.emit(core.Event{Type: core.EventText, Content: "Submitted, waiting..."})
|
||||
sess.emit(core.Event{Type: core.EventResult, Content: "Submitted, waiting..."})
|
||||
|
||||
require.True(t, waitForMessage(t, platform, "Submitted", 3*time.Second),
|
||||
"foreground turn response was not delivered")
|
||||
|
||||
// ─── Phase 2: simulate background task completion ──────────
|
||||
// These events arrive AFTER the foreground turn ended. Under the old
|
||||
// behavior they would sit in the buffer until drained by the next msg.
|
||||
const bgDoneMarker = "All 5 campaigns created successfully"
|
||||
sess.emit(core.Event{Type: core.EventText, Content: bgDoneMarker})
|
||||
sess.emit(core.Event{Type: core.EventResult, Content: bgDoneMarker})
|
||||
|
||||
require.True(t, waitForMessage(t, platform, bgDoneMarker, 3*time.Second),
|
||||
"unsolicited event was not relayed to platform")
|
||||
|
||||
// ─── Phase 3: user sends a SECOND message ──────────────────
|
||||
// This exercises the conditional-drain path: eventsNeedResync is false
|
||||
// (clean unsolicited turn), so any events here must be attributed to
|
||||
// the new turn rather than drained away. Since the channel is empty
|
||||
// by now, this is really a smoke test that a follow-up turn still works.
|
||||
userMsg2 := &core.Message{
|
||||
SessionKey: "test:ch1:u1",
|
||||
Platform: "capture",
|
||||
MessageID: "msg-002",
|
||||
UserID: "u1",
|
||||
Content: "thanks, any failures?",
|
||||
ReplyCtx: "rctx-2",
|
||||
}
|
||||
go platform.dispatch(userMsg2)
|
||||
|
||||
require.True(t, waitForPromptCount(t, sess, 2, 3*time.Second),
|
||||
"second foreground turn never dispatched to agent")
|
||||
|
||||
const secondTurnMarker = "no failures, all clean"
|
||||
sess.emit(core.Event{Type: core.EventText, Content: secondTurnMarker})
|
||||
sess.emit(core.Event{Type: core.EventResult, Content: secondTurnMarker})
|
||||
|
||||
require.True(t, waitForMessage(t, platform, secondTurnMarker, 3*time.Second),
|
||||
"second foreground turn response was not delivered")
|
||||
|
||||
// Final sanity: all three distinct messages present in order.
|
||||
msgs := platform.messages()
|
||||
t.Logf("platform received %d messages: %v", len(msgs), msgs)
|
||||
assert.True(t, containsAll(msgs, "Submitted", bgDoneMarker, secondTurnMarker),
|
||||
"expected all three messages to be delivered: %v", msgs)
|
||||
}
|
||||
|
||||
// TestIntegration_StaleEventsDrainedAfterAbnormalExit verifies that when a
|
||||
// turn ends abnormally (EventError → eventsNeedResync=true), any events that
|
||||
// arrive afterward are NOT relayed as unsolicited AND are drained (not
|
||||
// mistaken for the response of) when the next user message starts a new turn.
|
||||
func TestIntegration_StaleEventsDrainedAfterAbnormalExit(t *testing.T) {
|
||||
sess := newPersistentEventsSession("unsol-abnormal")
|
||||
agent := newPersistentEventsAgent("fake-claude", sess)
|
||||
platform := newCapturingPlatform()
|
||||
|
||||
engine := core.NewEngine("test", agent, []core.Platform{platform}, "", core.LangEnglish)
|
||||
defer engine.Stop()
|
||||
require.NoError(t, engine.Start())
|
||||
<-platform.started
|
||||
|
||||
// ─── Phase 1: user message → abnormal exit ──────────────────
|
||||
userMsg1 := &core.Message{
|
||||
SessionKey: "test:ch1:u1",
|
||||
Platform: "capture",
|
||||
MessageID: "msg-err",
|
||||
UserID: "u1",
|
||||
Content: "do something",
|
||||
ReplyCtx: "rctx",
|
||||
}
|
||||
go platform.dispatch(userMsg1)
|
||||
|
||||
require.True(t, waitForPromptCount(t, sess, 1, 3*time.Second),
|
||||
"agent never received the prompt")
|
||||
|
||||
// Turn exits abnormally — EventError sets eventsNeedResync=true and
|
||||
// causes cleanupInteractiveState in the EventError path (if agent is
|
||||
// reported dead). Here the agent is still Alive(), so session persists
|
||||
// but the flag stays true.
|
||||
sess.emit(core.Event{Type: core.EventError, Error: simpleError("turn failed")})
|
||||
|
||||
require.True(t, waitForMessage(t, platform, "turn failed", 3*time.Second),
|
||||
"error message not delivered")
|
||||
|
||||
// ─── Phase 2: push "leftover" events that would wrongly relay ──
|
||||
// Because eventsNeedResync=true after the error, the unsolicited reader
|
||||
// should NOT be started. These events should sit in the buffer.
|
||||
const leftoverMarker = "LEFTOVER-SHOULD-BE-DRAINED"
|
||||
sess.emit(core.Event{Type: core.EventText, Content: leftoverMarker})
|
||||
sess.emit(core.Event{Type: core.EventResult, Content: leftoverMarker})
|
||||
|
||||
// ─── Phase 3: send a NEXT user message ─────────────────────
|
||||
// drainEvents() in processInteractiveMessageWith should clear the
|
||||
// buffered leftovers BEFORE agent.Send() is called for the new turn.
|
||||
userMsg2 := &core.Message{
|
||||
SessionKey: "test:ch1:u1",
|
||||
Platform: "capture",
|
||||
MessageID: "msg-002",
|
||||
UserID: "u1",
|
||||
Content: "retry please",
|
||||
ReplyCtx: "rctx-2",
|
||||
}
|
||||
go platform.dispatch(userMsg2)
|
||||
|
||||
require.True(t, waitForPromptCount(t, sess, 2, 3*time.Second),
|
||||
"second foreground turn never dispatched to agent")
|
||||
|
||||
// Feed the new turn's events.
|
||||
const retryMarker = "retry succeeded"
|
||||
sess.emit(core.Event{Type: core.EventText, Content: retryMarker})
|
||||
sess.emit(core.Event{Type: core.EventResult, Content: retryMarker})
|
||||
|
||||
require.True(t, waitForMessage(t, platform, retryMarker, 3*time.Second),
|
||||
"retry turn response not delivered")
|
||||
|
||||
// ─── Verify: leftovers were NEVER relayed, before or after the retry ──
|
||||
for _, m := range platform.messages() {
|
||||
if strings.Contains(m, leftoverMarker) {
|
||||
t.Fatalf("leftover event from abnormal turn was incorrectly relayed: %v",
|
||||
platform.messages())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func containsAll(msgs []string, needles ...string) bool {
|
||||
joined := strings.Join(msgs, "\n")
|
||||
for _, n := range needles {
|
||||
if !strings.Contains(joined, n) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
type simpleError string
|
||||
|
||||
func (e simpleError) Error() string { return string(e) }
|
||||
@@ -0,0 +1,228 @@
|
||||
package fake
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/chenhg5/cc-connect/core"
|
||||
)
|
||||
|
||||
// TestMessage creates a basic test message with sensible defaults.
|
||||
func TestMessage() *core.Message {
|
||||
return &core.Message{
|
||||
SessionKey: "test:channel:user",
|
||||
Platform: "test",
|
||||
MessageID: "msg-test-001",
|
||||
UserID: "user-test",
|
||||
UserName: "Test User",
|
||||
ChatName: "Test Channel",
|
||||
Content: "Hello, world",
|
||||
ReplyCtx: nil,
|
||||
}
|
||||
}
|
||||
|
||||
// TestMessageWithContent creates a message with specific content.
|
||||
func TestMessageWithContent(content string) *core.Message {
|
||||
msg := TestMessage()
|
||||
msg.Content = content
|
||||
return msg
|
||||
}
|
||||
|
||||
// TestMessageWithSession creates a message with a specific session key.
|
||||
func TestMessageWithSession(sessionKey string) *core.Message {
|
||||
msg := TestMessage()
|
||||
msg.SessionKey = sessionKey
|
||||
return msg
|
||||
}
|
||||
|
||||
// TestMessageWithImages creates a message with images.
|
||||
func TestMessageWithImages(images []core.ImageAttachment) *core.Message {
|
||||
msg := TestMessage()
|
||||
msg.Images = images
|
||||
return msg
|
||||
}
|
||||
|
||||
// TestMessageWithFiles creates a message with files.
|
||||
func TestMessageWithFiles(files []core.FileAttachment) *core.Message {
|
||||
msg := TestMessage()
|
||||
msg.Files = files
|
||||
return msg
|
||||
}
|
||||
|
||||
// TestMessageWithAudio creates a message with audio.
|
||||
func TestMessageWithAudio(audio *core.AudioAttachment) *core.Message {
|
||||
msg := TestMessage()
|
||||
msg.Audio = audio
|
||||
return msg
|
||||
}
|
||||
|
||||
// TestMessageFromVoice creates a message that originated from voice.
|
||||
func TestMessageFromVoice(content string) *core.Message {
|
||||
msg := TestMessageWithContent(content)
|
||||
msg.FromVoice = true
|
||||
return msg
|
||||
}
|
||||
|
||||
// TestLongMessage creates a message with a very long content for truncation testing.
|
||||
func TestLongMessage(length int) *core.Message {
|
||||
return &core.Message{
|
||||
SessionKey: "test:channel:user",
|
||||
Platform: "test",
|
||||
MessageID: "msg-test-long",
|
||||
UserID: "user-test",
|
||||
Content: strings.Repeat("x", length),
|
||||
}
|
||||
}
|
||||
|
||||
// TestSpecialCharsMessage creates a message with special characters for security testing.
|
||||
func TestSpecialCharsMessage() *core.Message {
|
||||
return &core.Message{
|
||||
SessionKey: "test:channel:user",
|
||||
Platform: "test",
|
||||
MessageID: "msg-test-special",
|
||||
UserID: "user-test",
|
||||
Content: "Hello! 🎉 <script>alert('xss')</script> & \"quotes\"",
|
||||
}
|
||||
}
|
||||
|
||||
// TestImageAttachment creates a test image attachment.
|
||||
func TestImageAttachment(mimeType, filename string, data []byte) core.ImageAttachment {
|
||||
return core.ImageAttachment{
|
||||
MimeType: mimeType,
|
||||
Data: data,
|
||||
FileName: filename,
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileAttachment creates a test file attachment.
|
||||
func TestFileAttachment(mimeType, filename string, data []byte) core.FileAttachment {
|
||||
return core.FileAttachment{
|
||||
MimeType: mimeType,
|
||||
Data: data,
|
||||
FileName: filename,
|
||||
}
|
||||
}
|
||||
|
||||
// TestAudioAttachment creates a test audio attachment.
|
||||
func TestAudioAttachment(mimeType, format string, data []byte, duration int) *core.AudioAttachment {
|
||||
return &core.AudioAttachment{
|
||||
MimeType: mimeType,
|
||||
Data: data,
|
||||
Format: format,
|
||||
Duration: duration,
|
||||
}
|
||||
}
|
||||
|
||||
// TestEvent creates a test event.
|
||||
func TestEvent(eventType core.EventType, content string) core.Event {
|
||||
return core.Event{
|
||||
Type: eventType,
|
||||
Content: content,
|
||||
Done: eventType == core.EventResult || eventType == core.EventError,
|
||||
}
|
||||
}
|
||||
|
||||
// TestTextEvent creates a text event.
|
||||
func TestTextEvent(content string) core.Event {
|
||||
return TestEvent(core.EventText, content)
|
||||
}
|
||||
|
||||
// TestResultEvent creates a result event.
|
||||
func TestResultEvent(content string) core.Event {
|
||||
return TestEvent(core.EventResult, content)
|
||||
}
|
||||
|
||||
// TestErrorEvent creates an error event.
|
||||
func TestErrorEvent(err error) core.Event {
|
||||
return core.Event{
|
||||
Type: core.EventError,
|
||||
Done: true,
|
||||
Error: err,
|
||||
}
|
||||
}
|
||||
|
||||
// TestPermissionRequestEvent creates a permission request event.
|
||||
func TestPermissionRequestEvent(requestID, toolName, toolInput string) core.Event {
|
||||
return core.Event{
|
||||
Type: core.EventPermissionRequest,
|
||||
ToolName: toolName,
|
||||
ToolInput: toolInput,
|
||||
RequestID: requestID,
|
||||
}
|
||||
}
|
||||
|
||||
// TestToolUseEvent creates a tool use event.
|
||||
func TestToolUseEvent(toolName, toolInput string) core.Event {
|
||||
return core.Event{
|
||||
Type: core.EventToolUse,
|
||||
ToolName: toolName,
|
||||
ToolInput: toolInput,
|
||||
}
|
||||
}
|
||||
|
||||
// TestThinkingEvent creates a thinking event.
|
||||
func TestThinkingEvent(content string) core.Event {
|
||||
return TestEvent(core.EventThinking, content)
|
||||
}
|
||||
|
||||
// TestHistoryEntry creates a test history entry.
|
||||
func TestHistoryEntry(role, content string) core.HistoryEntry {
|
||||
return core.HistoryEntry{
|
||||
Role: role,
|
||||
Content: content,
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgentSessionInfo creates a test agent session info.
|
||||
func TestAgentSessionInfo(id, summary string, messageCount int) core.AgentSessionInfo {
|
||||
return core.AgentSessionInfo{
|
||||
ID: id,
|
||||
Summary: summary,
|
||||
MessageCount: messageCount,
|
||||
ModifiedAt: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
// TestPermissionResult creates a test permission result.
|
||||
func TestPermissionResultAllow() core.PermissionResult {
|
||||
return core.PermissionResult{
|
||||
Behavior: "allow",
|
||||
}
|
||||
}
|
||||
|
||||
func TestPermissionResultDeny(message string) core.PermissionResult {
|
||||
return core.PermissionResult{
|
||||
Behavior: "deny",
|
||||
Message: message,
|
||||
}
|
||||
}
|
||||
|
||||
// TestProviderConfig creates a test provider config.
|
||||
func TestProviderConfig(name, apiKey, baseURL, model string) core.ProviderConfig {
|
||||
return core.ProviderConfig{
|
||||
Name: name,
|
||||
APIKey: apiKey,
|
||||
BaseURL: baseURL,
|
||||
Model: model,
|
||||
}
|
||||
}
|
||||
|
||||
// TestModelOption creates a test model option.
|
||||
func TestModelOption(name, desc, alias string) core.ModelOption {
|
||||
return core.ModelOption{
|
||||
Name: name,
|
||||
Desc: desc,
|
||||
Alias: alias,
|
||||
}
|
||||
}
|
||||
|
||||
// BatchTestMessages creates multiple test messages for batch testing.
|
||||
func BatchTestMessages(count int) []*core.Message {
|
||||
messages := make([]*core.Message, count)
|
||||
for i := 0; i < count; i++ {
|
||||
messages[i] = TestMessageWithContent(fmt.Sprintf("Test message %d", i))
|
||||
}
|
||||
return messages
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package fake
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/chenhg5/cc-connect/core"
|
||||
)
|
||||
|
||||
// TestUsageReport creates a test usage report.
|
||||
func TestUsageReport(provider, accountID, email string) *core.UsageReport {
|
||||
return &core.UsageReport{
|
||||
Provider: provider,
|
||||
AccountID: accountID,
|
||||
UserID: "test-user",
|
||||
Email: email,
|
||||
Plan: "pro",
|
||||
Buckets: []core.UsageBucket{
|
||||
{
|
||||
Name: "Standard Requests",
|
||||
Allowed: true,
|
||||
LimitReached: false,
|
||||
Windows: []core.UsageWindow{
|
||||
{UsedPercent: 45, WindowSeconds: 3600},
|
||||
},
|
||||
},
|
||||
},
|
||||
Credits: &core.UsageCredits{
|
||||
HasCredits: true,
|
||||
Unlimited: false,
|
||||
Balance: "$12.50",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// TestPermissionModeInfo creates a test permission mode info.
|
||||
func TestPermissionModeInfo(key, name, nameZh, desc, descZh string) core.PermissionModeInfo {
|
||||
return core.PermissionModeInfo{
|
||||
Key: key,
|
||||
Name: name,
|
||||
NameZh: nameZh,
|
||||
Desc: desc,
|
||||
DescZh: descZh,
|
||||
}
|
||||
}
|
||||
|
||||
// TestCard creates a test card using the CardBuilder.
|
||||
func TestCard() *core.Card {
|
||||
return core.NewCard().
|
||||
Title("Test Card", "blue").
|
||||
Markdown("**Test content**").
|
||||
Build()
|
||||
}
|
||||
|
||||
// TestCardWithTitle creates a card with a specific title.
|
||||
func TestCardWithTitle(title string) *core.Card {
|
||||
return core.NewCard().
|
||||
Title(title, "blue").
|
||||
Markdown("**Content**").
|
||||
Build()
|
||||
}
|
||||
|
||||
// TestCardWithButtons creates a card with buttons.
|
||||
func TestCardWithButtons(buttons ...core.CardButton) *core.Card {
|
||||
return core.NewCard().
|
||||
Title("Test Card", "blue").
|
||||
Markdown("Select an option:").
|
||||
Buttons(buttons...).
|
||||
Build()
|
||||
}
|
||||
|
||||
// TestMessageHandler is a simple message handler for testing.
|
||||
type TestMessageHandler struct {
|
||||
mu sync.Mutex
|
||||
Messages []*core.Message
|
||||
}
|
||||
|
||||
func NewTestMessageHandler() *TestMessageHandler {
|
||||
return &TestMessageHandler{
|
||||
Messages: make([]*core.Message, 0),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *TestMessageHandler) Handle(p core.Platform, msg *core.Message) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.Messages = append(h.Messages, msg)
|
||||
}
|
||||
|
||||
func (h *TestMessageHandler) GetMessages() []*core.Message {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
cp := make([]*core.Message, len(h.Messages))
|
||||
copy(cp, h.Messages)
|
||||
return cp
|
||||
}
|
||||
|
||||
func (h *TestMessageHandler) Clear() {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.Messages = h.Messages[:0]
|
||||
}
|
||||
|
||||
// TestDedupeItem creates a test deduplication item.
|
||||
type TestDedupeItem struct {
|
||||
key string
|
||||
expiration time.Time
|
||||
}
|
||||
|
||||
func NewTestDedupeItem(key string, ttl time.Duration) *TestDedupeItem {
|
||||
return &TestDedupeItem{
|
||||
key: key,
|
||||
expiration: time.Now().Add(ttl),
|
||||
}
|
||||
}
|
||||
|
||||
// TestRateLimiterToken creates a test rate limiter token bucket state.
|
||||
type TestRateLimiterToken struct{}
|
||||
|
||||
// TestCronJob creates a test cron job.
|
||||
func TestCronJob(id, desc, prompt string, cronExpr string) *core.CronJob {
|
||||
enabled := true
|
||||
return &core.CronJob{
|
||||
ID: id,
|
||||
Description: desc,
|
||||
Prompt: prompt,
|
||||
CronExpr: cronExpr,
|
||||
Enabled: enabled,
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgentSessionInfoList creates a list of test agent session info.
|
||||
func TestAgentSessionInfoList(count int) []core.AgentSessionInfo {
|
||||
sessions := make([]core.AgentSessionInfo, count)
|
||||
for i := 0; i < count; i++ {
|
||||
sessions[i] = core.AgentSessionInfo{
|
||||
ID: fmt.Sprintf("session-%d", i),
|
||||
Summary: fmt.Sprintf("Test session %d", i),
|
||||
MessageCount: (i + 1) * 10,
|
||||
ModifiedAt: time.Now().Add(-time.Duration(i) * time.Hour),
|
||||
}
|
||||
}
|
||||
return sessions
|
||||
}
|
||||
|
||||
// TestContext returns a context with timeout for testing.
|
||||
func TestContext(timeout time.Duration) (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(context.Background(), timeout)
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
package fake
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/chenhg5/cc-connect/core"
|
||||
)
|
||||
|
||||
// FakeAgentSession is a fake implementation of AgentSession for testing.
|
||||
// It simulates agent behavior without calling real CLI tools.
|
||||
type FakeAgentSession struct {
|
||||
mu sync.RWMutex
|
||||
sessionID string
|
||||
promptQueue []string
|
||||
events []core.Event
|
||||
closed bool
|
||||
alive bool
|
||||
responseDelay time.Duration
|
||||
responses []string
|
||||
responseIdx int
|
||||
}
|
||||
|
||||
func NewFakeAgentSession(sessionID string) *FakeAgentSession {
|
||||
return &FakeAgentSession{
|
||||
sessionID: sessionID,
|
||||
alive: true,
|
||||
events: make([]core.Event, 0),
|
||||
promptQueue: make([]string, 0),
|
||||
}
|
||||
}
|
||||
|
||||
// SetResponseDelay sets a delay before sending responses (for timeout testing).
|
||||
func (s *FakeAgentSession) SetResponseDelay(delay time.Duration) *FakeAgentSession {
|
||||
s.responseDelay = delay
|
||||
return s
|
||||
}
|
||||
|
||||
// SetResponses sets predefined responses to return.
|
||||
func (s *FakeAgentSession) SetResponses(responses ...string) *FakeAgentSession {
|
||||
s.responses = responses
|
||||
return s
|
||||
}
|
||||
|
||||
// AddTextEvent adds a text event to the event stream.
|
||||
func (s *FakeAgentSession) AddTextEvent(content string) *FakeAgentSession {
|
||||
s.events = append(s.events, TestTextEvent(content))
|
||||
return s
|
||||
}
|
||||
|
||||
// AddResultEvent adds a result event to the event stream.
|
||||
func (s *FakeAgentSession) AddResultEvent(content string) *FakeAgentSession {
|
||||
s.events = append(s.events, TestResultEvent(content))
|
||||
return s
|
||||
}
|
||||
|
||||
// AddErrorEvent adds an error event to the event stream.
|
||||
func (s *FakeAgentSession) AddErrorEvent(err error) *FakeAgentSession {
|
||||
s.events = append(s.events, TestErrorEvent(err))
|
||||
return s
|
||||
}
|
||||
|
||||
// AddThinkingEvent adds a thinking event to the event stream.
|
||||
func (s *FakeAgentSession) AddThinkingEvent(content string) *FakeAgentSession {
|
||||
s.events = append(s.events, TestThinkingEvent(content))
|
||||
return s
|
||||
}
|
||||
|
||||
// AddPermissionRequest adds a permission request event.
|
||||
func (s *FakeAgentSession) AddPermissionRequest(requestID, toolName, toolInput string) *FakeAgentSession {
|
||||
s.events = append(s.events, TestPermissionRequestEvent(requestID, toolName, toolInput))
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *FakeAgentSession) Send(prompt string, images []core.ImageAttachment, files []core.FileAttachment) error {
|
||||
s.mu.Lock()
|
||||
if s.closed {
|
||||
s.mu.Unlock()
|
||||
return io.ErrClosedPipe
|
||||
}
|
||||
|
||||
s.promptQueue = append(s.promptQueue, prompt)
|
||||
|
||||
if len(s.events) == 0 {
|
||||
if len(s.responses) > 0 && s.responseIdx < len(s.responses) {
|
||||
resp := s.responses[s.responseIdx]
|
||||
s.responseIdx++
|
||||
s.events = append(s.events, TestTextEvent(resp), TestResultEvent(resp))
|
||||
} else {
|
||||
s.events = append(s.events, TestTextEvent("Processing: "+prompt), TestResultEvent("Done"))
|
||||
}
|
||||
}
|
||||
delay := s.responseDelay
|
||||
s.mu.Unlock()
|
||||
|
||||
if delay > 0 {
|
||||
time.Sleep(delay)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *FakeAgentSession) RespondPermission(requestID string, result core.PermissionResult) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.events = append(s.events, core.Event{
|
||||
Type: core.EventToolResult,
|
||||
Content: "Permission " + result.Behavior,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *FakeAgentSession) Events() <-chan core.Event {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
needsDone := len(s.events) == 0 || !s.events[len(s.events)-1].Done
|
||||
bufSize := len(s.events)
|
||||
if needsDone {
|
||||
bufSize++
|
||||
}
|
||||
ch := make(chan core.Event, bufSize)
|
||||
for _, e := range s.events {
|
||||
ch <- e
|
||||
}
|
||||
if needsDone {
|
||||
ch <- core.Event{Type: core.EventResult, Done: true}
|
||||
}
|
||||
close(ch)
|
||||
return ch
|
||||
}
|
||||
|
||||
func (s *FakeAgentSession) CurrentSessionID() string {
|
||||
return s.sessionID
|
||||
}
|
||||
|
||||
func (s *FakeAgentSession) Alive() bool {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.alive && !s.closed
|
||||
}
|
||||
|
||||
func (s *FakeAgentSession) Close() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.alive = false
|
||||
s.closed = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetPrompts returns all prompts sent to this session (for verification).
|
||||
func (s *FakeAgentSession) GetPrompts() []string {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.promptQueue
|
||||
}
|
||||
|
||||
// FakeAgent is a fake implementation of Agent for testing.
|
||||
type FakeAgent struct {
|
||||
name string
|
||||
sessionID string
|
||||
session *FakeAgentSession
|
||||
preConfiguredSession *FakeAgentSession // session from NewFakeAgentWithSession
|
||||
sessions []core.AgentSessionInfo
|
||||
stopped bool
|
||||
}
|
||||
|
||||
func NewFakeAgent(name string) *FakeAgent {
|
||||
return &FakeAgent{
|
||||
name: name,
|
||||
sessionID: "fake-session-001",
|
||||
sessions: []core.AgentSessionInfo{
|
||||
{ID: "fake-session-001", Summary: "Test session"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (a *FakeAgent) Name() string {
|
||||
return a.name
|
||||
}
|
||||
|
||||
func (a *FakeAgent) StartSession(ctx context.Context, sessionID string) (core.AgentSession, error) {
|
||||
a.sessionID = sessionID
|
||||
// Return pre-configured session on first call (from NewFakeAgentWithSession)
|
||||
// then create fresh sessions for subsequent calls
|
||||
if a.preConfiguredSession != nil {
|
||||
a.session = a.preConfiguredSession
|
||||
a.preConfiguredSession = nil
|
||||
return a.session, nil
|
||||
}
|
||||
a.session = NewFakeAgentSession(sessionID)
|
||||
return a.session, nil
|
||||
}
|
||||
|
||||
func (a *FakeAgent) ListSessions(ctx context.Context) ([]core.AgentSessionInfo, error) {
|
||||
return a.sessions, nil
|
||||
}
|
||||
|
||||
func (a *FakeAgent) Stop() error {
|
||||
a.stopped = true
|
||||
if a.session != nil {
|
||||
a.session.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSession returns the current fake session.
|
||||
func (a *FakeAgent) GetSession() *FakeAgentSession {
|
||||
return a.session
|
||||
}
|
||||
|
||||
// NewFakeAgentWithSession creates a fake agent with a pre-configured session.
|
||||
// The pre-configured session is returned on the first StartSession call.
|
||||
// Subsequent StartSession calls create fresh sessions (simulating real agent behavior).
|
||||
func NewFakeAgentWithSession(name, sessionID string, session *FakeAgentSession) *FakeAgent {
|
||||
return &FakeAgent{
|
||||
name: name,
|
||||
preConfiguredSession: session,
|
||||
sessionID: sessionID,
|
||||
sessions: []core.AgentSessionInfo{
|
||||
{ID: sessionID, Summary: "Test session"},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
package mocks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
|
||||
"github.com/chenhg5/cc-connect/core"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
// MockAgent is a mock implementation of the core.Agent interface.
|
||||
type MockAgent struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *MockAgent) Name() string {
|
||||
args := m.Called()
|
||||
return args.String(0)
|
||||
}
|
||||
|
||||
func (m *MockAgent) StartSession(ctx context.Context, sessionID string) (core.AgentSession, error) {
|
||||
args := m.Called(ctx, sessionID)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(core.AgentSession), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockAgent) ListSessions(ctx context.Context) ([]core.AgentSessionInfo, error) {
|
||||
args := m.Called(ctx)
|
||||
return args.Get(0).([]core.AgentSessionInfo), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockAgent) Stop() error {
|
||||
args := m.Called()
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
// MockAgentSession is a mock implementation of the core.AgentSession interface.
|
||||
type MockAgentSession struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *MockAgentSession) Send(prompt string, images []core.ImageAttachment, files []core.FileAttachment) error {
|
||||
args := m.Called(prompt, images, files)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockAgentSession) RespondPermission(requestID string, result core.PermissionResult) error {
|
||||
args := m.Called(requestID, result)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockAgentSession) Events() <-chan core.Event {
|
||||
args := m.Called()
|
||||
return args.Get(0).(<-chan core.Event)
|
||||
}
|
||||
|
||||
func (m *MockAgentSession) CurrentSessionID() string {
|
||||
args := m.Called()
|
||||
return args.String(0)
|
||||
}
|
||||
|
||||
func (m *MockAgentSession) Alive() bool {
|
||||
args := m.Called()
|
||||
return args.Bool(0)
|
||||
}
|
||||
|
||||
func (m *MockAgentSession) Close() error {
|
||||
args := m.Called()
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
// MockAgentWithProviders is a mock agent that also implements ProviderSwitcher.
|
||||
type MockAgentWithProviders struct {
|
||||
*MockAgent
|
||||
}
|
||||
|
||||
func (m *MockAgentWithProviders) SetProviders(providers []core.ProviderConfig) {
|
||||
m.Called(providers)
|
||||
}
|
||||
|
||||
func (m *MockAgentWithProviders) SetActiveProvider(name string) bool {
|
||||
args := m.Called(name)
|
||||
return args.Bool(0)
|
||||
}
|
||||
|
||||
func (m *MockAgentWithProviders) GetActiveProvider() *core.ProviderConfig {
|
||||
args := m.Called()
|
||||
if args.Get(0) == nil {
|
||||
return nil
|
||||
}
|
||||
return args.Get(0).(*core.ProviderConfig)
|
||||
}
|
||||
|
||||
func (m *MockAgentWithProviders) ListProviders() []core.ProviderConfig {
|
||||
args := m.Called()
|
||||
return args.Get(0).([]core.ProviderConfig)
|
||||
}
|
||||
|
||||
// MockAgentWithModel is a mock agent that also implements ModelSwitcher.
|
||||
type MockAgentWithModel struct {
|
||||
*MockAgent
|
||||
}
|
||||
|
||||
func (m *MockAgentWithModel) SetModel(model string) {
|
||||
m.Called(model)
|
||||
}
|
||||
|
||||
func (m *MockAgentWithModel) GetModel() string {
|
||||
args := m.Called()
|
||||
return args.String(0)
|
||||
}
|
||||
|
||||
func (m *MockAgentWithModel) AvailableModels(ctx context.Context) []core.ModelOption {
|
||||
args := m.Called(ctx)
|
||||
return args.Get(0).([]core.ModelOption)
|
||||
}
|
||||
|
||||
// MockAgentWithMode is a mock agent that also implements ModeSwitcher.
|
||||
type MockAgentWithMode struct {
|
||||
*MockAgent
|
||||
}
|
||||
|
||||
func (m *MockAgentWithMode) SetMode(mode string) {
|
||||
m.Called(mode)
|
||||
}
|
||||
|
||||
func (m *MockAgentWithMode) GetMode() string {
|
||||
args := m.Called()
|
||||
return args.String(0)
|
||||
}
|
||||
|
||||
func (m *MockAgentWithMode) PermissionModes() []core.PermissionModeInfo {
|
||||
args := m.Called()
|
||||
return args.Get(0).([]core.PermissionModeInfo)
|
||||
}
|
||||
|
||||
// MockAgentWithToolAuth is a mock agent that also implements ToolAuthorizer.
|
||||
type MockAgentWithToolAuth struct {
|
||||
*MockAgent
|
||||
}
|
||||
|
||||
func (m *MockAgentWithToolAuth) AddAllowedTools(tools ...string) error {
|
||||
args := m.Called(tools)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockAgentWithToolAuth) GetAllowedTools() []string {
|
||||
args := m.Called()
|
||||
return args.Get(0).([]string)
|
||||
}
|
||||
|
||||
// MockAgentWithHistory is a mock agent that also implements HistoryProvider.
|
||||
type MockAgentWithHistory struct {
|
||||
*MockAgent
|
||||
}
|
||||
|
||||
func (m *MockAgentWithHistory) GetSessionHistory(ctx context.Context, sessionID string, limit int) ([]core.HistoryEntry, error) {
|
||||
args := m.Called(ctx, sessionID, limit)
|
||||
return args.Get(0).([]core.HistoryEntry), args.Error(1)
|
||||
}
|
||||
|
||||
// MockAgentWithUsage is a mock agent that also implements UsageReporter.
|
||||
type MockAgentWithUsage struct {
|
||||
*MockAgent
|
||||
}
|
||||
|
||||
func (m *MockAgentWithUsage) GetUsage(ctx context.Context) (*core.UsageReport, error) {
|
||||
args := m.Called(ctx)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(*core.UsageReport), args.Error(1)
|
||||
}
|
||||
|
||||
// MockAgentWithMemory is a mock agent that also implements MemoryFileProvider.
|
||||
type MockAgentWithMemory struct {
|
||||
*MockAgent
|
||||
}
|
||||
|
||||
func (m *MockAgentWithMemory) ProjectMemoryFile() string {
|
||||
args := m.Called()
|
||||
return args.String(0)
|
||||
}
|
||||
|
||||
func (m *MockAgentWithMemory) GlobalMemoryFile() string {
|
||||
args := m.Called()
|
||||
return args.String(0)
|
||||
}
|
||||
|
||||
// MockAgentWithWorkDir is a mock agent that also implements WorkDirSwitcher.
|
||||
type MockAgentWithWorkDir struct {
|
||||
*MockAgent
|
||||
}
|
||||
|
||||
func (m *MockAgentWithWorkDir) SetWorkDir(dir string) {
|
||||
m.Called(dir)
|
||||
}
|
||||
|
||||
func (m *MockAgentWithWorkDir) GetWorkDir() string {
|
||||
args := m.Called()
|
||||
return args.String(0)
|
||||
}
|
||||
|
||||
// MockAgentWithSkill is a mock agent that also implements SkillProvider.
|
||||
type MockAgentWithSkill struct {
|
||||
*MockAgent
|
||||
}
|
||||
|
||||
func (m *MockAgentWithSkill) SkillDirs() []string {
|
||||
args := m.Called()
|
||||
return args.Get(0).([]string)
|
||||
}
|
||||
|
||||
// MockAgentWithCommand is a mock agent that also implements CommandProvider.
|
||||
type MockAgentWithCommand struct {
|
||||
*MockAgent
|
||||
}
|
||||
|
||||
func (m *MockAgentWithCommand) CommandDirs() []string {
|
||||
args := m.Called()
|
||||
return args.Get(0).([]string)
|
||||
}
|
||||
|
||||
// MockAgentWithContextCompressor is a mock agent that also implements ContextCompressor.
|
||||
type MockAgentWithContextCompressor struct {
|
||||
*MockAgent
|
||||
}
|
||||
|
||||
func (m *MockAgentWithContextCompressor) CompressCommand() string {
|
||||
args := m.Called()
|
||||
return args.String(0)
|
||||
}
|
||||
|
||||
// MockAgentWithReasoning is a mock agent that also implements ReasoningEffortSwitcher.
|
||||
type MockAgentWithReasoning struct {
|
||||
*MockAgent
|
||||
}
|
||||
|
||||
func (m *MockAgentWithReasoning) SetReasoningEffort(effort string) {
|
||||
m.Called(effort)
|
||||
}
|
||||
|
||||
func (m *MockAgentWithReasoning) GetReasoningEffort() string {
|
||||
args := m.Called()
|
||||
return args.String(0)
|
||||
}
|
||||
|
||||
func (m *MockAgentWithReasoning) AvailableReasoningEfforts() []string {
|
||||
args := m.Called()
|
||||
return args.Get(0).([]string)
|
||||
}
|
||||
|
||||
// MockAgentWithSessionDeleter is a mock agent that also implements SessionDeleter.
|
||||
type MockAgentWithSessionDeleter struct {
|
||||
*MockAgent
|
||||
}
|
||||
|
||||
func (m *MockAgentWithSessionDeleter) DeleteSession(ctx context.Context, sessionID string) error {
|
||||
args := m.Called(ctx, sessionID)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
// MockAgentWithSystemPrompt is a mock agent that also implements SystemPromptSupporter.
|
||||
type MockAgentWithSystemPrompt struct {
|
||||
*MockAgent
|
||||
}
|
||||
|
||||
func (m *MockAgentWithSystemPrompt) HasSystemPromptSupport() bool {
|
||||
args := m.Called()
|
||||
return args.Bool(0)
|
||||
}
|
||||
|
||||
// MockAgentWithPlatformPrompt is a mock agent that also implements PlatformPromptInjector.
|
||||
type MockAgentWithPlatformPrompt struct {
|
||||
*MockAgent
|
||||
}
|
||||
|
||||
func (m *MockAgentWithPlatformPrompt) SetPlatformPrompt(prompt string) {
|
||||
m.Called(prompt)
|
||||
}
|
||||
|
||||
// MockAgentWithSessionEnv is a mock agent that also implements SessionEnvInjector.
|
||||
type MockAgentWithSessionEnv struct {
|
||||
*MockAgent
|
||||
}
|
||||
|
||||
func (m *MockAgentWithSessionEnv) SetSessionEnv(env []string) {
|
||||
m.Called(env)
|
||||
}
|
||||
|
||||
// MockAgentFull implements all optional interfaces for comprehensive testing.
|
||||
type MockAgentFull struct {
|
||||
*MockAgent
|
||||
*MockAgentWithProviders
|
||||
*MockAgentWithModel
|
||||
*MockAgentWithMode
|
||||
*MockAgentWithToolAuth
|
||||
*MockAgentWithHistory
|
||||
*MockAgentWithUsage
|
||||
*MockAgentWithMemory
|
||||
*MockAgentWithWorkDir
|
||||
*MockAgentWithSkill
|
||||
*MockAgentWithCommand
|
||||
*MockAgentWithContextCompressor
|
||||
*MockAgentWithReasoning
|
||||
*MockAgentWithSessionDeleter
|
||||
*MockAgentWithSystemPrompt
|
||||
*MockAgentWithPlatformPrompt
|
||||
*MockAgentWithSessionEnv
|
||||
}
|
||||
|
||||
func NewMockAgentFull(name string) *MockAgentFull {
|
||||
m := &MockAgentFull{
|
||||
MockAgent: new(MockAgent),
|
||||
MockAgentWithProviders: new(MockAgentWithProviders),
|
||||
MockAgentWithModel: new(MockAgentWithModel),
|
||||
MockAgentWithMode: new(MockAgentWithMode),
|
||||
MockAgentWithToolAuth: new(MockAgentWithToolAuth),
|
||||
MockAgentWithHistory: new(MockAgentWithHistory),
|
||||
MockAgentWithUsage: new(MockAgentWithUsage),
|
||||
MockAgentWithMemory: new(MockAgentWithMemory),
|
||||
MockAgentWithWorkDir: new(MockAgentWithWorkDir),
|
||||
MockAgentWithSkill: new(MockAgentWithSkill),
|
||||
MockAgentWithCommand: new(MockAgentWithCommand),
|
||||
MockAgentWithContextCompressor: new(MockAgentWithContextCompressor),
|
||||
MockAgentWithReasoning: new(MockAgentWithReasoning),
|
||||
MockAgentWithSessionDeleter: new(MockAgentWithSessionDeleter),
|
||||
MockAgentWithSystemPrompt: new(MockAgentWithSystemPrompt),
|
||||
MockAgentWithPlatformPrompt: new(MockAgentWithPlatformPrompt),
|
||||
MockAgentWithSessionEnv: new(MockAgentWithSessionEnv),
|
||||
}
|
||||
m.MockAgent.On("Name").Return(name)
|
||||
return m
|
||||
}
|
||||
|
||||
// EventIterator is a helper for simulating agent events in tests.
|
||||
type EventIterator struct {
|
||||
events []core.Event
|
||||
index int
|
||||
}
|
||||
|
||||
func NewEventIterator(events []core.Event) *EventIterator {
|
||||
return &EventIterator{events: events, index: 0}
|
||||
}
|
||||
|
||||
func (e *EventIterator) Next() (core.Event, bool) {
|
||||
if e.index >= len(e.events) {
|
||||
return core.Event{}, false
|
||||
}
|
||||
event := e.events[e.index]
|
||||
e.index++
|
||||
return event, true
|
||||
}
|
||||
|
||||
func (e *EventIterator) EventChannel() <-chan core.Event {
|
||||
ch := make(chan core.Event, len(e.events))
|
||||
for _, event := range e.events {
|
||||
ch <- event
|
||||
}
|
||||
close(ch)
|
||||
return ch
|
||||
}
|
||||
|
||||
// NewMockAgentSessionWithEvents creates a mock session that emits predefined events.
|
||||
func NewMockAgentSessionWithEvents(sessionID string, events []core.Event) *MockAgentSession {
|
||||
m := new(MockAgentSession)
|
||||
m.On("CurrentSessionID").Return(sessionID)
|
||||
m.On("Alive").Return(true)
|
||||
m.On("Events").Return(NewEventIterator(events).EventChannel())
|
||||
m.On("Close").Return(nil)
|
||||
m.On("Send", mock.Anything, mock.Anything, mock.Anything).Return(nil)
|
||||
return m
|
||||
}
|
||||
|
||||
// MockEventReader implements io.Reader for testing streaming scenarios.
|
||||
type MockEventReader struct {
|
||||
events []core.Event
|
||||
index int
|
||||
}
|
||||
|
||||
func NewMockEventReader(events []core.Event) *MockEventReader {
|
||||
return &MockEventReader{events: events, index: 0}
|
||||
}
|
||||
|
||||
func (r *MockEventReader) Read(p []byte) (n int, err error) {
|
||||
if r.index >= len(r.events) {
|
||||
return 0, io.EOF
|
||||
}
|
||||
event := r.events[r.index]
|
||||
r.index++
|
||||
data := []byte(event.Content)
|
||||
copy(p, data)
|
||||
return len(data), nil
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// Package mocks provides mock implementations for testing cc-connect components.
|
||||
package mocks
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/chenhg5/cc-connect/core"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
// MockPlatform is a mock implementation of the core.Platform interface.
|
||||
type MockPlatform struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *MockPlatform) Name() string {
|
||||
args := m.Called()
|
||||
return args.String(0)
|
||||
}
|
||||
|
||||
func (m *MockPlatform) Start(handler core.MessageHandler) error {
|
||||
args := m.Called(handler)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockPlatform) Reply(ctx context.Context, replyCtx any, content string) error {
|
||||
args := m.Called(ctx, replyCtx, content)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockPlatform) Send(ctx context.Context, replyCtx any, content string) error {
|
||||
args := m.Called(ctx, replyCtx, content)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockPlatform) Stop() error {
|
||||
args := m.Called()
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
// MockPlatformWithReplyCtxReconstructor is a mock platform that also implements
|
||||
// ReplyContextReconstructor for testing cron job scenarios.
|
||||
type MockPlatformWithReplyCtxReconstructor struct {
|
||||
*MockPlatform
|
||||
}
|
||||
|
||||
func (m *MockPlatformWithReplyCtxReconstructor) ReconstructReplyCtx(sessionKey string) (any, error) {
|
||||
args := m.Called(sessionKey)
|
||||
return args.Get(0), args.Error(1)
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
//go:build performance
|
||||
|
||||
// Package performance contains benchmark tests for cc-connect.
|
||||
// These tests measure latency, throughput, and resource usage.
|
||||
//
|
||||
// Run with: go test -bench=. -benchmem -tags=performance ./tests/performance/...
|
||||
package performance
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/chenhg5/cc-connect/core"
|
||||
"github.com/chenhg5/cc-connect/tests/mocks/fake"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// T-400: Single Message Latency
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func Benchmark_SingleMessageLatency(b *testing.B) {
|
||||
ctx := context.Background()
|
||||
agent := fake.NewFakeAgent("bench-agent")
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
sess, _ := agent.StartSession(ctx, "bench-session")
|
||||
sess.Send("Hello", nil, nil)
|
||||
for e := range sess.Events() {
|
||||
if e.Done {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// T-401: Concurrent Throughput
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func Benchmark_ConcurrentThroughput(b *testing.B) {
|
||||
ctx := context.Background()
|
||||
numAgents := 10
|
||||
|
||||
// Create multiple agents with sessions
|
||||
agents := make([]struct {
|
||||
agent *fake.FakeAgent
|
||||
sess core.AgentSession
|
||||
}, numAgents)
|
||||
for i := 0; i < numAgents; i++ {
|
||||
agent := fake.NewFakeAgent("bench-agent")
|
||||
sess, _ := agent.StartSession(ctx, "bench-session")
|
||||
agents[i] = struct {
|
||||
agent *fake.FakeAgent
|
||||
sess core.AgentSession
|
||||
}{agent: agent, sess: sess}
|
||||
}
|
||||
|
||||
var totalMessages int64
|
||||
|
||||
b.ResetTimer()
|
||||
b.RunParallel(func(pb *testing.PB) {
|
||||
idx := 0
|
||||
for pb.Next() {
|
||||
a := &agents[idx%numAgents]
|
||||
a.sess.Send("Concurrent message", nil, nil)
|
||||
atomic.AddInt64(&totalMessages, 1)
|
||||
idx++
|
||||
}
|
||||
})
|
||||
|
||||
b.StopTimer()
|
||||
msgsPerSec := float64(totalMessages) / b.Elapsed().Seconds()
|
||||
b.ReportMetric(msgsPerSec, "msgs/sec")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// T-402: Session Switch Latency
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func Benchmark_SessionSwitch(b *testing.B) {
|
||||
ctx := context.Background()
|
||||
agent := fake.NewFakeAgent("bench-agent")
|
||||
|
||||
// Pre-create multiple sessions
|
||||
const numSessions = 10
|
||||
sessions := make([]core.AgentSession, numSessions)
|
||||
for i := 0; i < numSessions; i++ {
|
||||
sess, _ := agent.StartSession(ctx, "session-switch")
|
||||
sessions[i] = sess
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
sess := sessions[i%numSessions]
|
||||
sess.Send("Switch test", nil, nil)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// T-403: Memory Usage During Message Processing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func Benchmark_MemoryUsage(b *testing.B) {
|
||||
ctx := context.Background()
|
||||
agent := fake.NewFakeAgent("bench-agent")
|
||||
sess, _ := agent.StartSession(ctx, "bench-session")
|
||||
|
||||
b.ResetTimer()
|
||||
b.ReportMetric(float64(b.N)*32, "bytes/op") // baseline estimate
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
sess.Send("Memory test message", nil, nil)
|
||||
// Consume events
|
||||
for e := range sess.Events() {
|
||||
if e.Done {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// T-404: Rate Limiter Performance
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func Benchmark_RateLimiter(b *testing.B) {
|
||||
rl := core.NewRateLimiter(1000, time.Second)
|
||||
defer rl.Stop()
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
rl.Allow("bench-user")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// T-405: Message Deduplication Performance
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func Benchmark_MessageDedup(b *testing.B) {
|
||||
dedup := &core.MessageDedup{}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
msgID := "bench-msg"
|
||||
dedup.IsDuplicate(msgID)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// T-406: Command Registry Lookup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func Benchmark_CommandRegistryLookup(b *testing.B) {
|
||||
registry := core.NewCommandRegistry()
|
||||
|
||||
// Add commands
|
||||
for i := 0; i < 50; i++ {
|
||||
registry.Add("cmd", "Command", "{{1}}", "", "", "bench")
|
||||
}
|
||||
registry.Add("target", "Target command", "{{1}}", "", "", "bench")
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
registry.Resolve("target")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// T-407: Card Rendering Performance
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func Benchmark_CardRendering(b *testing.B) {
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
card := core.NewCard().
|
||||
Title("Benchmark Card", "blue").
|
||||
Markdown("## Section\nSome content here.").
|
||||
Markdown("- Item 1\n- Item 2\n- Item 3").
|
||||
Divider().
|
||||
Buttons(
|
||||
core.PrimaryBtn("Confirm", "act:/confirm"),
|
||||
core.DefaultBtn("Cancel", "act:/cancel"),
|
||||
core.DangerBtn("Delete", "act:/delete"),
|
||||
).
|
||||
Note("Footnote text").
|
||||
Build()
|
||||
|
||||
_ = card.RenderText()
|
||||
_ = card.CollectButtons()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// T-408: Cron Store Operations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func Benchmark_CronStoreOperations(b *testing.B) {
|
||||
tmpDir := b.TempDir()
|
||||
store, _ := core.NewCronStore(tmpDir)
|
||||
|
||||
// Pre-populate
|
||||
for i := 0; i < 20; i++ {
|
||||
store.Add(&core.CronJob{
|
||||
ID: "bench-cron",
|
||||
Description: "Bench",
|
||||
Prompt: "Run",
|
||||
CronExpr: "0 9 * * *",
|
||||
Enabled: true,
|
||||
})
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
jobs := store.List()
|
||||
_ = len(jobs)
|
||||
store.SetEnabled("bench-cron", true)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// T-409: Session Creation Overhead
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func Benchmark_SessionCreation(b *testing.B) {
|
||||
ctx := context.Background()
|
||||
agent := fake.NewFakeAgent("bench-agent")
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, _ = agent.StartSession(ctx, "bench-session")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// T-410: Session Send/Receive Overhead
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func Benchmark_SessionSendReceive(b *testing.B) {
|
||||
ctx := context.Background()
|
||||
agent := fake.NewFakeAgent("bench-agent")
|
||||
sess, _ := agent.StartSession(ctx, "bench-session")
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
sess.Send("Bench message", nil, nil)
|
||||
for e := range sess.Events() {
|
||||
if e.Done {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// T-411: Role Manager Resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func Benchmark_RoleManagerResolution(b *testing.B) {
|
||||
manager := core.NewUserRoleManager()
|
||||
manager.Configure("viewer", []core.RoleInput{
|
||||
{Name: "admin", UserIDs: []string{"admin1", "admin2"}},
|
||||
{Name: "developer", UserIDs: []string{"dev1", "dev2"}},
|
||||
{Name: "viewer", UserIDs: []string{"*"}},
|
||||
})
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
manager.ResolveRole("admin1")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// T-412: Concurrent Rate Limiter Access
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func Benchmark_ConcurrentRateLimiter(b *testing.B) {
|
||||
rl := core.NewRateLimiter(10000, time.Second)
|
||||
defer rl.Stop()
|
||||
|
||||
var counter int64
|
||||
b.ResetTimer()
|
||||
b.RunParallel(func(pb *testing.PB) {
|
||||
for pb.Next() {
|
||||
if rl.Allow("bench-user") {
|
||||
atomic.AddInt64(&counter, 1)
|
||||
}
|
||||
}
|
||||
})
|
||||
b.ReportMetric(float64(counter), "allowed")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// T-413: Multi-Agent Coordination
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func Benchmark_MultiAgentCoordination(b *testing.B) {
|
||||
ctx := context.Background()
|
||||
numAgents := 5
|
||||
|
||||
agents := make([]*fake.FakeAgent, numAgents)
|
||||
for i := 0; i < numAgents; i++ {
|
||||
agents[i] = fake.NewFakeAgent("bench-agent")
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
var wg sync.WaitGroup
|
||||
for j := 0; j < numAgents; j++ {
|
||||
wg.Add(1)
|
||||
go func(a *fake.FakeAgent) {
|
||||
defer wg.Done()
|
||||
sess, _ := a.StartSession(ctx, "coord-session")
|
||||
sess.Send("Coordinated message", nil, nil)
|
||||
}(agents[j])
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
package config_matrix
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/chenhg5/cc-connect/config"
|
||||
)
|
||||
|
||||
func writeConfig(t *testing.T, body string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "config.toml")
|
||||
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func baseProjectTOML(extra string) string {
|
||||
return `
|
||||
data_dir = "` + filepath.ToSlash(os.TempDir()) + `/cc-connect-release-test"
|
||||
` + extra + `
|
||||
|
||||
[[projects]]
|
||||
name = "release"
|
||||
|
||||
[projects.agent]
|
||||
type = "claudecode"
|
||||
work_dir = "/tmp/cc-connect-release-work"
|
||||
|
||||
[[projects.platforms]]
|
||||
type = "feishu"
|
||||
app_id = "cli_release"
|
||||
app_secret = "secret"
|
||||
`
|
||||
}
|
||||
|
||||
func TestReleaseConfig_ProjectDisplayOverridesGlobalFromLoadedConfig(t *testing.T) {
|
||||
path := writeConfig(t, `
|
||||
attachment_send = "off"
|
||||
|
||||
[display]
|
||||
mode = "quiet"
|
||||
card_mode = "rich"
|
||||
thinking_messages = true
|
||||
tool_messages = false
|
||||
|
||||
[[projects]]
|
||||
name = "release"
|
||||
reset_on_idle_mins = 0
|
||||
|
||||
[projects.display]
|
||||
mode = "full"
|
||||
card_mode = "legacy"
|
||||
thinking_messages = false
|
||||
tool_messages = true
|
||||
thinking_max_len = 111
|
||||
tool_max_len = 222
|
||||
|
||||
[projects.agent]
|
||||
type = "claudecode"
|
||||
work_dir = "/tmp/cc-connect-release-work"
|
||||
|
||||
[[projects.platforms]]
|
||||
type = "feishu"
|
||||
app_id = "cli_release"
|
||||
app_secret = "secret"
|
||||
`)
|
||||
|
||||
cfg, err := config.Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
if cfg.AttachmentSend != "off" {
|
||||
t.Fatalf("AttachmentSend = %q, want off", cfg.AttachmentSend)
|
||||
}
|
||||
if len(cfg.Projects) != 1 {
|
||||
t.Fatalf("projects = %d, want 1", len(cfg.Projects))
|
||||
}
|
||||
proj := &cfg.Projects[0]
|
||||
if proj.ResetOnIdleMins == nil || *proj.ResetOnIdleMins != 0 {
|
||||
t.Fatalf("ResetOnIdleMins = %#v, want explicit 0", proj.ResetOnIdleMins)
|
||||
}
|
||||
|
||||
mode, thinking, tools, thinkingMax, toolMax, _, _ := config.EffectiveDisplay(cfg, proj)
|
||||
if mode != config.DisplayModeFull {
|
||||
t.Fatalf("mode = %q, want project full override", mode)
|
||||
}
|
||||
if thinking {
|
||||
t.Fatal("thinking_messages = true, want project override false")
|
||||
}
|
||||
if !tools {
|
||||
t.Fatal("tool_messages = false, want project override true")
|
||||
}
|
||||
if thinkingMax != 111 || toolMax != 222 {
|
||||
t.Fatalf("max lens = %d/%d, want 111/222", thinkingMax, toolMax)
|
||||
}
|
||||
if got := config.EffectiveCardMode(cfg, proj); got != "legacy" {
|
||||
t.Fatalf("card mode = %q, want project legacy override", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseConfig_DefaultsKeepAttachmentsAndFullDisplayEnabled(t *testing.T) {
|
||||
path := writeConfig(t, baseProjectTOML(""))
|
||||
cfg, err := config.Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
if cfg.AttachmentSend != "on" {
|
||||
t.Fatalf("AttachmentSend = %q, want default on", cfg.AttachmentSend)
|
||||
}
|
||||
mode, thinking, tools, _, _, _, _ := config.EffectiveDisplay(cfg, &cfg.Projects[0])
|
||||
if mode != config.DisplayModeFull || !thinking || !tools {
|
||||
t.Fatalf("display = mode:%s thinking:%v tools:%v, want full/true/true", mode, thinking, tools)
|
||||
}
|
||||
if got := config.EffectiveCardMode(cfg, &cfg.Projects[0]); got != "legacy" {
|
||||
t.Fatalf("card mode = %q, want default legacy", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseConfig_BehaviorControlSwitchesParseFromLoadedConfig(t *testing.T) {
|
||||
path := writeConfig(t, `
|
||||
[stream_preview]
|
||||
enabled = false
|
||||
disabled_platforms = ["feishu", "telegram"]
|
||||
interval_ms = 250
|
||||
min_delta_chars = 12
|
||||
max_chars = 777
|
||||
|
||||
[[projects]]
|
||||
name = "release"
|
||||
show_context_indicator = false
|
||||
reply_footer = false
|
||||
disabled_commands = ["restart", "shell"]
|
||||
|
||||
[projects.display]
|
||||
mode = "quiet"
|
||||
card_mode = "rich"
|
||||
thinking_messages = false
|
||||
tool_messages = false
|
||||
|
||||
[projects.agent]
|
||||
type = "claudecode"
|
||||
work_dir = "/tmp/cc-connect-release-work"
|
||||
|
||||
[[projects.platforms]]
|
||||
type = "feishu"
|
||||
app_id = "cli_release"
|
||||
app_secret = "secret"
|
||||
`)
|
||||
|
||||
cfg, err := config.Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
if cfg.StreamPreview.Enabled == nil || *cfg.StreamPreview.Enabled {
|
||||
t.Fatalf("stream_preview.enabled = %#v, want false", cfg.StreamPreview.Enabled)
|
||||
}
|
||||
if got := strings.Join(cfg.StreamPreview.DisabledPlatforms, ","); got != "feishu,telegram" {
|
||||
t.Fatalf("stream_preview.disabled_platforms = %#v", cfg.StreamPreview.DisabledPlatforms)
|
||||
}
|
||||
if cfg.StreamPreview.IntervalMs == nil || *cfg.StreamPreview.IntervalMs != 250 {
|
||||
t.Fatalf("stream_preview.interval_ms = %#v, want 250", cfg.StreamPreview.IntervalMs)
|
||||
}
|
||||
if cfg.StreamPreview.MinDeltaChars == nil || *cfg.StreamPreview.MinDeltaChars != 12 {
|
||||
t.Fatalf("stream_preview.min_delta_chars = %#v, want 12", cfg.StreamPreview.MinDeltaChars)
|
||||
}
|
||||
if cfg.StreamPreview.MaxChars == nil || *cfg.StreamPreview.MaxChars != 777 {
|
||||
t.Fatalf("stream_preview.max_chars = %#v, want 777", cfg.StreamPreview.MaxChars)
|
||||
}
|
||||
|
||||
proj := &cfg.Projects[0]
|
||||
if proj.ShowContextIndicator == nil || *proj.ShowContextIndicator {
|
||||
t.Fatalf("show_context_indicator = %#v, want false", proj.ShowContextIndicator)
|
||||
}
|
||||
if proj.ReplyFooter == nil || *proj.ReplyFooter {
|
||||
t.Fatalf("reply_footer = %#v, want false", proj.ReplyFooter)
|
||||
}
|
||||
if strings.Join(proj.DisabledCommands, ",") != "restart,shell" {
|
||||
t.Fatalf("disabled_commands = %#v", proj.DisabledCommands)
|
||||
}
|
||||
mode, thinking, tools, _, _, _, _ := config.EffectiveDisplay(cfg, proj)
|
||||
if mode != config.DisplayModeQuiet || thinking || tools {
|
||||
t.Fatalf("display = mode:%s thinking:%v tools:%v, want quiet/false/false", mode, thinking, tools)
|
||||
}
|
||||
if got := config.EffectiveCardMode(cfg, proj); got != "rich" {
|
||||
t.Fatalf("card mode = %q, want rich", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseConfig_InvalidCriticalOptionsFailFast(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
toml string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "invalid attachment send",
|
||||
toml: baseProjectTOML(`
|
||||
attachment_send = "maybe"
|
||||
`),
|
||||
wantErr: `attachment_send must be "on" or "off"`,
|
||||
},
|
||||
{
|
||||
name: "invalid project display mode",
|
||||
toml: `
|
||||
[[projects]]
|
||||
name = "release"
|
||||
|
||||
[projects.display]
|
||||
mode = "verbose"
|
||||
|
||||
[projects.agent]
|
||||
type = "claudecode"
|
||||
work_dir = "/tmp/cc-connect-release-work"
|
||||
|
||||
[[projects.platforms]]
|
||||
type = "feishu"
|
||||
app_id = "cli_release"
|
||||
app_secret = "secret"
|
||||
`,
|
||||
wantErr: `projects[0].display.mode must be "full", "compact", or "quiet"`,
|
||||
},
|
||||
{
|
||||
name: "negative reset on idle",
|
||||
toml: `
|
||||
[[projects]]
|
||||
name = "release"
|
||||
reset_on_idle_mins = -1
|
||||
|
||||
[projects.agent]
|
||||
type = "claudecode"
|
||||
work_dir = "/tmp/cc-connect-release-work"
|
||||
|
||||
[[projects.platforms]]
|
||||
type = "feishu"
|
||||
app_id = "cli_release"
|
||||
app_secret = "secret"
|
||||
`,
|
||||
wantErr: "reset_on_idle_mins must be >= 0",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := config.Load(writeConfig(t, tt.toml))
|
||||
if err == nil {
|
||||
t.Fatal("Load() error = nil, want validation error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("Load() error = %q, want contains %q", err.Error(), tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
package engine_matrix
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/chenhg5/cc-connect/core"
|
||||
)
|
||||
|
||||
type promptRecord struct {
|
||||
sessionID string
|
||||
prompt string
|
||||
}
|
||||
|
||||
type matrixAgent struct {
|
||||
mu sync.Mutex
|
||||
sessions []*matrixSession
|
||||
list []core.AgentSessionInfo
|
||||
records []promptRecord
|
||||
}
|
||||
|
||||
func newMatrixAgent() *matrixAgent {
|
||||
return &matrixAgent{}
|
||||
}
|
||||
|
||||
func (a *matrixAgent) Name() string { return "matrix-agent" }
|
||||
|
||||
func (a *matrixAgent) StartSession(_ context.Context, sessionID string) (core.AgentSession, error) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if sessionID == "" {
|
||||
sessionID = fmt.Sprintf("agent-session-%d", len(a.sessions)+1)
|
||||
}
|
||||
session := &matrixSession{agent: a, id: sessionID, alive: true, events: make(chan core.Event, 32)}
|
||||
a.sessions = append(a.sessions, session)
|
||||
a.list = append(a.list, core.AgentSessionInfo{
|
||||
ID: sessionID,
|
||||
Summary: "release matrix session",
|
||||
MessageCount: 1,
|
||||
ModifiedAt: time.Now(),
|
||||
})
|
||||
return session, nil
|
||||
}
|
||||
|
||||
func (a *matrixAgent) ListSessions(_ context.Context) ([]core.AgentSessionInfo, error) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
return append([]core.AgentSessionInfo(nil), a.list...), nil
|
||||
}
|
||||
|
||||
func (a *matrixAgent) Stop() error {
|
||||
a.mu.Lock()
|
||||
sessions := append([]*matrixSession(nil), a.sessions...)
|
||||
a.mu.Unlock()
|
||||
for _, session := range sessions {
|
||||
_ = session.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *matrixAgent) addRecord(sessionID, prompt string) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
a.records = append(a.records, promptRecord{sessionID: sessionID, prompt: prompt})
|
||||
}
|
||||
|
||||
func (a *matrixAgent) waitRecords(t *testing.T, n int) []promptRecord {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
a.mu.Lock()
|
||||
if len(a.records) >= n {
|
||||
out := append([]promptRecord(nil), a.records...)
|
||||
a.mu.Unlock()
|
||||
return out
|
||||
}
|
||||
a.mu.Unlock()
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
t.Fatalf("timeout waiting for %d prompts, got %d: %#v", n, len(a.records), a.records)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *matrixAgent) recordCount() int {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
return len(a.records)
|
||||
}
|
||||
|
||||
type matrixSession struct {
|
||||
mu sync.Mutex
|
||||
agent *matrixAgent
|
||||
id string
|
||||
alive bool
|
||||
events chan core.Event
|
||||
counter int
|
||||
}
|
||||
|
||||
func (s *matrixSession) Send(prompt string, _ []core.ImageAttachment, _ []core.FileAttachment) error {
|
||||
s.mu.Lock()
|
||||
id := s.id
|
||||
s.counter++
|
||||
count := s.counter
|
||||
s.mu.Unlock()
|
||||
|
||||
s.agent.addRecord(id, prompt)
|
||||
s.events <- core.Event{Type: core.EventResult, Content: "matrix response " + id + " #" + strconv.Itoa(count), Done: true}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *matrixSession) Events() <-chan core.Event { return s.events }
|
||||
func (s *matrixSession) RespondPermission(string, core.PermissionResult) error {
|
||||
return nil
|
||||
}
|
||||
func (s *matrixSession) CurrentSessionID() string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.id
|
||||
}
|
||||
func (s *matrixSession) Alive() bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.alive
|
||||
}
|
||||
func (s *matrixSession) Close() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if !s.alive {
|
||||
return nil
|
||||
}
|
||||
s.alive = false
|
||||
close(s.events)
|
||||
return nil
|
||||
}
|
||||
|
||||
type matrixPlatform struct {
|
||||
mu sync.Mutex
|
||||
texts []string
|
||||
}
|
||||
|
||||
func (p *matrixPlatform) Name() string { return "matrix" }
|
||||
func (p *matrixPlatform) Start(core.MessageHandler) error {
|
||||
return nil
|
||||
}
|
||||
func (p *matrixPlatform) Stop() error { return nil }
|
||||
func (p *matrixPlatform) Reply(_ context.Context, replyCtx any, content string) error {
|
||||
return p.Send(context.Background(), replyCtx, content)
|
||||
}
|
||||
func (p *matrixPlatform) Send(_ context.Context, _ any, content string) error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.texts = append(p.texts, content)
|
||||
return nil
|
||||
}
|
||||
func (p *matrixPlatform) clear() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.texts = nil
|
||||
}
|
||||
func (p *matrixPlatform) snapshot() []string {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return append([]string(nil), p.texts...)
|
||||
}
|
||||
func (p *matrixPlatform) waitTextContaining(t *testing.T, substr string) string {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
for _, text := range p.snapshot() {
|
||||
if strings.Contains(strings.ToLower(text), strings.ToLower(substr)) {
|
||||
return text
|
||||
}
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("timeout waiting for %q, got %#v", substr, p.snapshot())
|
||||
return ""
|
||||
}
|
||||
|
||||
func newMatrixEngine(t *testing.T) (*core.Engine, *matrixAgent, *matrixPlatform) {
|
||||
t.Helper()
|
||||
agent := newMatrixAgent()
|
||||
platform := &matrixPlatform{}
|
||||
engine := core.NewEngine("release-core", agent, []core.Platform{platform}, t.TempDir()+"/sessions.json", core.LangEnglish)
|
||||
t.Cleanup(func() {
|
||||
engine.Stop()
|
||||
_ = agent.Stop()
|
||||
})
|
||||
return engine, agent, platform
|
||||
}
|
||||
|
||||
func matrixMessage(content string) *core.Message {
|
||||
return &core.Message{
|
||||
SessionKey: "matrix:chat-1:user-1",
|
||||
Platform: "matrix",
|
||||
UserID: "user-1",
|
||||
UserName: "Release Tester",
|
||||
ChatName: "Release Room",
|
||||
Content: content,
|
||||
ReplyCtx: "reply-ctx",
|
||||
}
|
||||
}
|
||||
|
||||
func receive(engine *core.Engine, platform *matrixPlatform, content string) {
|
||||
engine.ReceiveMessage(platform, matrixMessage(content))
|
||||
}
|
||||
|
||||
func TestSessionLifecycleCommandsThroughReceiveMessage(t *testing.T) {
|
||||
engine, agent, platform := newMatrixEngine(t)
|
||||
|
||||
receive(engine, platform, "first user turn")
|
||||
agent.waitRecords(t, 1)
|
||||
platform.waitTextContaining(t, "matrix response")
|
||||
platform.clear()
|
||||
|
||||
receive(engine, platform, "/new release-named")
|
||||
platform.waitTextContaining(t, "release-named")
|
||||
platform.clear()
|
||||
|
||||
receive(engine, platform, "second user turn")
|
||||
agent.waitRecords(t, 2)
|
||||
platform.waitTextContaining(t, "matrix response")
|
||||
platform.clear()
|
||||
|
||||
receive(engine, platform, "/list")
|
||||
platform.waitTextContaining(t, "release-named")
|
||||
platform.clear()
|
||||
|
||||
receive(engine, platform, "/name renamed-release")
|
||||
platform.waitTextContaining(t, "renamed-release")
|
||||
platform.clear()
|
||||
|
||||
receive(engine, platform, "/list")
|
||||
platform.waitTextContaining(t, "renamed-release")
|
||||
platform.clear()
|
||||
|
||||
receive(engine, platform, "/current")
|
||||
platform.waitTextContaining(t, "session")
|
||||
platform.clear()
|
||||
|
||||
receive(engine, platform, "/status")
|
||||
platform.waitTextContaining(t, "user-1")
|
||||
}
|
||||
|
||||
func TestAliasDisabledCommandAndBannedWordsThroughReceiveMessage(t *testing.T) {
|
||||
engine, agent, platform := newMatrixEngine(t)
|
||||
engine.AddAlias("帮助", "/whoami")
|
||||
|
||||
receive(engine, platform, "帮助")
|
||||
platform.waitTextContaining(t, "user-1")
|
||||
if got := agent.recordCount(); got != 0 {
|
||||
t.Fatalf("alias to command should not reach agent, got %d prompts", got)
|
||||
}
|
||||
platform.clear()
|
||||
|
||||
engine.SetDisabledCommands([]string{"whoami"})
|
||||
receive(engine, platform, "/whoami")
|
||||
platform.waitTextContaining(t, "disabled")
|
||||
if got := agent.recordCount(); got != 0 {
|
||||
t.Fatalf("disabled command should not reach agent, got %d prompts", got)
|
||||
}
|
||||
platform.clear()
|
||||
|
||||
engine.SetBannedWords([]string{"forbidden"})
|
||||
receive(engine, platform, "this contains forbidden content")
|
||||
platform.waitTextContaining(t, "blocked")
|
||||
if got := agent.recordCount(); got != 0 {
|
||||
t.Fatalf("banned message should not reach agent, got %d prompts", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomPromptCommandThroughReceiveMessage(t *testing.T) {
|
||||
engine, agent, platform := newMatrixEngine(t)
|
||||
engine.AddCommand("daily", "Daily summary", "Summarize release status for {{1}}", "", "", "release-test")
|
||||
|
||||
receive(engine, platform, "/daily beta")
|
||||
|
||||
records := agent.waitRecords(t, 1)
|
||||
if !strings.Contains(records[0].prompt, "Summarize release status for beta") {
|
||||
t.Fatalf("custom command prompt = %q", records[0].prompt)
|
||||
}
|
||||
platform.waitTextContaining(t, "matrix response")
|
||||
}
|
||||
|
||||
func TestUnknownSlashCommandNotifiesThenFallsThroughToAgent(t *testing.T) {
|
||||
engine, agent, platform := newMatrixEngine(t)
|
||||
|
||||
receive(engine, platform, "/not-a-command keep this request")
|
||||
|
||||
platform.waitTextContaining(t, "forwarding")
|
||||
records := agent.waitRecords(t, 1)
|
||||
if !strings.Contains(records[0].prompt, "/not-a-command keep this request") {
|
||||
t.Fatalf("unknown slash command should fall through to agent, got prompt %q", records[0].prompt)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
package media_pipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/chenhg5/cc-connect/core"
|
||||
)
|
||||
|
||||
type sendRecord struct {
|
||||
prompt string
|
||||
images []core.ImageAttachment
|
||||
files []core.FileAttachment
|
||||
}
|
||||
|
||||
type recordingAgent struct {
|
||||
session *recordingSession
|
||||
}
|
||||
|
||||
func newRecordingAgent() *recordingAgent {
|
||||
return &recordingAgent{session: newRecordingSession()}
|
||||
}
|
||||
|
||||
func (a *recordingAgent) Name() string { return "recording-agent" }
|
||||
|
||||
func (a *recordingAgent) StartSession(_ context.Context, sessionID string) (core.AgentSession, error) {
|
||||
a.session.setID(sessionID)
|
||||
return a.session, nil
|
||||
}
|
||||
|
||||
func (a *recordingAgent) ListSessions(_ context.Context) ([]core.AgentSessionInfo, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (a *recordingAgent) Stop() error {
|
||||
return a.session.Close()
|
||||
}
|
||||
|
||||
type recordingSession struct {
|
||||
mu sync.Mutex
|
||||
id string
|
||||
alive bool
|
||||
records []sendRecord
|
||||
events chan core.Event
|
||||
blockFirst bool
|
||||
blocked bool
|
||||
}
|
||||
|
||||
func newRecordingSession() *recordingSession {
|
||||
return &recordingSession{alive: true, events: make(chan core.Event, 16)}
|
||||
}
|
||||
|
||||
func (s *recordingSession) setID(id string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.id = id
|
||||
}
|
||||
|
||||
func (s *recordingSession) blockFirstResult() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.blockFirst = true
|
||||
}
|
||||
|
||||
func (s *recordingSession) Send(prompt string, images []core.ImageAttachment, files []core.FileAttachment) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if !s.alive {
|
||||
return errors.New("session closed")
|
||||
}
|
||||
rec := sendRecord{
|
||||
prompt: prompt,
|
||||
images: append([]core.ImageAttachment(nil), images...),
|
||||
files: append([]core.FileAttachment(nil), files...),
|
||||
}
|
||||
s.records = append(s.records, rec)
|
||||
if !(s.blockFirst && len(s.records) == 1) {
|
||||
s.events <- core.Event{Type: core.EventResult, Content: "media ok", Done: true}
|
||||
} else {
|
||||
s.blocked = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *recordingSession) Events() <-chan core.Event {
|
||||
return s.events
|
||||
}
|
||||
|
||||
func (s *recordingSession) RespondPermission(string, core.PermissionResult) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *recordingSession) CurrentSessionID() string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.id
|
||||
}
|
||||
|
||||
func (s *recordingSession) Alive() bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.alive
|
||||
}
|
||||
|
||||
func (s *recordingSession) Close() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if !s.alive {
|
||||
return nil
|
||||
}
|
||||
s.alive = false
|
||||
close(s.events)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *recordingSession) releaseFirstResult(content string) {
|
||||
s.releaseFirstEvent(core.Event{Type: core.EventResult, Content: content, Done: true})
|
||||
}
|
||||
|
||||
func (s *recordingSession) releaseFirstEvent(event core.Event) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if !s.blocked {
|
||||
return
|
||||
}
|
||||
s.events <- event
|
||||
s.blocked = false
|
||||
}
|
||||
|
||||
func (s *recordingSession) waitRecords(t *testing.T, n int) []sendRecord {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
s.mu.Lock()
|
||||
if len(s.records) >= n {
|
||||
out := append([]sendRecord(nil), s.records...)
|
||||
s.mu.Unlock()
|
||||
return out
|
||||
}
|
||||
s.mu.Unlock()
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
t.Fatalf("timeout waiting for %d Send calls, got %d: %#v", n, len(s.records), s.records)
|
||||
return nil
|
||||
}
|
||||
|
||||
type mediaPlatform struct {
|
||||
mu sync.Mutex
|
||||
texts []string
|
||||
images []core.ImageAttachment
|
||||
files []core.FileAttachment
|
||||
replyCtx []any
|
||||
}
|
||||
|
||||
func (p *mediaPlatform) Name() string { return "media" }
|
||||
func (p *mediaPlatform) Start(core.MessageHandler) error {
|
||||
return nil
|
||||
}
|
||||
func (p *mediaPlatform) Stop() error { return nil }
|
||||
func (p *mediaPlatform) Reply(_ context.Context, replyCtx any, content string) error {
|
||||
return p.Send(context.Background(), replyCtx, content)
|
||||
}
|
||||
func (p *mediaPlatform) Send(_ context.Context, replyCtx any, content string) error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.texts = append(p.texts, content)
|
||||
p.replyCtx = append(p.replyCtx, replyCtx)
|
||||
return nil
|
||||
}
|
||||
func (p *mediaPlatform) SendImage(_ context.Context, replyCtx any, img core.ImageAttachment) error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.images = append(p.images, img)
|
||||
p.replyCtx = append(p.replyCtx, replyCtx)
|
||||
return nil
|
||||
}
|
||||
func (p *mediaPlatform) SendFile(_ context.Context, replyCtx any, file core.FileAttachment) error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.files = append(p.files, file)
|
||||
p.replyCtx = append(p.replyCtx, replyCtx)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *mediaPlatform) snapshot() (texts []string, images []core.ImageAttachment, files []core.FileAttachment, replyCtx []any) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return append([]string(nil), p.texts...),
|
||||
append([]core.ImageAttachment(nil), p.images...),
|
||||
append([]core.FileAttachment(nil), p.files...),
|
||||
append([]any(nil), p.replyCtx...)
|
||||
}
|
||||
|
||||
func (p *mediaPlatform) waitTextContaining(t *testing.T, substr string) string {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
texts, _, _, _ := p.snapshot()
|
||||
for _, text := range texts {
|
||||
if strings.Contains(strings.ToLower(text), strings.ToLower(substr)) {
|
||||
return text
|
||||
}
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
texts, _, _, _ := p.snapshot()
|
||||
t.Fatalf("timeout waiting for text containing %q, got %#v", substr, texts)
|
||||
return ""
|
||||
}
|
||||
|
||||
func newMediaEngine(t *testing.T) (*core.Engine, *recordingAgent, *mediaPlatform) {
|
||||
t.Helper()
|
||||
agent := newRecordingAgent()
|
||||
platform := &mediaPlatform{}
|
||||
engine := core.NewEngine("release-media", agent, []core.Platform{platform}, t.TempDir()+"/sessions.json", core.LangEnglish)
|
||||
t.Cleanup(func() {
|
||||
engine.Stop()
|
||||
_ = agent.Stop()
|
||||
})
|
||||
return engine, agent, platform
|
||||
}
|
||||
|
||||
func mediaMessage(content string) *core.Message {
|
||||
return &core.Message{
|
||||
SessionKey: "media:chat-1:user-1",
|
||||
Platform: "media",
|
||||
UserID: "user-1",
|
||||
UserName: "tester",
|
||||
Content: content,
|
||||
ReplyCtx: "reply-ctx-1",
|
||||
}
|
||||
}
|
||||
|
||||
func TestInboundImagesAndFilesReachAgentThroughEngine(t *testing.T) {
|
||||
engine, agent, platform := newMediaEngine(t)
|
||||
msg := mediaMessage("analyze these attachments")
|
||||
msg.Images = []core.ImageAttachment{{MimeType: "image/png", FileName: "screenshot.png", Data: []byte("png-bytes")}}
|
||||
msg.Files = []core.FileAttachment{{MimeType: "application/pdf", FileName: "spec.pdf", Data: []byte("%PDF")}}
|
||||
|
||||
engine.ReceiveMessage(platform, msg)
|
||||
|
||||
records := agent.session.waitRecords(t, 1)
|
||||
if !strings.Contains(records[0].prompt, "analyze these attachments") {
|
||||
t.Fatalf("prompt = %q, want user content", records[0].prompt)
|
||||
}
|
||||
if len(records[0].images) != 1 || records[0].images[0].FileName != "screenshot.png" || string(records[0].images[0].Data) != "png-bytes" {
|
||||
t.Fatalf("images not preserved: %#v", records[0].images)
|
||||
}
|
||||
if len(records[0].files) != 1 || records[0].files[0].FileName != "spec.pdf" || string(records[0].files[0].Data) != "%PDF" {
|
||||
t.Fatalf("files not preserved: %#v", records[0].files)
|
||||
}
|
||||
platform.waitTextContaining(t, "media ok")
|
||||
}
|
||||
|
||||
func TestAttachmentOnlyMessageReachesAgent(t *testing.T) {
|
||||
engine, agent, platform := newMediaEngine(t)
|
||||
msg := mediaMessage("")
|
||||
msg.Images = []core.ImageAttachment{{MimeType: "image/jpeg", FileName: "photo.jpg", Data: []byte("jpeg-bytes")}}
|
||||
|
||||
engine.ReceiveMessage(platform, msg)
|
||||
|
||||
records := agent.session.waitRecords(t, 1)
|
||||
if len(records[0].images) != 1 || records[0].images[0].MimeType != "image/jpeg" {
|
||||
t.Fatalf("attachment-only image not delivered: %#v", records[0].images)
|
||||
}
|
||||
platform.waitTextContaining(t, "media ok")
|
||||
}
|
||||
|
||||
func TestQueuedMessagePreservesFiles(t *testing.T) {
|
||||
engine, agent, platform := newMediaEngine(t)
|
||||
agent.session.blockFirstResult()
|
||||
|
||||
first := mediaMessage("start long task")
|
||||
engine.ReceiveMessage(platform, first)
|
||||
agent.session.waitRecords(t, 1)
|
||||
|
||||
queued := mediaMessage("please also inspect this file")
|
||||
queued.MessageID = "queued-msg"
|
||||
queued.Files = []core.FileAttachment{{MimeType: "text/plain", FileName: "queued.txt", Data: []byte("queued-file")}}
|
||||
engine.ReceiveMessage(platform, queued)
|
||||
|
||||
platform.waitTextContaining(t, "process after")
|
||||
agent.session.releaseFirstResult("first done")
|
||||
|
||||
records := agent.session.waitRecords(t, 2)
|
||||
if !strings.Contains(records[1].prompt, "please also inspect this file") {
|
||||
t.Fatalf("queued prompt = %q", records[1].prompt)
|
||||
}
|
||||
if len(records[1].files) != 1 || records[1].files[0].FileName != "queued.txt" || string(records[1].files[0].Data) != "queued-file" {
|
||||
t.Fatalf("queued file not preserved: %#v", records[1].files)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendToSessionWithAttachmentsDeliversTextImagesAndFiles(t *testing.T) {
|
||||
engine, agent, platform := newMediaEngine(t)
|
||||
msg := mediaMessage("establish active session")
|
||||
engine.ReceiveMessage(platform, msg)
|
||||
agent.session.waitRecords(t, 1)
|
||||
platform.waitTextContaining(t, "media ok")
|
||||
|
||||
err := engine.SendToSessionWithAttachments(
|
||||
msg.SessionKey,
|
||||
"delivery ready",
|
||||
[]core.ImageAttachment{{MimeType: "image/png", FileName: "chart.png", Data: []byte("chart")}},
|
||||
[]core.FileAttachment{{MimeType: "text/plain", FileName: "report.txt", Data: []byte("report")}},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("SendToSessionWithAttachments() error = %v", err)
|
||||
}
|
||||
|
||||
texts, images, files, replyCtx := platform.snapshot()
|
||||
if !containsText(texts, "delivery ready") {
|
||||
t.Fatalf("texts = %#v, want delivery message", texts)
|
||||
}
|
||||
if len(images) != 1 || images[0].FileName != "chart.png" || string(images[0].Data) != "chart" {
|
||||
t.Fatalf("images = %#v", images)
|
||||
}
|
||||
if len(files) != 1 || files[0].FileName != "report.txt" || string(files[0].Data) != "report" {
|
||||
t.Fatalf("files = %#v", files)
|
||||
}
|
||||
for _, ctx := range replyCtx {
|
||||
if ctx != "reply-ctx-1" {
|
||||
t.Fatalf("reply context = %#v, want original reply context", replyCtx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendToSessionWithAttachmentsDoesNotDuplicateEchoedFinalTextWithContextIndicator(t *testing.T) {
|
||||
engine, agent, platform := newMediaEngine(t)
|
||||
agent.session.blockFirstResult()
|
||||
|
||||
msg := mediaMessage("start long task")
|
||||
engine.ReceiveMessage(platform, msg)
|
||||
agent.session.waitRecords(t, 1)
|
||||
|
||||
sideText := "delivery ready"
|
||||
err := engine.SendToSessionWithAttachments(
|
||||
msg.SessionKey,
|
||||
sideText,
|
||||
nil,
|
||||
[]core.FileAttachment{{MimeType: "text/plain", FileName: "report.txt", Data: []byte("report")}},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("SendToSessionWithAttachments() error = %v", err)
|
||||
}
|
||||
|
||||
agent.session.releaseFirstEvent(core.Event{
|
||||
Type: core.EventResult,
|
||||
Content: sideText,
|
||||
InputTokens: 52000,
|
||||
Done: true,
|
||||
})
|
||||
|
||||
deadline := time.Now().Add(300 * time.Millisecond)
|
||||
var lastTexts []string
|
||||
for time.Now().Before(deadline) {
|
||||
texts, _, _, _ := platform.snapshot()
|
||||
lastTexts = texts
|
||||
count := 0
|
||||
for _, text := range texts {
|
||||
if strings.Contains(text, sideText) {
|
||||
count++
|
||||
}
|
||||
if strings.Contains(text, "[ctx:") {
|
||||
t.Fatalf("unexpected duplicate context indicator reply: %#v", texts)
|
||||
}
|
||||
}
|
||||
if count > 1 {
|
||||
t.Fatalf("texts = %#v, want no duplicate delivery message", texts)
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
count := 0
|
||||
for _, text := range lastTexts {
|
||||
if strings.Contains(text, sideText) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("texts = %#v, want exactly one side-channel delivery message", lastTexts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendToSessionWithAttachmentsRespectsDisabledAttachmentSend(t *testing.T) {
|
||||
engine, agent, platform := newMediaEngine(t)
|
||||
msg := mediaMessage("establish active session")
|
||||
engine.ReceiveMessage(platform, msg)
|
||||
agent.session.waitRecords(t, 1)
|
||||
platform.waitTextContaining(t, "media ok")
|
||||
|
||||
engine.SetAttachmentSendEnabled(false)
|
||||
err := engine.SendToSessionWithAttachments(
|
||||
msg.SessionKey,
|
||||
"should not send",
|
||||
nil,
|
||||
[]core.FileAttachment{{MimeType: "text/plain", FileName: "blocked.txt", Data: []byte("blocked")}},
|
||||
)
|
||||
if !errors.Is(err, core.ErrAttachmentSendDisabled) {
|
||||
t.Fatalf("err = %v, want ErrAttachmentSendDisabled", err)
|
||||
}
|
||||
texts, images, files, _ := platform.snapshot()
|
||||
if containsText(texts, "should not send") || len(images) != 0 || len(files) != 0 {
|
||||
t.Fatalf("disabled attachment send leaked output: texts=%#v images=%#v files=%#v", texts, images, files)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendToSessionWithAttachmentsRequiresSessionWhenMultipleSessionsHaveAttachments(t *testing.T) {
|
||||
engine, agent, platform := newMediaEngine(t)
|
||||
first := mediaMessage("first")
|
||||
first.SessionKey = "media:chat-1:user-1"
|
||||
second := mediaMessage("second")
|
||||
second.SessionKey = "media:chat-1:user-2"
|
||||
second.ReplyCtx = "reply-ctx-2"
|
||||
|
||||
engine.ReceiveMessage(platform, first)
|
||||
engine.ReceiveMessage(platform, second)
|
||||
agent.session.waitRecords(t, 2)
|
||||
platform.waitTextContaining(t, "media ok")
|
||||
|
||||
err := engine.SendToSessionWithAttachments(
|
||||
"",
|
||||
"ambiguous",
|
||||
[]core.ImageAttachment{{MimeType: "image/png", FileName: "ambiguous.png", Data: []byte("img")}},
|
||||
nil,
|
||||
)
|
||||
if err == nil || !strings.Contains(err.Error(), "multiple active sessions") {
|
||||
t.Fatalf("err = %v, want multiple active sessions error", err)
|
||||
}
|
||||
texts, images, files, _ := platform.snapshot()
|
||||
if containsText(texts, "ambiguous") || len(images) != 0 || len(files) != 0 {
|
||||
t.Fatalf("ambiguous attachment send leaked output: texts=%#v images=%#v files=%#v", texts, images, files)
|
||||
}
|
||||
}
|
||||
|
||||
func containsText(texts []string, want string) bool {
|
||||
for _, text := range texts {
|
||||
if strings.Contains(text, want) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user