@@ -1148,6 +1148,83 @@ describe("handleFeishuMessage command authorization", () => {
11481148 ) ;
11491149 } ) ;
11501150
1151+ it ( "keeps root_id as topic key when root_id and thread_id both exist" , async ( ) => {
1152+ mockShouldComputeCommandAuthorized . mockReturnValue ( false ) ;
1153+
1154+ const cfg : ClawdbotConfig = {
1155+ channels : {
1156+ feishu : {
1157+ groups : {
1158+ "oc-group" : {
1159+ requireMention : false ,
1160+ groupSessionScope : "group_topic_sender" ,
1161+ } ,
1162+ } ,
1163+ } ,
1164+ } ,
1165+ } as ClawdbotConfig ;
1166+
1167+ const event : FeishuMessageEvent = {
1168+ sender : { sender_id : { open_id : "ou-topic-user" } } ,
1169+ message : {
1170+ message_id : "msg-scope-topic-thread-id" ,
1171+ chat_id : "oc-group" ,
1172+ chat_type : "group" ,
1173+ root_id : "om_root_topic" ,
1174+ thread_id : "omt_topic_1" ,
1175+ message_type : "text" ,
1176+ content : JSON . stringify ( { text : "topic sender scope" } ) ,
1177+ } ,
1178+ } ;
1179+
1180+ await dispatchMessage ( { cfg, event } ) ;
1181+
1182+ expect ( mockResolveAgentRoute ) . toHaveBeenCalledWith (
1183+ expect . objectContaining ( {
1184+ peer : { kind : "group" , id : "oc-group:topic:om_root_topic:sender:ou-topic-user" } ,
1185+ parentPeer : { kind : "group" , id : "oc-group" } ,
1186+ } ) ,
1187+ ) ;
1188+ } ) ;
1189+
1190+ it ( "uses thread_id as topic key when root_id is missing" , async ( ) => {
1191+ mockShouldComputeCommandAuthorized . mockReturnValue ( false ) ;
1192+
1193+ const cfg : ClawdbotConfig = {
1194+ channels : {
1195+ feishu : {
1196+ groups : {
1197+ "oc-group" : {
1198+ requireMention : false ,
1199+ groupSessionScope : "group_topic_sender" ,
1200+ } ,
1201+ } ,
1202+ } ,
1203+ } ,
1204+ } as ClawdbotConfig ;
1205+
1206+ const event : FeishuMessageEvent = {
1207+ sender : { sender_id : { open_id : "ou-topic-user" } } ,
1208+ message : {
1209+ message_id : "msg-scope-topic-thread-only" ,
1210+ chat_id : "oc-group" ,
1211+ chat_type : "group" ,
1212+ thread_id : "omt_topic_1" ,
1213+ message_type : "text" ,
1214+ content : JSON . stringify ( { text : "topic sender scope" } ) ,
1215+ } ,
1216+ } ;
1217+
1218+ await dispatchMessage ( { cfg, event } ) ;
1219+
1220+ expect ( mockResolveAgentRoute ) . toHaveBeenCalledWith (
1221+ expect . objectContaining ( {
1222+ peer : { kind : "group" , id : "oc-group:topic:omt_topic_1:sender:ou-topic-user" } ,
1223+ parentPeer : { kind : "group" , id : "oc-group" } ,
1224+ } ) ,
1225+ ) ;
1226+ } ) ;
1227+
11511228 it ( "maps legacy topicSessionMode=enabled to group_topic routing" , async ( ) => {
11521229 mockShouldComputeCommandAuthorized . mockReturnValue ( false ) ;
11531230
@@ -1186,6 +1263,45 @@ describe("handleFeishuMessage command authorization", () => {
11861263 ) ;
11871264 } ) ;
11881265
1266+ it ( "maps legacy topicSessionMode=enabled to root_id when both root_id and thread_id exist" , async ( ) => {
1267+ mockShouldComputeCommandAuthorized . mockReturnValue ( false ) ;
1268+
1269+ const cfg : ClawdbotConfig = {
1270+ channels : {
1271+ feishu : {
1272+ topicSessionMode : "enabled" ,
1273+ groups : {
1274+ "oc-group" : {
1275+ requireMention : false ,
1276+ } ,
1277+ } ,
1278+ } ,
1279+ } ,
1280+ } as ClawdbotConfig ;
1281+
1282+ const event : FeishuMessageEvent = {
1283+ sender : { sender_id : { open_id : "ou-legacy-thread-id" } } ,
1284+ message : {
1285+ message_id : "msg-legacy-topic-thread-id" ,
1286+ chat_id : "oc-group" ,
1287+ chat_type : "group" ,
1288+ root_id : "om_root_legacy" ,
1289+ thread_id : "omt_topic_legacy" ,
1290+ message_type : "text" ,
1291+ content : JSON . stringify ( { text : "legacy topic mode" } ) ,
1292+ } ,
1293+ } ;
1294+
1295+ await dispatchMessage ( { cfg, event } ) ;
1296+
1297+ expect ( mockResolveAgentRoute ) . toHaveBeenCalledWith (
1298+ expect . objectContaining ( {
1299+ peer : { kind : "group" , id : "oc-group:topic:om_root_legacy" } ,
1300+ parentPeer : { kind : "group" , id : "oc-group" } ,
1301+ } ) ,
1302+ ) ;
1303+ } ) ;
1304+
11891305 it ( "uses message_id as topic root when group_topic + replyInThread and no root_id" , async ( ) => {
11901306 mockShouldComputeCommandAuthorized . mockReturnValue ( false ) ;
11911307
@@ -1224,6 +1340,102 @@ describe("handleFeishuMessage command authorization", () => {
12241340 ) ;
12251341 } ) ;
12261342
1343+ it ( "keeps topic session key stable after first turn creates a thread" , async ( ) => {
1344+ mockShouldComputeCommandAuthorized . mockReturnValue ( false ) ;
1345+
1346+ const cfg : ClawdbotConfig = {
1347+ channels : {
1348+ feishu : {
1349+ groups : {
1350+ "oc-group" : {
1351+ requireMention : false ,
1352+ groupSessionScope : "group_topic" ,
1353+ replyInThread : "enabled" ,
1354+ } ,
1355+ } ,
1356+ } ,
1357+ } ,
1358+ } as ClawdbotConfig ;
1359+
1360+ const firstTurn : FeishuMessageEvent = {
1361+ sender : { sender_id : { open_id : "ou-topic-init" } } ,
1362+ message : {
1363+ message_id : "msg-topic-first" ,
1364+ chat_id : "oc-group" ,
1365+ chat_type : "group" ,
1366+ message_type : "text" ,
1367+ content : JSON . stringify ( { text : "create topic" } ) ,
1368+ } ,
1369+ } ;
1370+ const secondTurn : FeishuMessageEvent = {
1371+ sender : { sender_id : { open_id : "ou-topic-init" } } ,
1372+ message : {
1373+ message_id : "msg-topic-second" ,
1374+ chat_id : "oc-group" ,
1375+ chat_type : "group" ,
1376+ root_id : "msg-topic-first" ,
1377+ thread_id : "omt_topic_created" ,
1378+ message_type : "text" ,
1379+ content : JSON . stringify ( { text : "follow up in same topic" } ) ,
1380+ } ,
1381+ } ;
1382+
1383+ await dispatchMessage ( { cfg, event : firstTurn } ) ;
1384+ await dispatchMessage ( { cfg, event : secondTurn } ) ;
1385+
1386+ expect ( mockResolveAgentRoute ) . toHaveBeenNthCalledWith (
1387+ 1 ,
1388+ expect . objectContaining ( {
1389+ peer : { kind : "group" , id : "oc-group:topic:msg-topic-first" } ,
1390+ } ) ,
1391+ ) ;
1392+ expect ( mockResolveAgentRoute ) . toHaveBeenNthCalledWith (
1393+ 2 ,
1394+ expect . objectContaining ( {
1395+ peer : { kind : "group" , id : "oc-group:topic:msg-topic-first" } ,
1396+ } ) ,
1397+ ) ;
1398+ } ) ;
1399+
1400+ it ( "forces thread replies when inbound message contains thread_id" , async ( ) => {
1401+ mockShouldComputeCommandAuthorized . mockReturnValue ( false ) ;
1402+
1403+ const cfg : ClawdbotConfig = {
1404+ channels : {
1405+ feishu : {
1406+ groups : {
1407+ "oc-group" : {
1408+ requireMention : false ,
1409+ groupSessionScope : "group" ,
1410+ replyInThread : "disabled" ,
1411+ } ,
1412+ } ,
1413+ } ,
1414+ } ,
1415+ } as ClawdbotConfig ;
1416+
1417+ const event : FeishuMessageEvent = {
1418+ sender : { sender_id : { open_id : "ou-thread-reply" } } ,
1419+ message : {
1420+ message_id : "msg-thread-reply" ,
1421+ chat_id : "oc-group" ,
1422+ chat_type : "group" ,
1423+ thread_id : "omt_topic_thread_reply" ,
1424+ message_type : "text" ,
1425+ content : JSON . stringify ( { text : "thread content" } ) ,
1426+ } ,
1427+ } ;
1428+
1429+ await dispatchMessage ( { cfg, event } ) ;
1430+
1431+ expect ( mockCreateFeishuReplyDispatcher ) . toHaveBeenCalledWith (
1432+ expect . objectContaining ( {
1433+ replyInThread : true ,
1434+ threadReply : true ,
1435+ } ) ,
1436+ ) ;
1437+ } ) ;
1438+
12271439 it ( "does not dispatch twice for the same image message_id (concurrent dedupe)" , async ( ) => {
12281440 mockShouldComputeCommandAuthorized . mockReturnValue ( false ) ;
12291441
0 commit comments