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
8 changes: 5 additions & 3 deletions docs/general/feishu-card-ui-state-machine.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# Feishu 卡片 UI 状态机

> Type: `general`
> Updated: `2026-08-29`
> Summary: 补充路径选择器 stale 初始路径会回退到最近存在父目录,不再中断 `/workspace new` 子步骤。
> Updated: `2026-09-25`
> Summary: 群话题卡片按真实群 ID 校验 callback,文字、卡片和图片回复使用话题内发送,失败不回退群主聊天。

## 1. 文档定位

Expand Down Expand Up @@ -445,11 +445,13 @@ MCP request 卡片当前新增的可视语义:
1. 优先用 `open_message_id -> 已记录的 surfaceSessionID`
2. 如果消息映射找不到,再读取 callback payload 里的 `surface_session_id`
3. `surface_session_id` 必须是 `feishu:<gatewayID>:user:<scopeID>` 或 `feishu:<gatewayID>:chat:<scopeID>`,并且 `gatewayID` 匹配当前 gateway
4. `user` scope 还要求 `scopeID == callback operator preferred actor id`;`chat` scope 还要求 `scopeID == callback context open_chat_id`
4. `user` scope 还要求 `scopeID == callback operator preferred actor id`;`chat` scope 通过 `SurfaceRef.ChatID()` 去除可选 `@topicRootID` 后,要求真实群 ID 与 callback context `open_chat_id` 完全相同;空或重复话题后缀拒绝解析
5. 如果消息映射和可信 `surface_session_id` 都不存在,gateway 直接 fail closed,不再按 `open_chat_id + operator` 推导一个新 surface

这个顺序是当前 P2P surface 不被拆裂、且 runtime hot rebuild 后旧卡仍能回到原 surface 的前提之一。

群话题 surface 的文字、卡片与图片回复,以及目标仍为同群的工具图片/文件/视频发送,通过原消息 `message.reply` 并设置 `reply_in_thread=true`。未指定回复锚点时使用 gateway 已记录的同 surface 消息 ID(包括命令入站、有效卡片 callback 和成功出站),不把 `thread_id` 当作 `message_id`;缺少已知锚点或回复失败直接返回错误,不使用 `message.create` 发到群主聊天;显式发往其他聊天的工具媒体保持指定目标并创建独立消息,不更新来源话题的消息映射或回复锚点。私聊及旧群级 surface 保持既有回复与 fallback 行为。

## 5. 当前同步 Replace 与 Append 边界

### 5.1 同步 replace 的必要条件
Expand Down
10 changes: 5 additions & 5 deletions docs/general/remote-surface-state-machine.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# Remote Surface 核心状态机

> Type: `general`
> Updated: `2026-08-29`
> Summary: 补充 Feishu 群聊纯 @ 主机器人快捷切换,以及 `/workspace new` 路径子步骤对已删除当前目录的回退规则。
> Updated: `2026-09-25`
> Summary: 群聊以根消息 ID 统一原生话题和普通消息转话题的 surface;缺根字段回复优先复用已知父消息映射。
> 1. visible 但 contract mismatch 的 workspace/session 仍然可见,不会再被 `/list`、`/use`、workspace recency、target picker 直接吞掉;
> 2. 这些 mismatch 候选不会再假装“可直接接管”;
> 3. detached `/use`、headless exact-thread restore、workspace attach、startup resume、`/mode` backend switch、`/claudeprofile`、`/codexprofile`、`/opencodeprofile` 现在都会统一先判定 `attach visible compatible / reuse managed compatible / restart managed incompatible / fresh-start matching headless / reject`,而不是各自维护平行 continuation;
Expand Down Expand Up @@ -104,7 +104,7 @@

### 2.2 surface 按 gateway/chat 区分,但 claim 是 relay 全局的

