Skip to content

Commit 7c15c27

Browse files
ArthurNiesteipete
andauthored
fix(feishu): fallback when accepted turns send no visible reply (#87896)
* fix(feishu): fallback when accepted turns send no visible reply * fix(feishu): cover no-visible-reply fallback gaps * fix(feishu): mark media replies visible * fix(feishu): honor suppressed delivery fallback * test(auto-reply): trim fallback test churn * fix(feishu): gate empty fallback eligibility * test(auto-reply): expect fallback metadata after denied dispatch * fix(feishu): fallback after failed visible final sends * test(feishu): keep reply dispatcher mock shape aligned * fix(auto-reply): respect silent policy for no-visible fallback * fix(feishu): wait for streaming close before fallback * fix(feishu): clear silent skip before later finals * fix(feishu): preserve visible state across keepalives * test(feishu): align lifecycle dispatcher mocks * fix(feishu): require accepted streaming content for fallback --------- Co-authored-by: ArthurNie <[email protected]> Co-authored-by: Peter Steinberger <[email protected]>
1 parent e681569 commit 7c15c27

13 files changed

Lines changed: 732 additions & 48 deletions

extensions/feishu/src/bot.broadcast.test.ts

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ const { mockCreateFeishuReplyDispatcher, mockCreateFeishuClient, mockResolveAgen
1919
},
2020
replyOptions: {},
2121
markDispatchIdle: vi.fn(),
22+
ensureNoVisibleReplyFallback: vi.fn(),
2223
})),
2324
mockCreateFeishuClient: vi.fn(),
2425
mockResolveAgentRoute: vi.fn(),
@@ -227,6 +228,20 @@ describe("broadcast dispatch", () => {
227228
lastRoutePolicy: "session",
228229
matchedBy: "default",
229230
});
231+
mockCreateFeishuReplyDispatcher.mockReturnValue({
232+
dispatcher: {
233+
sendToolResult: vi.fn(),
234+
sendBlockReply: vi.fn(),
235+
sendFinalReply: vi.fn(),
236+
waitForIdle: vi.fn(),
237+
getQueuedCounts: vi.fn(() => ({ tool: 0, block: 0, final: 0 })),
238+
getFailedCounts: vi.fn(() => ({ tool: 0, block: 0, final: 0 })),
239+
markComplete: vi.fn(),
240+
},
241+
replyOptions: {},
242+
markDispatchIdle: vi.fn(),
243+
ensureNoVisibleReplyFallback: vi.fn(),
244+
});
230245
mockCreateFeishuClient.mockReturnValue({
231246
contact: {
232247
user: {
@@ -329,6 +344,130 @@ describe("broadcast dispatch", () => {
329344
expect(dispatcherParams?.agentId).toBe("main");
330345
});
331346

347+
it("sends no-visible-reply fallback for active broadcast zero-final dispatch", async () => {
348+
mockDispatchReplyFromConfig
349+
.mockResolvedValueOnce({ queuedFinal: false, counts: { final: 1 } })
350+
.mockResolvedValueOnce({
351+
queuedFinal: false,
352+
counts: { final: 0 },
353+
noVisibleReplyFallbackEligible: true,
354+
});
355+
const ensureNoVisibleReplyFallback = vi.fn();
356+
mockCreateFeishuReplyDispatcher.mockReturnValueOnce({
357+
dispatcher: {
358+
sendToolResult: vi.fn(),
359+
sendBlockReply: vi.fn(),
360+
sendFinalReply: vi.fn(),
361+
waitForIdle: vi.fn(),
362+
getQueuedCounts: vi.fn(() => ({ tool: 0, block: 0, final: 0 })),
363+
getFailedCounts: vi.fn(() => ({ tool: 0, block: 0, final: 0 })),
364+
markComplete: vi.fn(),
365+
},
366+
replyOptions: {},
367+
markDispatchIdle: vi.fn(),
368+
ensureNoVisibleReplyFallback,
369+
});
370+
const cfg = createBroadcastConfig();
371+
const event = createBroadcastEvent({
372+
messageId: "msg-broadcast-zero-final",
373+
text: "hello @bot",
374+
botMentioned: true,
375+
});
376+
377+
await handleFeishuMessage({
378+
cfg,
379+
event,
380+
botOpenId: "bot-open-id",
381+
runtime: createRuntimeEnv(),
382+
});
383+
384+
expect(ensureNoVisibleReplyFallback).toHaveBeenCalledWith(
385+
"broadcast-dispatch-complete-no-visible-reply",
386+
);
387+
});
388+
389+
it("sends no-visible-reply fallback for active broadcast failed final delivery", async () => {
390+
mockDispatchReplyFromConfig
391+
.mockResolvedValueOnce({ queuedFinal: false, counts: { final: 1 } })
392+
.mockResolvedValueOnce({
393+
queuedFinal: true,
394+
counts: { final: 1 },
395+
});
396+
const ensureNoVisibleReplyFallback = vi.fn();
397+
mockCreateFeishuReplyDispatcher.mockReturnValueOnce({
398+
dispatcher: {
399+
sendToolResult: vi.fn(),
400+
sendBlockReply: vi.fn(),
401+
sendFinalReply: vi.fn(),
402+
waitForIdle: vi.fn(),
403+
getQueuedCounts: vi.fn(() => ({ tool: 0, block: 0, final: 0 })),
404+
getFailedCounts: vi.fn(() => ({ tool: 0, block: 0, final: 1 })),
405+
markComplete: vi.fn(),
406+
},
407+
replyOptions: {},
408+
markDispatchIdle: vi.fn(),
409+
ensureNoVisibleReplyFallback,
410+
});
411+
const cfg = createBroadcastConfig();
412+
const event = createBroadcastEvent({
413+
messageId: "msg-broadcast-final-failed",
414+
text: "hello @bot",
415+
botMentioned: true,
416+
});
417+
418+
await handleFeishuMessage({
419+
cfg,
420+
event,
421+
botOpenId: "bot-open-id",
422+
runtime: createRuntimeEnv(),
423+
});
424+
425+
expect(ensureNoVisibleReplyFallback).toHaveBeenCalledWith(
426+
"broadcast-dispatch-complete-no-visible-reply",
427+
);
428+
});
429+
430+
it("skips no-visible-reply fallback for source-suppressed active broadcast dispatch", async () => {
431+
mockDispatchReplyFromConfig
432+
.mockResolvedValueOnce({ queuedFinal: false, counts: { final: 1 } })
433+
.mockResolvedValueOnce({
434+
queuedFinal: false,
435+
counts: { final: 0 },
436+
sourceReplyDeliveryMode: "message_tool_only",
437+
noVisibleReplyFallbackEligible: true,
438+
});
439+
const ensureNoVisibleReplyFallback = vi.fn();
440+
mockCreateFeishuReplyDispatcher.mockReturnValueOnce({
441+
dispatcher: {
442+
sendToolResult: vi.fn(),
443+
sendBlockReply: vi.fn(),
444+
sendFinalReply: vi.fn(),
445+
waitForIdle: vi.fn(),
446+
getQueuedCounts: vi.fn(() => ({ tool: 0, block: 0, final: 0 })),
447+
getFailedCounts: vi.fn(() => ({ tool: 0, block: 0, final: 0 })),
448+
markComplete: vi.fn(),
449+
},
450+
replyOptions: {},
451+
markDispatchIdle: vi.fn(),
452+
ensureNoVisibleReplyFallback,
453+
});
454+
const cfg = createBroadcastConfig();
455+
const event = createBroadcastEvent({
456+
messageId: "msg-broadcast-source-suppressed",
457+
text: "hello @bot",
458+
botMentioned: true,
459+
});
460+
461+
await handleFeishuMessage({
462+
cfg,
463+
event,
464+
botOpenId: "bot-open-id",
465+
runtime: createRuntimeEnv(),
466+
});
467+
468+
expect(ensureNoVisibleReplyFallback).not.toHaveBeenCalled();
469+
});
470+
332471
it("skips broadcast dispatch when bot is NOT mentioned (requireMention=true)", async () => {
333472
const cfg = createBroadcastConfig();
334473
const event = createBroadcastEvent({

extensions/feishu/src/bot.test.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,7 @@ function createFeishuBotRuntime(overrides: DeepPartial<PluginRuntime> = {}): Plu
210210
onRecordError: turn.record?.onRecordError ?? (() => undefined),
211211
});
212212
return {
213+
dispatched: true,
213214
dispatchResult: await turn.runDispatch(),
214215
};
215216
}),
@@ -294,6 +295,7 @@ const {
294295
dispatcher: createReplyDispatcher(),
295296
replyOptions: {},
296297
markDispatchIdle: vi.fn(),
298+
ensureNoVisibleReplyFallback: vi.fn(),
297299
})),
298300
mockSendMessageFeishu: vi.fn().mockResolvedValue({ messageId: "pairing-msg", chatId: "oc-dm" }),
299301
mockGetMessageFeishu: vi.fn().mockResolvedValue(null),
@@ -464,6 +466,7 @@ describe("handleFeishuMessage ACP routing", () => {
464466
dispatcher: createReplyDispatcher(),
465467
replyOptions: {},
466468
markDispatchIdle: vi.fn(),
469+
ensureNoVisibleReplyFallback: vi.fn(),
467470
});
468471

469472
setFeishuRuntime(createFeishuBotRuntime());
@@ -1046,6 +1049,90 @@ describe("handleFeishuMessage command authorization", () => {
10461049
expect(mockEnqueueSystemEvent).not.toHaveBeenCalled();
10471050
});
10481051

1052+
it("does not send no-visible fallback when send policy denied delivery", async () => {
1053+
mockDispatchReplyFromConfig.mockResolvedValueOnce({
1054+
queuedFinal: false,
1055+
counts: { tool: 0, block: 0, final: 0 },
1056+
sendPolicyDenied: true,
1057+
noVisibleReplyFallbackEligible: true,
1058+
});
1059+
const ensureNoVisibleReplyFallback = vi.fn();
1060+
mockCreateFeishuReplyDispatcher.mockReturnValueOnce({
1061+
dispatcher: createReplyDispatcher(),
1062+
replyOptions: {},
1063+
markDispatchIdle: vi.fn(),
1064+
ensureNoVisibleReplyFallback,
1065+
});
1066+
1067+
await dispatchMessage({
1068+
cfg: {
1069+
channels: {
1070+
feishu: {
1071+
dmPolicy: "open",
1072+
},
1073+
},
1074+
} as ClawdbotConfig,
1075+
event: {
1076+
sender: {
1077+
sender_id: {
1078+
open_id: "ou-sender",
1079+
},
1080+
},
1081+
message: {
1082+
message_id: "msg-send-policy-deny",
1083+
chat_id: "oc-dm",
1084+
chat_type: "p2p",
1085+
message_type: "text",
1086+
content: JSON.stringify({ text: "hello" }),
1087+
},
1088+
},
1089+
});
1090+
1091+
expect(ensureNoVisibleReplyFallback).not.toHaveBeenCalled();
1092+
});
1093+
1094+
it("sends no-visible fallback when queued final delivery fails", async () => {
1095+
mockDispatchReplyFromConfig.mockResolvedValueOnce({
1096+
queuedFinal: true,
1097+
counts: { tool: 0, block: 0, final: 1 },
1098+
});
1099+
const ensureNoVisibleReplyFallback = vi.fn();
1100+
const dispatcher = createReplyDispatcher();
1101+
vi.mocked(dispatcher.getFailedCounts).mockReturnValue({ tool: 0, block: 0, final: 1 });
1102+
mockCreateFeishuReplyDispatcher.mockReturnValueOnce({
1103+
dispatcher,
1104+
replyOptions: {},
1105+
markDispatchIdle: vi.fn(),
1106+
ensureNoVisibleReplyFallback,
1107+
});
1108+
1109+
await dispatchMessage({
1110+
cfg: {
1111+
channels: {
1112+
feishu: {
1113+
dmPolicy: "open",
1114+
},
1115+
},
1116+
} as ClawdbotConfig,
1117+
event: {
1118+
sender: {
1119+
sender_id: {
1120+
open_id: "ou-sender",
1121+
},
1122+
},
1123+
message: {
1124+
message_id: "msg-final-delivery-failed",
1125+
chat_id: "oc-dm",
1126+
chat_type: "p2p",
1127+
message_type: "text",
1128+
content: JSON.stringify({ text: "hello" }),
1129+
},
1130+
},
1131+
});
1132+
1133+
expect(ensureNoVisibleReplyFallback).toHaveBeenCalledWith("dispatch-complete-no-visible-reply");
1134+
});
1135+
10491136
it("passes disabled config-write policy to dynamic agent creation", async () => {
10501137
mockShouldComputeCommandAuthorized.mockReturnValue(false);
10511138

0 commit comments

Comments
 (0)