Files
cc-connect/core/multi_workspace_test.go
T
Leigh StillardandClaude Opus 4.6 647398f765 feat: multi-workspace mode — single bot, multiple workspaces per channel (#79)
* feat: add multi-workspace mode and base_dir config fields

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add WorkspaceBindingManager for channel-to-workspace persistence

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add workspacePool for managing per-workspace agent state

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add ChannelNameResolver interface and Slack implementation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add multi-workspace fields, SetMultiWorkspace, and idle reaper to Engine

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add resolveWorkspace method for channel-to-directory mapping

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: wire multi-workspace mode setup in main.go

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add workspace init flow for cloning repos and binding channels

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add /workspace commands (init, unbind, list, status)

Add handleWorkspaceCommand with subcommands:
- (no args): show current workspace binding for the channel
- init <git-url>: clone repo and bind workspace to channel
- unbind: remove workspace binding from channel
- list: show all workspace bindings for the project

Only available when multi-workspace mode is enabled.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: wire multi-workspace routing into handleMessage with per-workspace agents

Add workspace resolution to handleMessage that resolves channels to workspace
directories, creates per-workspace agents with correct work_dir, and routes
messages through workspace-specific session managers. The normal (single-workspace)
code path is unchanged — all new logic is gated behind e.multiWorkspace.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* test: add integration tests for multi-workspace resolution

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: resolve multi-workspace bugs found during live testing

Three bugs fixed:

1. App mention events not handled - Slack sends `app_mention` events
   (not `message.channels`) when the bot is @mentioned. Added
   AppMentionEvent handler in the Slack platform.

2. Bot mention prefix not stripped - app_mention text includes
   `<@UBOTID> ` prefix, breaking command parsing and workspace
   resolution. Now stripped before passing to engine.

3. Slash command syntax incompatible with Slack - `/workspace` commands
   were being intercepted by Slack as slash commands. Changed all
   user-facing references and prefix detection to use `workspace`
   without the leading slash.

Also added debug logging to handleEvent for diagnosing event delivery.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: instruct agent to use Slack mrkdwn formatting

Add Slack formatting guidelines to the agent system prompt so responses
use Slack's mrkdwn syntax instead of standard Markdown (e.g. *bold*
instead of **bold**, no ## headings).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 19:38:58 +08:00

197 lines
5.6 KiB
Go

package core
import (
"context"
"fmt"
"os"
"path/filepath"
"testing"
)
// mockChannelResolver implements both Platform and ChannelNameResolver.
type mockChannelResolver struct {
names map[string]string
}
func (m *mockChannelResolver) Name() string { return "mock" }
func (m *mockChannelResolver) Start(MessageHandler) error { return nil }
func (m *mockChannelResolver) Reply(_ context.Context, _ any, _ string) error { return nil }
func (m *mockChannelResolver) Send(_ context.Context, _ any, _ string) error { return nil }
func (m *mockChannelResolver) Stop() error { return nil }
func (m *mockChannelResolver) ResolveChannelName(channelID string) (string, error) {
if name, ok := m.names[channelID]; ok {
return name, nil
}
return "", fmt.Errorf("unknown channel %s", channelID)
}
func newTestEngineWithMultiWorkspace(t *testing.T, baseDir string) *Engine {
t.Helper()
tmpDir := t.TempDir()
bindingPath := filepath.Join(tmpDir, "bindings.json")
e := NewEngine("test", nil, nil, "", LangEnglish)
e.SetMultiWorkspace(baseDir, bindingPath)
return e
}
func TestMultiWorkspaceResolution_ConventionMatch(t *testing.T) {
baseDir := t.TempDir()
channelName := "my-project"
channelID := "C001"
// Create a directory matching the channel name
if err := os.MkdirAll(filepath.Join(baseDir, channelName), 0o755); err != nil {
t.Fatal(err)
}
e := newTestEngineWithMultiWorkspace(t, baseDir)
p := &mockChannelResolver{names: map[string]string{channelID: channelName}}
ws, name, err := e.resolveWorkspace(p, channelID)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if name != channelName {
t.Errorf("expected channel name %q, got %q", channelName, name)
}
expectedWS := filepath.Join(baseDir, channelName)
if ws != expectedWS {
t.Errorf("expected workspace %q, got %q", expectedWS, ws)
}
// Verify auto-binding was persisted
b := e.workspaceBindings.Lookup("project:test", channelID)
if b == nil {
t.Fatal("expected binding to be created by convention match")
}
if b.Workspace != expectedWS {
t.Errorf("binding workspace = %q, want %q", b.Workspace, expectedWS)
}
}
func TestMultiWorkspaceResolution_NoMatch(t *testing.T) {
baseDir := t.TempDir() // empty directory — no convention match possible
e := newTestEngineWithMultiWorkspace(t, baseDir)
p := &mockChannelResolver{names: map[string]string{"C002": "nonexistent-project"}}
ws, name, err := e.resolveWorkspace(p, "C002")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ws != "" {
t.Errorf("expected empty workspace, got %q", ws)
}
if name != "nonexistent-project" {
t.Errorf("expected channel name %q, got %q", "nonexistent-project", name)
}
}
func TestMultiWorkspaceResolution_ExistingBinding(t *testing.T) {
baseDir := t.TempDir()
channelID := "C003"
channelName := "bound-channel"
// Create the workspace directory the binding points to
wsDir := filepath.Join(baseDir, "some-workspace")
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
e := newTestEngineWithMultiWorkspace(t, baseDir)
e.workspaceBindings.Bind("project:test", channelID, channelName, wsDir)
// Platform that does NOT know this channel — binding should still work
p := &mockChannelResolver{names: map[string]string{}}
ws, name, err := e.resolveWorkspace(p, channelID)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ws != wsDir {
t.Errorf("expected workspace %q, got %q", wsDir, ws)
}
if name != channelName {
t.Errorf("expected channel name %q, got %q", channelName, name)
}
}
func TestMultiWorkspaceResolution_MissingDirRemovesBinding(t *testing.T) {
baseDir := t.TempDir()
channelID := "C004"
channelName := "stale-channel"
missingDir := filepath.Join(baseDir, "deleted-workspace")
e := newTestEngineWithMultiWorkspace(t, baseDir)
e.workspaceBindings.Bind("project:test", channelID, channelName, missingDir)
p := &mockChannelResolver{names: map[string]string{}}
ws, name, err := e.resolveWorkspace(p, channelID)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ws != "" {
t.Errorf("expected empty workspace for missing dir, got %q", ws)
}
if name != channelName {
t.Errorf("expected channel name %q, got %q", channelName, name)
}
// Verify binding was removed
if b := e.workspaceBindings.Lookup("project:test", channelID); b != nil {
t.Error("expected binding to be removed after missing directory")
}
}
func TestExtractRepoName(t *testing.T) {
tests := []struct {
input string
want string
}{
{"https://github.com/org/my-repo.git", "my-repo"},
{"https://github.com/org/my-repo", "my-repo"},
{"git@github.com:org/my-repo.git", "my-repo"},
{"git@github.com:org/my-repo", "my-repo"},
{"https://gitlab.com/group/subgroup/project.git", "project"},
{"ssh://git@github.com/org/repo.git", "repo"},
{"https://github.com/org/repo", "repo"},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got := extractRepoName(tt.input)
if got != tt.want {
t.Errorf("extractRepoName(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestLooksLikeGitURL(t *testing.T) {
valid := []string{
"https://github.com/org/repo",
"http://github.com/org/repo",
"git@github.com:org/repo.git",
"ssh://git@github.com/org/repo",
}
for _, s := range valid {
if !looksLikeGitURL(s) {
t.Errorf("looksLikeGitURL(%q) = false, want true", s)
}
}
invalid := []string{
"not-a-url",
"ftp://files.example.com/repo",
"/local/path/to/repo",
"",
"github.com/org/repo",
}
for _, s := range invalid {
if looksLikeGitURL(s) {
t.Errorf("looksLikeGitURL(%q) = true, want false", s)
}
}
}