361 lines
11 KiB
Go
361 lines
11 KiB
Go
//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)
|
|
}
|