Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -1021,8 +1021,8 @@ app_secret = "your-feishu-app-secret"
# # 设为 true 时,群聊中无需 @机器人 也会响应所有消息(默认 false)
# share_session_in_channel = false # If true, all users in a group share one agent session
# # 设为 true 时,群聊内所有用户共享同一个 Agent 会话
# thread_isolation = false # If true, group messages use Feishu reply threads as session boundaries (one thread/root = one agent session)
# # 设为 true 时,群聊按飞书话题/根消息隔离会话(一个 thread/root 对应一个 Agent 会话)
# thread_isolation = false # If true, group messages use Feishu reply threads as session boundaries (one thread/root = one agent session and, in multi-workspace mode, one workspace binding)
# # 设为 true 时,群聊按飞书话题/根消息隔离会话;multi-workspace 模式下每个话题也独立绑定 workspace
# reply_to_trigger = true # Default: bot uses “reply to message” API (quotes the user’s message). Set false to post plain chat messages instead.
# # 默认 true:用「回复消息」API(引用用户消息)。设为 false 则在会话里发普通消息、不引用触发消息。
# progress_style = "legacy" # Progress rendering style: legacy | compact | card
Expand Down
98 changes: 92 additions & 6 deletions core/cuj_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"testing"
Expand Down Expand Up @@ -64,8 +65,8 @@ type cujAgent struct {
// Used by tests that need to drive a multi-event turn (text chunks +
// permission request + result) from a single Send call. See
// setNextSessionEvents on cujAgent.
nextSessionEvents []Event
nextSessionDelayMs int
nextSessionEvents []Event
nextSessionDelayMs int
}

