初始化仓库

This commit is contained in:
2026-06-02 23:14:41 +08:00
commit 0bc3f02670
520 changed files with 191097 additions and 0 deletions
+15
View File
@@ -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"
)
+443
View File
@@ -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 ""
}
+175
View File
@@ -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] + "…"
}
+120
View File
@@ -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")
}
+79
View File
@@ -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))
}
}
+12
View File
@@ -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"
)
+209
View File
@@ -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)
}
+12
View File
@@ -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"
)
+199
View File
@@ -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)
}
+407
View File
@@ -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))
}
+128
View File
@@ -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")
}
+336
View File
@@ -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
+561
View File
@@ -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
+33
View File
@@ -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
}
+450
View File
@@ -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")
}
+497
View File
@@ -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")
}
+427
View File
@@ -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) }
+228
View File
@@ -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
}
+151
View File
@@ -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)
}
+226
View File
@@ -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"},
},
}
}
+396
View File
@@ -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
}
+50
View File
@@ -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)
}
+323
View File
@@ -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