Files
cc-connect/core/workspace_state_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

69 lines
1.6 KiB
Go

package core
import (
"testing"
"time"
)
func TestWorkspacePool_GetOrCreate(t *testing.T) {
pool := newWorkspacePool(15 * time.Minute)
state1 := pool.GetOrCreate("/workspace/a")
state2 := pool.GetOrCreate("/workspace/a")
state3 := pool.GetOrCreate("/workspace/b")
if state1 != state2 {
t.Error("expected same state for same workspace")
}
if state1 == state3 {
t.Error("expected different state for different workspace")
}
}
func TestWorkspacePool_Touch(t *testing.T) {
pool := newWorkspacePool(15 * time.Minute)
state := pool.GetOrCreate("/workspace/a")
before := state.LastActivity()
time.Sleep(10 * time.Millisecond)
state.Touch()
after := state.LastActivity()
if !after.After(before) {
t.Error("expected lastActivity to advance after Touch()")
}
}
func TestWorkspacePool_ReapIdle(t *testing.T) {
pool := newWorkspacePool(50 * time.Millisecond)
pool.GetOrCreate("/workspace/a")
time.Sleep(100 * time.Millisecond)
reaped := pool.ReapIdle()
if len(reaped) != 1 || reaped[0] != "/workspace/a" {
t.Errorf("expected [/workspace/a] reaped, got %v", reaped)
}
if s := pool.Get("/workspace/a"); s != nil {
t.Error("expected workspace removed after reap")
}
}
func TestWorkspacePool_ReapIdle_KeepsActive(t *testing.T) {
pool := newWorkspacePool(200 * time.Millisecond)
state := pool.GetOrCreate("/workspace/active")
time.Sleep(100 * time.Millisecond)
state.Touch() // Keep it alive
reaped := pool.ReapIdle()
if len(reaped) != 0 {
t.Errorf("expected no reaping for active workspace, got %v", reaped)
}
if s := pool.Get("/workspace/active"); s == nil {
t.Error("expected active workspace to still exist")
}
}