本文记录 PR #1698 合入时的实现,以下示例以 group_reply_all = false 的普通群聊 text/post 消息为主。
之前 cc-connect 在这个配置下的行为是这样:
普通群消息
↓
忽略
@Bot 消息
↓
发送给 Agent
↓
回复
这个模型下,普通群聊讨论会被 mention filter 过滤,Agent 无法通过这些消息获得讨论上下文;已有的引用消息、thread bootstrap 等上下文路径另行处理。
例如:
Alice: 这个接口最近经常超时
Bob: 我看日志像是 upstream 的问题
Alice: 昨天晚上 11 点之后开始明显变多
Alice: @Bot 帮我总结一下现在的问题
Agent 实际收到的可能只有:
帮我总结一下现在的问题
前三条消息并不会进入上下文。
这次给 cc-connect 增加了一个 opt-in 配置:
group_chat_history_share = true
默认值仍然是:
false
行为
启用以后,上述 text/post 消息仍然需要满足已有触发条件,例如显式 @Bot。group_chat_history_share 本身不会让普通消息触发 Agent。
区别在于,普通群消息不再直接丢弃,而是先保存到当前 group scope 的 pending history。
Alice: message A
Bob: message B
Carol: message C
↓
pending history
Alice: @Bot question
↓
message A
message B
message C
question
↓
Agent
因此:
“是否触发 Agent”
和:
“是否允许消息成为 Agent context”
被拆成了两个行为。
普通消息:
observe = yes
invoke = no
@Bot:
observe = yes
invoke = yes
Pending history
pending history 保存当前进程观察到的、尚未被已接纳 Agent turn 消费的消息。连续两次正常请求都被接纳时,可以用下面的区间理解:
上一次有效 @
↓
本次有效 @
之间积累的消息。
例如:
A
B
C
@Bot Q1
D
E
@Bot Q2
Q1 获得:
A
B
C
Q1
Q2 获得:
D
E
Q2
Q1 消费过的:
A
B
C
不会再次注入 Q2。
这条缓存路径使用已有 message event,不额外调用 Feishu history API,也不会补齐启动前或断连期间未收到的讨论。缓存只保存在内存中;已有的引用消息和 thread bootstrap 路径仍可能调用飞书 API。
当前限制为每个 scope:
50 messages
超过以后丢弃最旧记录,进程重启后缓存清空。合入版本没有为 pending history 设置 TTL,50 条上限按 scope 计算;正文长度和 scope 总数也不受这个条数上限约束。
消息注入
历史消息按接收顺序注入,并保留 sender。
例如:
Alice: API 返回 502
Bob: staging 也出现了
Alice: prod 大概一分钟一次
不能压成:
API 返回 502
staging 也出现了
prod 大概一分钟一次
否则 Agent 无法判断是谁提供了哪部分信息。
最终通过 ExtraContent 把 pending history 附加到本次 Agent turn。下面仅展示结构,实际格式使用 sender 名称、换行和上下文分隔标记:
概念上类似:
ExtraContent:
[Alice] API 返回 502
[Bob] staging 也出现了
[Alice] prod 大概一分钟一次
User:
帮我分析一下
当前只处理能够稳定转换为上下文的:
text
post
post 会提取为纯文本,提取结果为空的消息不进入缓存。图片、文件和音频等消息沿用已有处理路径。
/status 不消费 history
假设当前状态:
A
B
C
然后用户发送:
@Bot /status
/status 由 cc-connect 直接处理,不进入正常 Agent turn。adapter 可以为它附带 history 快照和回调,但 core 的命令分支不会调用 OnAccepted,因此不会消费缓存。
如果它把 pending history 一起消费掉,那么后面:
@Bot 帮我看一下刚才讨论的问题
就无法获得:
A
B
C
因此规则是:
/status
↓
读取 Bot 状态
↓
pending history 保留
即:
A
B
C
@Bot /status
@Bot Q
Q 仍然获得:
A
B
C
Q
/new 清空 history
/new 表示开启一个新的 session。对于通过触发和权限检查的 text 命令,adapter 会清空当前 history scope,再继续交给 core 处理。
A
B
C
@Bot /new
D
E
@Bot Q
Q 只获得:
D
E
Q
应当排除的旧上下文如下:
A
B
C
D
E
Q
否则 Agent session 已经重置,但群聊上下文仍然跨 session 泄漏。
Recall
如果群消息已经进入 pending history,随后被撤回,需要同时从缓存中删除。
例如:
Alice: password is ...
Alice: 撤回
Alice: @Bot 总结一下
收到 recall event 后,adapter 按 message ID 从 pending history 删除对应消息,格式化快照时也会跳过已标记撤回的记录。
这个保证适用于待注入的缓存。已经格式化并交给 core、进入队列或发送给 Agent 的 ExtraContent,无法仅靠删除 pending history 收回;撤回也不能保证清除 Agent 已处理的内容。
Thread isolation
cc-connect 已经支持:
thread_isolation = true
启用后,群主频道和各个 thread 使用不同 scope。
例如:
Main:
A
B
Thread #1:
C
D
如果在 Thread #1 中:
@Bot Q
Agent 获得:
C
D
Q
不会混入:
A
B
如果:
thread_isolation = false
则按照现有 session 语义,main channel 与 thread 被视为同一个 scope,消息按到达顺序进入同一份 pending history。
history scope 按 chat ID 和 thread root ID 隔离,具体键为 chat:<chat_id> 或 thread:<chat_id>:<root_id>。它与 Agent session key 分别计算:开启 thread isolation 后,主频道的一次 mention 可以创建 root-scoped session,后续主频道普通消息仍保存在 chat-level history 中,不会跟随之前分出的 session。
权限边界
权限检查分别回答下面两个问题:
Bot 能不能观察一条群消息
和:
这个发送者能不能触发 Agent
两个问题分别对应 observation 和 invocation 权限。
实现中继续区分:
allow_chat
和:
allow_from
allow_chat 决定这个群是否属于 Bot 可以处理的范围。
allow_from 决定某个用户是否可以真正触发 Agent。允许访问的群中,未获触发权限的用户所发普通消息仍可能进入 history,随后由有权限的用户触发共享。
飞书侧还需要申请并发布 im:message.group_msg 权限,才能向 Bot 推送未提及它的群消息。仅打开本地配置无法获得飞书未推送的消息。
合入版本也保留了 fail-closed 检查:bot ID 无法正常解析、mention filter 处于 degraded 状态时,普通群消息不会被捕获为 history。
否则为了获取群聊上下文而放宽 message observation,很容易顺便放宽 Agent invocation 权限。
因此:
普通消息
↓
允许进入 history
不代表:
该发送者可以 @Bot 并执行任意 Agent 请求
group_reply_all 不受影响
以下配置控制普通消息的触发行为:
group_reply_all = false
保持这个设置时,普通 text/post 消息不会因为打开 history share 而触发回复。
即使:
group_chat_history_share = true
Bot 对这些普通消息仍然只缓存上下文。
例如:
Alice: A
Bob: B
Carol: C
结果仍然是:
Bot: <nothing>
直到:
Alice: @Bot Q
才触发 Agent。
若已有配置是 group_reply_all = true,普通消息仍会立即进入原有处理路径。开启 history share 不会将它们改为等待下一次 mention。
所以两个配置控制的是不同维度:
group_reply_all
→ 什么消息触发回复
group_chat_history_share
→ 未触发回复的消息能否成为后续 context
状态模型
下面保留概念流程,适用于前述 mention 模式。实际实现会先检查 chat 权限和 mention filter;只有未触发 Bot 的合格 text/post 消息才进入 pending history。recall 由独立事件处理:
receive message
↓
resolve scope
↓
message recalled?
├─ yes → remove from pending history
└─ no
↓
normal group text/post?
├─ yes → append pending history
└─ no
↓
explicit Bot trigger?
├─ no → stop
└─ yes
↓
command?
├─ /status → keep pending history
├─ /new → clear pending history
└─ normal turn
↓
inject pending history
↓
Agent
↓
accepted turn
↓
consume pending history
这里的 accepted 指 core 接纳消息处理,或将消息加入忙碌 session 的队列。此时触发 OnAccepted 消费快照,Agent 的异步处理和最终回复可能尚未开始。
接纳前未触发回调时,pending history 会保留;接纳后发生模型错误或回复失败,不会自动恢复缓存。消费以快照中的最大记录 ID 为边界,快照之后新到达的普通消息会留给后续请求。
测试
合入版本的 platform/feishu/group_history_test.go 包含 10 个测试。下面按行为归纳,sender、顺序和 consume once 由同一个测试覆盖;recall 单独注明核实范围:
default-off
没有打开 group_chat_history_share 时,保持原有行为。
ordered history
多条消息按原顺序注入。
sender attribution
不同发送者不能在格式化时丢失身份。
consume once
一批历史只能被一次正常 Agent turn 消费。
/status
不消费 pending history。
/new
清空当前 scope。
thread isolation
main / thread 的 scope 与现有 thread_isolation 规则一致。
recall
源码已核实撤回消息会从 pending history 删除;上述测试文件没有专门针对 pending history recall 的测试,不能把这一项计为新增测试覆盖。
permissions
观察群聊消息不能绕过原有 invocation 权限。
group_reply_all
专门的测试验证 group_reply_all = true 时普通消息仍立即派发;其他 history 测试验证 mention 模式下普通消息只缓存、不派发。
text / post formatting
两种支持的 Feishu message type 在进入 history 后保持可读格式。
此外,TestFeishuGroupHistory_DegradedMentionFilterDoesNotCapture 验证 bot ID 解析降级时不捕获 history。PR 的提交记录还列出了全量测试、race detector、构建和实群验证;这些结果对应提交时的版本。
配置
下面保留配置意图示意。[platforms.feishu] 不是合入版本的实际 TOML 层级,不能直接照抄:
[platforms.feishu]
group_chat_history_share = true
group_reply_all = false
thread_isolation = true
实际使用时,在已有 [[projects.platforms]] 且 type = "feishu" 的配置项下,将这三个选项写入对应的 [projects.platforms.options],与 app_id、app_secret 同级。层级可对照合入版本的配置示例。
启用前还需要完成飞书群消息权限的申请和发布。本文的显式 mention 示例沿用默认 mention 设置;@all、已激活 thread 的附件等触发规则仍由原有配置和处理路径决定。
对应的群聊行为:
普通消息
→ 缓存
→ 不回复
@Bot
→ 注入当前 scope 的 pending history
→ 调用 Agent
→ 回复
/status
→ 不消费 pending history
/new
→ 清空当前 scope
recall
→ 从 pending history 删除
restart
→ pending history 清空
PR #1698 在 rebase 和 CI 通过后,于 2026-09-03 合入 cc-connect main。