surface 本身仍按 `gatewayID + chat/user` 区分,不同飞书 app 会形成不同 surface。
surface 本身按 `gatewayID + chat/user` 区分,不同飞书 app 会形成不同 surface。群聊入站消息使用 `feishu:<gatewayID>:chat:<chatID>@<topicRootID>`:使用根消息 ID 作为话题标识:回复取 `root_id`,没有 `root_id` 和 `parent_id` 的首条消息取自身 `message_id`,不因后来出现 `thread_id` 改变身份。原生话题首条和普通消息转为话题后的回复都归到同一 surface;缺失 `root_id` 但有 `parent_id` 的回复优先复用同 gateway、同群已记录的父消息 surface;没有可信映射时使用父消息 ID,不把本条回复的 `message_id` 当作新根,同群不同话题保持独立选中会话和输入队列。私聊继续使用用户级 surface;旧群级 surface identity 仍可解析,不自动迁移已有会话。

surface identity 只接受精确四段格式 `feishu:<gatewayID>:<user|chat>:<scopeID>`。`internal/feishuidentity` 是 build / parse / validate 的 SSOT;gateway、preview、daemon、state 与 orchestrator 必须直接复用该 contract,未知 scope、空字段或额外分段不得通过字符串扫描被识别成有效群聊或私聊 surface。普通消息也只保留 `PlanInboundMessageEvent -> QueuedMessageWork.parseAction` 一条 planning/parsing 主链,测试通过真实 handler 路径同步 capture action,不维护另一份 parser。