func (a *cujAgent) Name() string { return "cuj" }
Expand Down Expand Up @@ -1130,8 +1131,8 @@ func TestCUJ_A3_ImageReachesAgent(t *testing.T) {
msg := &Message{
SessionKey: "test:img", Platform: "test", MessageID: "img1",
UserID: "img", UserName: "img",
Content: "what is in this image",
Images: []ImageAttachment{{MimeType: "image/png", Data: []byte("\x89PNG fake"), FileName: "chart.png"}},
Content: "what is in this image",
Images: []ImageAttachment{{MimeType: "image/png", Data: []byte("\x89PNG fake"), FileName: "chart.png"}},
ReplyCtx: "ctx",
}
e.ReceiveMessage(plat, msg)
Expand Down Expand Up @@ -1194,8 +1195,8 @@ func TestCUJ_A5_FileReachesAgent(t *testing.T) {
msg := &Message{
SessionKey: "test:file", Platform: "test", MessageID: "f1",
UserID: "file", UserName: "file",
Content: "read this file",
Files: []FileAttachment{{MimeType: "text/plain", Data: []byte("hello world"), FileName: "note.txt"}},
Content: "read this file",
Files: []FileAttachment{{MimeType: "text/plain", Data: []byte("hello world"), FileName: "note.txt"}},
ReplyCtx: "ctx",
}
e.ReceiveMessage(plat, msg)
Expand Down Expand Up @@ -2326,3 +2327,88 @@ func TestCUJ_STREAM1_StreamingResumesAfterPermissionPrompt(t *testing.T) {
}
}

func TestCUJ_H4_FeishuTopicsKeepWorkspaceBindingsIsolated(t *testing.T) {
baseDir := t.TempDir()
defaultWorkspace := normalizeWorkspacePath(filepath.Join(baseDir, "workspace-default"))
workspaceA := normalizeWorkspacePath(filepath.Join(baseDir, "workspace-a"))
workspaceB := normalizeWorkspacePath(filepath.Join(baseDir, "workspace-b"))
for _, dir := range []string{defaultWorkspace, workspaceA, workspaceB} {
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
}
defaultWorkspace = normalizeWorkspacePath(defaultWorkspace)
workspaceA = normalizeWorkspacePath(workspaceA)
workspaceB = normalizeWorkspacePath(workspaceB)

const agentName = "cuj-feishu-topic-workspace-agent"
RegisterAgent(agentName, func(map[string]any) (Agent, error) {
return &namedTestAgent{name: agentName}, nil
})
platform := &stubPlatformEngine{n: "feishu"}
engine := NewEngine(
"test",
&namedTestAgent{name: agentName},
[]Platform{platform},
filepath.Join(t.TempDir(), "sessions.json"),
LangEnglish,
)
engine.SetMultiWorkspace(baseDir, filepath.Join(t.TempDir(), "bindings.json"))
engine.workspaceBindings.Bind(
"project:test",
workspaceChannelKey("feishu", "oc_chat"),
"topic-group",
defaultWorkspace,
)

sendTopicCommand := func(rootID, command string) {
engine.ReceiveMessage(platform, &Message{
SessionKey: "feishu:oc_chat:root:" + rootID,
Platform: "feishu",
MessageID: "msg-" + rootID,
UserID: "user",
UserName: "user",
Content: command,
ChannelKey: "oc_chat:topic:" + rootID,
LegacyChannelKey: "oc_chat",
ReplyCtx: "ctx-" + rootID,
})
}
lastReply := func() string {
sent := platform.getSent()
if len(sent) == 0 {
t.Fatal("expected a user-visible workspace reply")
}
return sent[len(sent)-1]
}

// User actions 1-3: A gets an override, while B first inherits the chat
// default and can then set its own override.
sendTopicCommand("om_root_a", "/workspace bind workspace-a")
sendTopicCommand("om_root_b", "/workspace")
if got := lastReply(); !strings.Contains(got, normalizeWorkspacePath(defaultWorkspace)) {
t.Fatalf("topic B did not inherit the chat default: %q", got)
}
sendTopicCommand("om_root_b", "/workspace bind workspace-b")

// User actions 4-5: each topic reports its own workspace.
sendTopicCommand("om_root_a", "/workspace")
if got := lastReply(); !strings.Contains(got, normalizeWorkspacePath(workspaceA)) {
t.Fatalf("topic A workspace reply = %q, want %q", got, normalizeWorkspacePath(workspaceA))
}
sendTopicCommand("om_root_b", "/workspace")
if got := lastReply(); !strings.Contains(got, normalizeWorkspacePath(workspaceB)) {
t.Fatalf("topic B workspace reply = %q, want %q", got, normalizeWorkspacePath(workspaceB))
}

// User action 6: unbinding A restores the chat default and does not affect B.
sendTopicCommand("om_root_a", "/workspace unbind")
sendTopicCommand("om_root_a", "/workspace")
if got := lastReply(); !strings.Contains(got, normalizeWorkspacePath(defaultWorkspace)) {
t.Fatalf("topic A did not fall back to the chat default after unbind: %q", got)
}
sendTopicCommand("om_root_b", "/workspace")
if got := lastReply(); !strings.Contains(got, normalizeWorkspacePath(workspaceB)) {
t.Fatalf("topic B changed after topic A unbind: %q", got)
}
}
26 changes: 26 additions & 0 deletions core/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -2837,6 +2837,9 @@ func (e *Engine) handleMessage(p Platform, msg *Message) {
var wsAgent Agent
var wsSessions *SessionManager
var resolvedWorkspace string
if e.multiWorkspace {
e.migrateLegacyWorkspaceBindings(msg)
}
if forcedWorkDir := e.sendWorkDirForSession(msg.SessionKey); forcedWorkDir != "" {
e.bindSendWorkDir(msg.SessionKey, forcedWorkDir)
var err error
Expand Down Expand Up @@ -16122,6 +16125,29 @@ func effectiveWorkspaceChannelKey(msg *Message) string {
return extractWorkspaceChannelKey(msg.SessionKey)
}

// migrateLegacyWorkspaceBindings moves bindings from a platform-provided
// legacy channel scope to the current scope before workspace resolution. The
// platform owns both opaque identifiers; core only adds the platform prefix.
func (e *Engine) migrateLegacyWorkspaceBindings(msg *Message) {
if e.workspaceBindings == nil || msg == nil || msg.ChannelKey == "" || msg.LegacyChannelKey == "" {
return
}
oldChannelKey := workspaceChannelKey(msg.Platform, msg.LegacyChannelKey)
newChannelKey := workspaceChannelKey(msg.Platform, msg.ChannelKey)
if oldChannelKey == "" || newChannelKey == "" || oldChannelKey == newChannelKey {
return
}

for _, projectKey := range []string{"project:" + e.name, sharedWorkspaceBindingsKey} {
if e.workspaceBindings.MigrateChannelKey(projectKey, oldChannelKey, newChannelKey) {
slog.Info("workspace binding migrated",
"project", projectKey,
"old_channel_key", oldChannelKey,
"new_channel_key", newChannelKey)
}
}
}

// commandContext resolves the appropriate agent, session manager, and interactive key
// for a command. In multi-workspace mode, it routes to the bound workspace if present.
func (e *Engine) commandContext(p Platform, msg *Message) (Agent, *SessionManager, string, error) {
Expand Down
10 changes: 7 additions & 3 deletions core/message.go
Original file line number Diff line number Diff line change
Expand Up @@ -224,9 +224,13 @@ type Message struct {
Location *LocationAttachment // geographical location (if any)
ExtraContent string // platform-enriched content (e.g. location text, reply quote) prepended for the agent
ChannelKey string // platform-provided channel identifier for workspace binding (optional)
ReplyCtx any // platform-specific context needed for replying
FromVoice bool // true if message originated from voice transcription
ModeOverride string // if set, temporarily override agent permission mode for this message
// LegacyChannelKey is the platform-provided channel identifier used by an
// older workspace-binding scope. When both keys are set, multi-workspace
// routing atomically migrates the legacy binding to ChannelKey.
LegacyChannelKey string
ReplyCtx any // platform-specific context needed for replying
FromVoice bool // true if message originated from voice transcription
ModeOverride string // if set, temporarily override agent permission mode for this message
// IsPermissionResponse is set by inline-button / card-action paths in
// platforms when a synthesized message is forwarded as a permission
// decision (e.g. Telegram handleCallbackQuery for perm:allow/deny,
Expand Down
24 changes: 24 additions & 0 deletions core/multi_workspace_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,30 @@ func TestWorkspaceInitFlow_SlashCommandCleansUpExistingFlow(t *testing.T) {
}
}

func TestMigrateLegacyWorkspaceBindings_CopiesProjectAndSharedDefaults(t *testing.T) {
baseDir := t.TempDir()
e := newTestEngineWithMultiWorkspace(t, baseDir)
oldKey := workspaceChannelKey("feishu", "oc_chat")
newKey := workspaceChannelKey("feishu", "oc_chat:topic:om_root")
e.workspaceBindings.Bind("project:test", oldKey, "topic-group", "/workspace/project")
e.workspaceBindings.Bind(sharedWorkspaceBindingsKey, oldKey, "topic-group", "/workspace/shared")

e.migrateLegacyWorkspaceBindings(&Message{
Platform: "feishu",
ChannelKey: "oc_chat:topic:om_root",
LegacyChannelKey: "oc_chat",
})

for _, projectKey := range []string{"project:test", sharedWorkspaceBindingsKey} {
if b := e.workspaceBindings.Lookup(projectKey, oldKey); b == nil {
t.Fatalf("%s chat default binding was not preserved", projectKey)
}
if b := e.workspaceBindings.Lookup(projectKey, newKey); b == nil {
t.Fatalf("%s topic binding did not inherit the default", projectKey)
}
}
}

// runAsTestAgent is a stub agent that reports run_as_user and run_as_env
// via the interface methods getOrCreateWorkspaceAgent uses for propagation.
// It exists specifically to test TestMultiWorkspaceAgent_PropagatesRunAsUser
Expand Down
34 changes: 34 additions & 0 deletions core/workspace_binding.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,40 @@ func (m *WorkspaceBindingManager) Bind(projectKey, channelKey, channelName, work
m.saveLocked()
}

// MigrateChannelKey copies an existing default binding from oldChannelKey to
// newChannelKey. It never overwrites an existing destination and deliberately
// preserves the default binding for other channels and older versions.
func (m *WorkspaceBindingManager) MigrateChannelKey(projectKey, oldChannelKey, newChannelKey string) bool {
if oldChannelKey == "" || newChannelKey == "" || oldChannelKey == newChannelKey {
return false
}

m.mu.Lock()
defer m.mu.Unlock()
m.refreshLocked()

proj := m.bindings[projectKey]
if proj == nil || m.lookupLocked(projectKey, newChannelKey) != nil {
return false
}

var binding *WorkspaceBinding
for _, candidate := range workspaceChannelKeyCandidates(oldChannelKey) {
if b := proj[candidate]; b != nil {
binding = b
break
}
}
if binding == nil {
return false
}

inherited := *binding
proj[newChannelKey] = &inherited
m.saveLocked()
return true
}

func (m *WorkspaceBindingManager) Unbind(projectKey, channelKey string) {
m.mu.Lock()
defer m.mu.Unlock()
Expand Down
87 changes: 87 additions & 0 deletions core/workspace_binding_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package core

import (
"path/filepath"
"sync"
"testing"
)

Expand Down Expand Up @@ -149,3 +150,89 @@ func TestWorkspaceBindingManager_UnbindScopedRemovesLegacyBinding(t *testing.T)
t.Fatalf("expected legacy binding to be removed, got %+v", b)
}
}

func TestWorkspaceBindingManager_InheritChannelKeyPreservesDefault(t *testing.T) {
dir := t.TempDir()
storePath := filepath.Join(dir, "bindings.json")
mgr := NewWorkspaceBindingManager(storePath)

projectKey := "project:claude"
oldKey := workspaceChannelKey("feishu", "oc_chat")
newKey := workspaceChannelKey("feishu", "oc_chat:topic:om_root")
mgr.Bind(projectKey, oldKey, "topic-group", "/workspace/a")

if !mgr.MigrateChannelKey(projectKey, oldKey, newKey) {
t.Fatal("expected topic binding to inherit the chat default")
}
if b := mgr.Lookup(projectKey, oldKey); b == nil || b.Workspace != "/workspace/a" {
t.Fatalf("expected chat default binding to be preserved, got %+v", b)
}
if b := mgr.Lookup(projectKey, newKey); b == nil || b.Workspace != "/workspace/a" {
t.Fatalf("expected inherited topic binding, got %+v", b)
}

reloaded := NewWorkspaceBindingManager(storePath)
if b := reloaded.Lookup(projectKey, newKey); b == nil || b.Workspace != "/workspace/a" {
t.Fatalf("expected inherited binding after reload, got %+v", b)
}
if b := reloaded.Lookup(projectKey, oldKey); b == nil || b.Workspace != "/workspace/a" {
t.Fatalf("expected chat default after reload, got %+v", b)
}
}

func TestWorkspaceBindingManager_InheritChannelKeyDoesNotOverwriteDestination(t *testing.T) {
mgr := NewWorkspaceBindingManager(filepath.Join(t.TempDir(), "bindings.json"))
projectKey := "project:claude"
oldKey := workspaceChannelKey("feishu", "oc_chat")
newKey := workspaceChannelKey("feishu", "oc_chat:topic:om_root")
mgr.Bind(projectKey, oldKey, "topic-group", "/workspace/legacy")
mgr.Bind(projectKey, newKey, "topic", "/workspace/current")

if mgr.MigrateChannelKey(projectKey, oldKey, newKey) {
t.Fatal("inheritance must not overwrite an existing topic binding")
}
if b := mgr.Lookup(projectKey, newKey); b == nil || b.Workspace != "/workspace/current" {
t.Fatalf("destination binding changed unexpectedly: %+v", b)
}
if b := mgr.Lookup(projectKey, oldKey); b == nil || b.Workspace != "/workspace/legacy" {
t.Fatalf("chat default binding should remain: %+v", b)
}
}

func TestWorkspaceBindingManager_InheritChannelKeyConcurrentTopics(t *testing.T) {
mgr := NewWorkspaceBindingManager(filepath.Join(t.TempDir(), "bindings.json"))
projectKey := "project:claude"
oldKey := workspaceChannelKey("feishu", "oc_chat")
targets := []string{
workspaceChannelKey("feishu", "oc_chat:topic:om_root_a"),
workspaceChannelKey("feishu", "oc_chat:topic:om_root_b"),
}
mgr.Bind(projectKey, oldKey, "topic-group", "/workspace/a")

results := make(chan bool, len(targets))
var wg sync.WaitGroup
for _, target := range targets {
wg.Add(1)
go func(channelKey string) {
defer wg.Done()
results <- mgr.MigrateChannelKey(projectKey, oldKey, channelKey)
}(target)
}
wg.Wait()
close(results)

inherited := 0
for ok := range results {
if ok {
inherited++
}
}
if inherited != len(targets) {
t.Fatalf("expected all topics to inherit the default, got %d of %d", inherited, len(targets))
}
for _, target := range targets {
if b := mgr.Lookup(projectKey, target); b == nil || b.Workspace != "/workspace/a" {
t.Fatalf("topic %q did not inherit the default: %+v", target, b)
}
}
}
1 change: 1 addition & 0 deletions docs/feishu.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ app_secret = "QhkMpxxxxxxxxxxxxxxxxxxxx"

> 如果应用没有交互卡片权限,或后台未配置卡片回调,可将 `enable_feishu_card = false`,让所有命令统一走纯文本回复,避免卡片发送失败后用户看不到内容。
> 如果开启 `thread_isolation = true`,群聊里每个根消息 / reply thread 会对应一个独立 agent session;私聊行为保持原样。
> 在 multi-workspace 模式下,`thread_isolation = true` 也会让每个话题独立绑定 workspace;在话题内执行 `/workspace bind <name>` 不会影响同群的其他话题。已有的群级 binding 会保留为默认值,由尚未显式绑定的话题继承,因此回退到旧版本时仍可使用。
> `progress_style = "compact"` 会把思考/工具进度合并到一条可更新消息里,减少刷屏;`legacy` 保持原有逐条发送;`card` 会使用结构化卡片(标题 + 进度块)持续更新同一条消息,观感比纯文本更清晰。
> `domain` 只影响运行时 API / WebSocket 请求地址;CLI `setup/new/bind` 的引导域名仍然使用内置默认值。
> `done_emoji` 设置后,agent 每次完成回复时会在用户消息上添加指定表情(如 `"Done"` → ✅)。先移除 "OnIt" 表情(如果有),再添加 done 表情。在 quiet 模式下特别有用,因为飞书卡片原地更新不触发推送,done 表情可以通知用户 agent 已完成。设为 `"none"` 或不配置则禁用。
Expand Down
Loading
Loading