初始化仓库

This commit is contained in:
2026-06-02 23:14:41 +08:00
commit 0bc3f02670
520 changed files with 191097 additions and 0 deletions
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) }