Expand All @@ -116,7 +116,7 @@ surface identity 只接受精确四段格式 `feishu:<gatewayID>:<user|chat>:<sc
Feishu 群聊消息在进入 surface 状态机前还有一层 gateway 入站前置 gate:

1. 私聊消息不要求 mention,继续按 `feishu:<gatewayID>:user:<preferredActorId>` 进入 surface。
2. 群聊消息若 `mentions` 命中当前 gateway 缓存的 bot `open_id`,允许 materialize / reuse `feishu:<gatewayID>:chat:<chatID>` surface。
2. 群聊消息若 `mentions` 命中当前 gateway 缓存的 bot `open_id`,允许 materialize / reuse 当前话题的 `feishu:<gatewayID>:chat:<chatID>@<topicRootID>` surface。
3. 群聊文本若只有当前 bot mention 和空白字符,在 gateway planner 中转换为 `/primary on` 命令;若 daemon 当前 primary snapshot 已记录 `chatID -> current gateway`,则静默忽略,不记录 `messageID -> surfaceID`,不发用户卡片。
4. 群聊消息若 `mentions` 存在但未命中当前 bot,fail closed 忽略,不记录 `messageID -> surfaceID`,不进入 queue / dispatch。
5. 群聊无 mention 的用户消息只在 daemon 当前 primary snapshot 记录 `chatID -> current gateway`,且 daemon 短 TTL 权限缓存确认该 gateway 具备当前权限 `im:message.group_msg` 或历史兼容权限 `im:message.group_msg:readonly` 时放行;否则在 record / parse / image-file download / queue 前忽略。该 snapshot 由 room durable state 复制生成,gateway callback 热路径不读取 orchestrator mutable root。
Expand All @@ -125,7 +125,7 @@ Feishu 群聊消息在进入 surface 状态机前还有一层 gateway 入站前

Feishu 群聊 surface 之上现在还有一层 room context coordination record,并已参与 headless workspace claim 仲裁:

1. 群聊 surface materialize/resume 时会按 `chatID` 维护 `FeishuRoomContexts[feishu:chat:<chatID>]`。
1. 群聊 surface materialize/resume 时会按真实 `chatID`(不带 `@topicRootID`)维护 `FeishuRoomContexts[feishu:chat:<chatID>]`。
2. record 当前保存 room id、`chatID`、参与过的 gateway id evidence、surface session id evidence、`WorkspaceKey`、workspace 绑定操作者/更新时间与 `WorkspaceResetGeneration`。
3. 私聊 surface 不创建 room context。
4. 同一 `chatID` 下不同 gateway 的群 surface 在 V1 会进入同一个 room context;这是当前本机实测策略,不是 Feishu 官方跨 app 稳定性承诺。
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@
## 1. 背景与问题现象

在多工作区使用飞书管理远程 Codex 会话时,出现以下现象:
1. **工作区内新会话在列表中不显示**:用户在特定物理工作区(如 `/home/qagent/program/svmpy`)中新开或重命名的会话,在飞书端发送 `/use` 或 `/list` 选择该工作区时,会话候选列表完全不包含该会话,甚至显示为无可用会话。
1. **工作区内新会话在列表中不显示**:用户在特定物理工作区(如 `/home/demo/program/svmpy`)中新开或重命名的会话,在飞书端发送 `/use` 或 `/list` 选择该工作区时,会话候选列表完全不包含该会话,甚至显示为无可用会话。
2. **会话名称未识别自定义重命名**:会话在终端 CLI 或 VS Code 中使用 `/rename` 重命名为自定义名称(如 `svmnew0910`)后,飞书端卡片顶部的标题与候选条目依然展示为创建会话时的首条输入长摘要或历史旧名称(如 `svmpy · svmpy_design_20260909`),自定义重命名完全失效。
3. **工作区路径展示偏离**:飞书表面展示的会话工作区路径偶发变为系统内部状态目录 `/home/qagent/.local/state/codex-remote`。
3. **工作区路径展示偏离**:飞书表面展示的会话工作区路径偶发变为系统内部状态目录 `/home/demo/.local/state/codex-remote`。

---

Expand All @@ -20,7 +20,7 @@
### 2.1 Headless Pool 预热机制与会话工作区标记污染

- **旧架构(按需随起)**:
历史版本中,headless 实例在用户发起请求时按需直接在目标物理目录(如 `/home/qagent/program/svmpy`)下冷启动。启动时实例的 `inst.WorkspaceRoot` 与该目录天然一致,内部会话继承该实例路径也不会发生错位。
历史版本中,headless 实例在用户发起请求时按需直接在目标物理目录(如 `/home/demo/program/svmpy`)下冷启动。启动时实例的 `inst.WorkspaceRoot` 与该目录天然一致,内部会话继承该实例路径也不会发生错位。
- **新架构(Managed Headless Pool 预热)**:
最新版本引入预热池以加速响应。预热实例为了提前通用待命,启动时的默认工作目录为状态目录 `~/.local/state/codex-remote`。
- **污染链路**:
Expand All @@ -31,7 +31,7 @@
current.WorkspaceKey = state.ResolveWorkspaceKey(thread.WorkspaceKey, current.WorkspaceKey, inst.WorkspaceKey, inst.WorkspaceRoot)
```
由于 `thread.WorkspaceKey` 为空,所有会话(包括 `svmpy` 会话)全部被无差别盖戳赋予了预热实例的当前目录:`~/.local/state/codex-remote`。
4. 当该预热实例后续被飞书表面借用绑定到目标工作区(如 `/home/qagent/program/svmpy`)时,实例本身属性已切换,但内部缓存的 50 个会话身上的 `WorkspaceKey` 依然残留为 `~/.local/state/codex-remote`。
4. 当该预热实例后续被飞书表面借用绑定到目标工作区(如 `/home/demo/program/svmpy`)时,实例本身属性已切换,但内部缓存的 50 个会话身上的 `WorkspaceKey` 依然残留为 `~/.local/state/codex-remote`。
5. 飞书在构建 `/use` 与 `/list` 候选会话列表时,执行 `threadBelongsToInstanceWorkspace`:
```go
// 原实现:
Expand Down
26 changes: 14 additions & 12 deletions internal/adapter/feishu/gateway.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,17 +52,18 @@ type LiveGateway struct {
uploadVideoPathFn func(context.Context, string) (string, string, error)
fetchMessageFn func(context.Context, string) (*gatewayMessage, error)
createMessageFn func(context.Context, string, string, string, string) (*larkim.CreateMessageResp, error)
replyMessageFn func(context.Context, string, string, string) (*larkim.ReplyMessageResp, error)
replyMessageFn func(context.Context, string, string, string, bool) (*larkim.ReplyMessageResp, error)
patchMessageFn func(context.Context, string, string) (*larkim.PatchMessageResp, error)
deleteMessageFn func(context.Context, string) (*larkim.DeleteMessageResp, error)
createReactionFn func(context.Context, string, string) (*larkim.CreateMessageReactionResp, error)
deleteReactionFn func(context.Context, string, string) (*larkim.DeleteMessageReactionResp, error)

mu sync.Mutex
stateHook func(GatewayState, error)
reactions map[string]string
messages map[string]string
botOpenID string
mu sync.Mutex
stateHook func(GatewayState, error)
reactions map[string]string
messages map[string]string
surfaceReplyAnchors map[string]string
botOpenID string
}

type gatewayMessage struct {
Expand Down Expand Up @@ -104,12 +105,13 @@ func NewLiveGateway(config LiveGatewayConfig) *LiveGateway {
config.GatewayID = gatewaypkg.NormalizeGatewayID(config.GatewayID)
client := NewLarkClientWithOpenBaseURL(config.AppID, config.AppSecret, config.Domain)
gateway := &LiveGateway{
config: config,
client: client,
broker: NewFeishuCallBroker(config.GatewayID, client),
reactions: map[string]string{},
messages: map[string]string{},
botOpenID: strings.TrimSpace(config.BotOpenID),
config: config,
client: client,
broker: NewFeishuCallBroker(config.GatewayID, client),
reactions: map[string]string{},
messages: map[string]string{},
surfaceReplyAnchors: map[string]string{},
botOpenID: strings.TrimSpace(config.BotOpenID),
}
gateway.downloadImageFn = gateway.downloadImage
gateway.downloadFileFn = gateway.downloadFile
Expand Down
7 changes: 6 additions & 1 deletion internal/adapter/feishu/gateway/inbound_lane.go
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,9 @@ func HandleInboundMessageEvent(ctx context.Context, env InboundEnv, event *larki
if lane != nil && lane.markActionDuplicate(*plan.Action) {
return nil
}
if env.RecordSurfaceMessage != nil {
env.RecordSurfaceMessage(plan.Action.MessageID, plan.Action.SurfaceSessionID)
}
return dispatch(ctx, *plan.Action)
}
if plan.Queue == nil {
Expand Down Expand Up @@ -449,8 +452,10 @@ func PlanInboundMessageEvent(env InboundEnv, event *larkim.P2MessageReceiveV1) (
chatType := xutil.StringValue(message.ChatType)
senderUserID := userIDFromMessage(event.Event.Sender)
gatewayID := strings.TrimSpace(env.GatewayID)
surfaceSessionID := SurfaceIDForInbound(gatewayID, chatID, chatType, senderUserID)
topicRootID := inboundTopicRootID(env, message)
surfaceSessionID := SurfaceIDForInboundTopic(gatewayID, chatID, chatType, senderUserID, topicRootID)
inbound := InboundMetaFromMessageEvent(event)

if reason := groupMessageMentionGateReason(env, message, senderTypeFromMessageSender(event.Event.Sender)); reason != "" {
logInboundMessageIgnored(gatewayID, surfaceSessionID, inbound, message, reason)
return PlannedInboundMessage{}, false, nil
Expand Down
10 changes: 5 additions & 5 deletions internal/adapter/feishu/gateway/legacy_compat_removal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,8 @@ func TestPlanInboundMessageEventQueuesUnmentionedGroupTextForPrimaryGateway(t *t
},
RecordSurfaceMessage: func(messageID, surfaceSessionID string) {
recorded = true
if messageID != "om-msg-primary" || surfaceSessionID != "feishu:app-1:chat:oc_chat" {
t.Fatalf("record = %s/%s, want om-msg-primary/feishu:app-1:chat:oc_chat", messageID, surfaceSessionID)
if messageID != "om-msg-primary" || surfaceSessionID != "feishu:app-1:chat:oc_chat@om-msg-primary" {
t.Fatalf("record = %s/%s, want om-msg-primary/feishu:app-1:chat:oc_chat@om-msg-primary", messageID, surfaceSessionID)
}
},
}
Expand Down Expand Up @@ -129,7 +129,7 @@ func TestPlanInboundMessageEventTreatsPureCurrentBotMentionAsPrimaryOnCommand(t
if action.Kind != control.ActionPrimaryCommand || action.Text != "/primary on" || action.CommandID != control.FeishuCommandPrimary {
t.Fatalf("primary shortcut action = %#v", action)
}
if action.GatewayID != "app-2" || action.SurfaceSessionID != "feishu:app-2:chat:oc_chat" || action.ChatID != "oc_chat" || action.ActorUserID != "ou_user" || action.MessageID != "om-msg-mention-primary" {
if action.GatewayID != "app-2" || action.SurfaceSessionID != "feishu:app-2:chat:oc_chat@om-msg-mention-primary" || action.ChatID != "oc_chat" || action.ActorUserID != "ou_user" || action.MessageID != "om-msg-mention-primary" {
t.Fatalf("primary shortcut action routing metadata = %#v", action)
}
}
Expand Down Expand Up @@ -241,8 +241,8 @@ func TestPlanInboundMessageEventQueuesUnmentionedGroupImageAndFileForPrimaryGate
}

want := []string{
"om-img-primary@feishu:app-1:chat:oc_chat",
"om-file-primary@feishu:app-1:chat:oc_chat",
"om-img-primary@feishu:app-1:chat:oc_chat@om-img-primary",
"om-file-primary@feishu:app-1:chat:oc_chat@om-file-primary",
}
if strings.Join(recorded, "|") != strings.Join(want, "|") {
t.Fatalf("recorded = %#v, want %#v", recorded, want)
Expand Down
26 changes: 26 additions & 0 deletions internal/adapter/feishu/gateway/routing.go
Original file line number Diff line number Diff line change
Expand Up @@ -671,10 +671,36 @@ func surfaceID(gatewayID, chatID, fallbackUserID string) string {
}.SurfaceID()
}

// A topic's root message ID stays stable when an ordinary message becomes a topic.
func inboundTopicRootID(env InboundEnv, message *larkim.EventMessage) string {
if rootID := strings.TrimSpace(xutil.StringValue(message.RootId)); rootID != "" {
return rootID
}
parentID := strings.TrimSpace(xutil.StringValue(message.ParentId))
if parentID == "" {
return strings.TrimSpace(xutil.StringValue(message.MessageId))
}
// A known parent preserves the root even when the reply omits root_id.
if env.LookupSurfaceMessage != nil {
ref, ok := feishuidentity.ParseSurfaceRef(env.LookupSurfaceMessage(parentID))
if ok && ref.TopicRootID() != "" && ref.GatewayID == strings.TrimSpace(env.GatewayID) && ref.ChatID() == strings.TrimSpace(xutil.StringValue(message.ChatId)) {
return ref.TopicRootID()
}
}
return parentID
}

func SurfaceIDForInbound(gatewayID, chatID, chatType, fallbackUserID string) string {
return SurfaceIDForInboundTopic(gatewayID, chatID, chatType, fallbackUserID, "")
}

func SurfaceIDForInboundTopic(gatewayID, chatID, chatType, fallbackUserID, topicRootID string) string {
if strings.EqualFold(chatType, "p2p") && fallbackUserID != "" {
return surfaceID(gatewayID, "", fallbackUserID)
}
if strings.EqualFold(strings.TrimSpace(chatType), "group") && strings.TrimSpace(chatID) != "" && strings.TrimSpace(topicRootID) != "" {
chatID = strings.TrimSpace(chatID) + "@" + strings.TrimSpace(topicRootID)
}
return surfaceID(gatewayID, chatID, fallbackUserID)
}

Expand Down
4 changes: 2 additions & 2 deletions internal/adapter/feishu/gateway_attention_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ func TestApplySendCardRepliesWithInCardAttention(t *testing.T) {
replyMsgType string
replyContent string
)
gateway.replyMessageFn = func(_ context.Context, messageID, msgType, content string) (*larkim.ReplyMessageResp, error) {
gateway.replyMessageFn = func(_ context.Context, messageID, msgType, content string, replyInThread bool) (*larkim.ReplyMessageResp, error) {
replyMessageID = messageID
replyMsgType = msgType
replyContent = content
Expand Down Expand Up @@ -73,7 +73,7 @@ func TestApplySendCardAttentionFallsBackToCreate(t *testing.T) {
createMsgType string
createContent string
)
gateway.replyMessageFn = func(_ context.Context, _, _, _ string) (*larkim.ReplyMessageResp, error) {
gateway.replyMessageFn = func(_ context.Context, _, _, _ string, _ bool) (*larkim.ReplyMessageResp, error) {
replyCalls++
return &larkim.ReplyMessageResp{
ApiResp: &larkcore.ApiResp{},
Expand Down
Loading