@openclaw/feishu 2026.3.13 → 2026.5.1-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/api.ts +31 -0
- package/channel-entry.ts +20 -0
- package/channel-plugin-api.ts +1 -0
- package/contract-api.ts +16 -0
- package/index.ts +70 -53
- package/openclaw.plugin.json +1653 -4
- package/package.json +32 -7
- package/runtime-api.ts +55 -0
- package/secret-contract-api.ts +5 -0
- package/security-contract-api.ts +1 -0
- package/session-key-api.ts +1 -0
- package/setup-api.ts +3 -0
- package/setup-entry.test.ts +14 -0
- package/setup-entry.ts +13 -0
- package/src/accounts.test.ts +95 -7
- package/src/accounts.ts +199 -117
- package/src/app-registration.ts +331 -0
- package/src/approval-auth.test.ts +24 -0
- package/src/approval-auth.ts +25 -0
- package/src/async.test.ts +35 -0
- package/src/async.ts +43 -1
- package/src/audio-preflight.runtime.ts +9 -0
- package/src/bitable.test.ts +131 -0
- package/src/bitable.ts +59 -22
- package/src/bot-content.ts +474 -0
- package/src/bot-group-name.test.ts +108 -0
- package/src/bot-runtime-api.ts +12 -0
- package/src/bot-sender-name.ts +125 -0
- package/src/bot.broadcast.test.ts +463 -0
- package/src/bot.card-action.test.ts +519 -5
- package/src/bot.checkBotMentioned.test.ts +92 -20
- package/src/bot.helpers.test.ts +118 -0
- package/src/bot.stripBotMention.test.ts +13 -21
- package/src/bot.test.ts +1334 -401
- package/src/bot.ts +778 -775
- package/src/card-action.ts +408 -40
- package/src/card-interaction.test.ts +129 -0
- package/src/card-interaction.ts +159 -0
- package/src/card-test-helpers.ts +47 -0
- package/src/card-ux-approval.ts +65 -0
- package/src/card-ux-launcher.test.ts +99 -0
- package/src/card-ux-launcher.ts +121 -0
- package/src/card-ux-shared.ts +33 -0
- package/src/channel-runtime-api.ts +16 -0
- package/src/channel.runtime.ts +47 -0
- package/src/channel.test.ts +914 -3
- package/src/channel.ts +1252 -309
- package/src/chat-schema.ts +5 -4
- package/src/chat.test.ts +84 -28
- package/src/chat.ts +68 -10
- package/src/client.test.ts +212 -103
- package/src/client.ts +115 -21
- package/src/comment-dispatcher-runtime-api.ts +6 -0
- package/src/comment-dispatcher.test.ts +169 -0
- package/src/comment-dispatcher.ts +107 -0
- package/src/comment-handler-runtime-api.ts +3 -0
- package/src/comment-handler.test.ts +486 -0
- package/src/comment-handler.ts +309 -0
- package/src/comment-reaction.test.ts +166 -0
- package/src/comment-reaction.ts +259 -0
- package/src/comment-shared.test.ts +182 -0
- package/src/comment-shared.ts +365 -0
- package/src/comment-target.ts +44 -0
- package/src/config-schema.test.ts +63 -1
- package/src/config-schema.ts +31 -4
- package/src/conversation-id.test.ts +18 -0
- package/src/conversation-id.ts +199 -0
- package/src/dedup-runtime-api.ts +1 -0
- package/src/dedup.ts +32 -94
- package/src/directory.static.ts +61 -0
- package/src/directory.test.ts +119 -20
- package/src/directory.ts +61 -91
- package/src/doc-schema.ts +1 -1
- package/src/docx-batch-insert.test.ts +39 -38
- package/src/docx-batch-insert.ts +55 -19
- package/src/docx-color-text.ts +9 -4
- package/src/docx-table-ops.test.ts +53 -0
- package/src/docx-table-ops.ts +52 -34
- package/src/docx-types.ts +38 -0
- package/src/docx.account-selection.test.ts +12 -3
- package/src/docx.test.ts +314 -74
- package/src/docx.ts +278 -122
- package/src/drive-schema.ts +47 -1
- package/src/drive.test.ts +1219 -0
- package/src/drive.ts +614 -13
- package/src/dynamic-agent.ts +10 -4
- package/src/event-types.ts +45 -0
- package/src/external-keys.ts +1 -1
- package/src/lifecycle.test-support.ts +220 -0
- package/src/media.test.ts +375 -26
- package/src/media.ts +434 -88
- package/src/mention-target.types.ts +5 -0
- package/src/mention.ts +32 -51
- package/src/message-action-contract.ts +13 -0
- package/src/monitor-state-runtime-api.ts +7 -0
- package/src/monitor-transport-runtime-api.ts +7 -0
- package/src/monitor.account.ts +218 -312
- package/src/monitor.acp-init-failure.lifecycle.test-support.ts +219 -0
- package/src/monitor.bot-identity.ts +86 -0
- package/src/monitor.bot-menu-handler.ts +165 -0
- package/src/monitor.bot-menu.lifecycle.test-support.ts +224 -0
- package/src/monitor.bot-menu.test.ts +178 -0
- package/src/monitor.broadcast.reply-once.lifecycle.test-support.ts +264 -0
- package/src/monitor.card-action.lifecycle.test-support.ts +373 -0
- package/src/monitor.cleanup.test.ts +376 -0
- package/src/monitor.comment-notice-handler.ts +105 -0
- package/src/monitor.comment.test.ts +937 -0
- package/src/monitor.comment.ts +1386 -0
- package/src/monitor.lifecycle.test.ts +4 -0
- package/src/monitor.message-handler.ts +339 -0
- package/src/monitor.reaction.lifecycle.test-support.ts +68 -0
- package/src/monitor.reaction.test.ts +108 -48
- package/src/monitor.reply-once.lifecycle.test-support.ts +190 -0
- package/src/monitor.startup.test.ts +11 -9
- package/src/monitor.startup.ts +26 -16
- package/src/monitor.state.ts +20 -5
- package/src/monitor.synthetic-error.ts +18 -0
- package/src/monitor.test-mocks.ts +2 -2
- package/src/monitor.transport.ts +220 -60
- package/src/monitor.ts +15 -10
- package/src/monitor.webhook-e2e.test.ts +65 -7
- package/src/monitor.webhook-security.test.ts +122 -0
- package/src/monitor.webhook.test-helpers.ts +44 -26
- package/src/outbound-runtime-api.ts +1 -0
- package/src/outbound.test.ts +616 -37
- package/src/outbound.ts +623 -81
- package/src/perm-schema.ts +1 -1
- package/src/perm.ts +1 -7
- package/src/pins.ts +108 -0
- package/src/policy.test.ts +297 -117
- package/src/policy.ts +142 -29
- package/src/post.ts +7 -6
- package/src/probe.test.ts +14 -9
- package/src/probe.ts +26 -16
- package/src/processing-claims.ts +59 -0
- package/src/qr-terminal.ts +1 -0
- package/src/reactions.ts +4 -34
- package/src/reasoning-preview.test.ts +59 -0
- package/src/reasoning-preview.ts +20 -0
- package/src/reply-dispatcher-runtime-api.ts +7 -0
- package/src/reply-dispatcher.test.ts +660 -29
- package/src/reply-dispatcher.ts +407 -154
- package/src/runtime.ts +6 -3
- package/src/secret-contract.ts +145 -0
- package/src/secret-input.ts +1 -13
- package/src/security-audit-shared.ts +69 -0
- package/src/security-audit.test.ts +61 -0
- package/src/security-audit.ts +1 -0
- package/src/send-result.ts +1 -1
- package/src/send-target.test.ts +9 -3
- package/src/send-target.ts +10 -4
- package/src/send.reply-fallback.test.ts +77 -2
- package/src/send.test.ts +386 -4
- package/src/send.ts +399 -86
- package/src/sequential-key.test.ts +72 -0
- package/src/sequential-key.ts +28 -0
- package/src/sequential-queue.test.ts +92 -0
- package/src/sequential-queue.ts +16 -0
- package/src/session-conversation.ts +42 -0
- package/src/session-route.ts +48 -0
- package/src/setup-core.ts +51 -0
- package/src/{onboarding.test.ts → setup-surface.test.ts} +52 -21
- package/src/setup-surface.ts +581 -0
- package/src/streaming-card.test.ts +138 -2
- package/src/streaming-card.ts +134 -18
- package/src/subagent-hooks.test.ts +603 -0
- package/src/subagent-hooks.ts +397 -0
- package/src/targets.ts +3 -13
- package/src/test-support/lifecycle-test-support.ts +479 -0
- package/src/thread-bindings.test.ts +143 -0
- package/src/thread-bindings.ts +330 -0
- package/src/tool-account-routing.test.ts +66 -8
- package/src/tool-account.test.ts +44 -0
- package/src/tool-account.ts +40 -17
- package/src/tool-factory-test-harness.ts +11 -8
- package/src/tool-result.ts +3 -1
- package/src/tools-config.ts +1 -1
- package/src/types.ts +16 -15
- package/src/typing.ts +10 -6
- package/src/wiki-schema.ts +1 -1
- package/src/wiki.ts +1 -7
- package/subagent-hooks-api.ts +31 -0
- package/tsconfig.json +16 -0
- package/src/feishu-command-handler.ts +0 -59
- package/src/onboarding.status.test.ts +0 -25
- package/src/onboarding.ts +0 -489
- package/src/send-message.ts +0 -71
- package/src/targets.test.ts +0 -70
|
@@ -1,22 +1,62 @@
|
|
|
1
1
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
2
|
|
|
3
|
+
type StreamingSessionStub = {
|
|
4
|
+
active: boolean;
|
|
5
|
+
start: ReturnType<typeof vi.fn>;
|
|
6
|
+
update: ReturnType<typeof vi.fn>;
|
|
7
|
+
close: ReturnType<typeof vi.fn>;
|
|
8
|
+
isActive: ReturnType<typeof vi.fn>;
|
|
9
|
+
};
|
|
10
|
+
|
|
3
11
|
const resolveFeishuAccountMock = vi.hoisted(() => vi.fn());
|
|
4
12
|
const getFeishuRuntimeMock = vi.hoisted(() => vi.fn());
|
|
5
13
|
const sendMessageFeishuMock = vi.hoisted(() => vi.fn());
|
|
6
14
|
const sendMarkdownCardFeishuMock = vi.hoisted(() => vi.fn());
|
|
15
|
+
const sendStructuredCardFeishuMock = vi.hoisted(() => vi.fn());
|
|
7
16
|
const sendMediaFeishuMock = vi.hoisted(() => vi.fn());
|
|
8
17
|
const createFeishuClientMock = vi.hoisted(() => vi.fn());
|
|
9
18
|
const resolveReceiveIdTypeMock = vi.hoisted(() => vi.fn());
|
|
10
19
|
const createReplyDispatcherWithTypingMock = vi.hoisted(() => vi.fn());
|
|
11
20
|
const addTypingIndicatorMock = vi.hoisted(() => vi.fn(async () => ({ messageId: "om_msg" })));
|
|
12
21
|
const removeTypingIndicatorMock = vi.hoisted(() => vi.fn(async () => {}));
|
|
13
|
-
const streamingInstances = vi.hoisted(()
|
|
22
|
+
const streamingInstances = vi.hoisted((): StreamingSessionStub[] => []);
|
|
23
|
+
|
|
24
|
+
function mergeStreamingText(
|
|
25
|
+
previousText: string | undefined,
|
|
26
|
+
nextText: string | undefined,
|
|
27
|
+
): string {
|
|
28
|
+
const previous = typeof previousText === "string" ? previousText : "";
|
|
29
|
+
const next = typeof nextText === "string" ? nextText : "";
|
|
30
|
+
if (!next) {
|
|
31
|
+
return previous;
|
|
32
|
+
}
|
|
33
|
+
if (!previous || next === previous) {
|
|
34
|
+
return next;
|
|
35
|
+
}
|
|
36
|
+
if (next.startsWith(previous) || next.includes(previous)) {
|
|
37
|
+
return next;
|
|
38
|
+
}
|
|
39
|
+
if (previous.startsWith(next) || previous.includes(next)) {
|
|
40
|
+
return previous;
|
|
41
|
+
}
|
|
42
|
+
const maxOverlap = Math.min(previous.length, next.length);
|
|
43
|
+
for (let overlap = maxOverlap; overlap > 0; overlap -= 1) {
|
|
44
|
+
if (previous.slice(-overlap) === next.slice(0, overlap)) {
|
|
45
|
+
return `${previous}${next.slice(overlap)}`;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return `${previous}${next}`;
|
|
49
|
+
}
|
|
14
50
|
|
|
15
|
-
vi.mock("./accounts.js", () => ({
|
|
51
|
+
vi.mock("./accounts.js", () => ({
|
|
52
|
+
resolveFeishuAccount: resolveFeishuAccountMock,
|
|
53
|
+
resolveFeishuRuntimeAccount: resolveFeishuAccountMock,
|
|
54
|
+
}));
|
|
16
55
|
vi.mock("./runtime.js", () => ({ getFeishuRuntime: getFeishuRuntimeMock }));
|
|
17
56
|
vi.mock("./send.js", () => ({
|
|
18
57
|
sendMessageFeishu: sendMessageFeishuMock,
|
|
19
58
|
sendMarkdownCardFeishu: sendMarkdownCardFeishuMock,
|
|
59
|
+
sendStructuredCardFeishu: sendStructuredCardFeishuMock,
|
|
20
60
|
}));
|
|
21
61
|
vi.mock("./media.js", () => ({ sendMediaFeishu: sendMediaFeishuMock }));
|
|
22
62
|
vi.mock("./client.js", () => ({ createFeishuClient: createFeishuClientMock }));
|
|
@@ -25,10 +65,9 @@ vi.mock("./typing.js", () => ({
|
|
|
25
65
|
addTypingIndicator: addTypingIndicatorMock,
|
|
26
66
|
removeTypingIndicator: removeTypingIndicatorMock,
|
|
27
67
|
}));
|
|
28
|
-
vi.mock("./streaming-card.js",
|
|
29
|
-
const actual = await vi.importActual<typeof import("./streaming-card.js")>("./streaming-card.js");
|
|
68
|
+
vi.mock("./streaming-card.js", () => {
|
|
30
69
|
return {
|
|
31
|
-
mergeStreamingText
|
|
70
|
+
mergeStreamingText,
|
|
32
71
|
FeishuStreamingSession: class {
|
|
33
72
|
active = false;
|
|
34
73
|
start = vi.fn(async () => {
|
|
@@ -47,15 +86,20 @@ vi.mock("./streaming-card.js", async () => {
|
|
|
47
86
|
};
|
|
48
87
|
});
|
|
49
88
|
|
|
50
|
-
import {
|
|
89
|
+
import {
|
|
90
|
+
clearFeishuStreamingStartBackoffForTests,
|
|
91
|
+
createFeishuReplyDispatcher,
|
|
92
|
+
} from "./reply-dispatcher.js";
|
|
51
93
|
|
|
52
94
|
describe("createFeishuReplyDispatcher streaming behavior", () => {
|
|
53
95
|
type ReplyDispatcherArgs = Parameters<typeof createFeishuReplyDispatcher>[0];
|
|
54
96
|
|
|
55
97
|
beforeEach(() => {
|
|
56
98
|
vi.clearAllMocks();
|
|
99
|
+
clearFeishuStreamingStartBackoffForTests();
|
|
57
100
|
streamingInstances.length = 0;
|
|
58
101
|
sendMediaFeishuMock.mockResolvedValue(undefined);
|
|
102
|
+
sendStructuredCardFeishuMock.mockResolvedValue(undefined);
|
|
59
103
|
|
|
60
104
|
resolveFeishuAccountMock.mockReturnValue({
|
|
61
105
|
accountId: "main",
|
|
@@ -252,14 +296,21 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
|
|
|
252
296
|
rootId: "om_root_topic",
|
|
253
297
|
});
|
|
254
298
|
await options.deliver({ text: "```ts\nconst x = 1\n```" }, { kind: "final" });
|
|
299
|
+
await options.onIdle?.();
|
|
255
300
|
|
|
256
301
|
expect(streamingInstances).toHaveLength(1);
|
|
257
302
|
expect(streamingInstances[0].start).toHaveBeenCalledTimes(1);
|
|
258
|
-
expect(streamingInstances[0].start).toHaveBeenCalledWith(
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
303
|
+
expect(streamingInstances[0].start).toHaveBeenCalledWith(
|
|
304
|
+
"oc_chat",
|
|
305
|
+
"chat_id",
|
|
306
|
+
expect.objectContaining({
|
|
307
|
+
replyToMessageId: undefined,
|
|
308
|
+
replyInThread: undefined,
|
|
309
|
+
rootId: "om_root_topic",
|
|
310
|
+
header: { title: "agent", template: "blue" },
|
|
311
|
+
note: "Agent: agent",
|
|
312
|
+
}),
|
|
313
|
+
);
|
|
263
314
|
expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
|
|
264
315
|
expect(sendMessageFeishuMock).not.toHaveBeenCalled();
|
|
265
316
|
expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
|
|
@@ -275,21 +326,27 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
|
|
|
275
326
|
expect(streamingInstances).toHaveLength(1);
|
|
276
327
|
expect(streamingInstances[0].start).toHaveBeenCalledTimes(1);
|
|
277
328
|
expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
|
|
278
|
-
expect(streamingInstances[0].close).toHaveBeenCalledWith("```md\npartial answer\n```"
|
|
329
|
+
expect(streamingInstances[0].close).toHaveBeenCalledWith("```md\npartial answer\n```", {
|
|
330
|
+
note: "Agent: agent",
|
|
331
|
+
});
|
|
279
332
|
});
|
|
280
333
|
|
|
281
|
-
it("
|
|
334
|
+
it("coalesces distinct final payloads into one streaming card until idle", async () => {
|
|
282
335
|
const { options } = createDispatcherHarness({
|
|
283
336
|
runtime: createRuntimeLogger(),
|
|
284
337
|
});
|
|
285
338
|
await options.deliver({ text: "```md\n完整回复第一段\n```" }, { kind: "final" });
|
|
286
339
|
await options.deliver({ text: "```md\n完整回复第一段 + 第二段\n```" }, { kind: "final" });
|
|
340
|
+
await options.onIdle?.();
|
|
287
341
|
|
|
288
|
-
expect(streamingInstances).toHaveLength(
|
|
342
|
+
expect(streamingInstances).toHaveLength(1);
|
|
289
343
|
expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
|
|
290
|
-
expect(streamingInstances[0].close).toHaveBeenCalledWith(
|
|
291
|
-
|
|
292
|
-
|
|
344
|
+
expect(streamingInstances[0].close).toHaveBeenCalledWith(
|
|
345
|
+
"```md\n完整回复第一段 + 第二段\n```",
|
|
346
|
+
{
|
|
347
|
+
note: "Agent: agent",
|
|
348
|
+
},
|
|
349
|
+
);
|
|
293
350
|
expect(sendMessageFeishuMock).not.toHaveBeenCalled();
|
|
294
351
|
expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
|
|
295
352
|
});
|
|
@@ -299,14 +356,89 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
|
|
|
299
356
|
runtime: createRuntimeLogger(),
|
|
300
357
|
});
|
|
301
358
|
await options.deliver({ text: "```md\n同一条回复\n```" }, { kind: "final" });
|
|
359
|
+
await options.onIdle?.();
|
|
302
360
|
await options.deliver({ text: "```md\n同一条回复\n```" }, { kind: "final" });
|
|
303
361
|
|
|
304
362
|
expect(streamingInstances).toHaveLength(1);
|
|
305
363
|
expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
|
|
306
|
-
expect(streamingInstances[0].close).toHaveBeenCalledWith("```md\n同一条回复\n```"
|
|
364
|
+
expect(streamingInstances[0].close).toHaveBeenCalledWith("```md\n同一条回复\n```", {
|
|
365
|
+
note: "Agent: agent",
|
|
366
|
+
});
|
|
307
367
|
expect(sendMessageFeishuMock).not.toHaveBeenCalled();
|
|
308
368
|
expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
|
|
309
369
|
});
|
|
370
|
+
|
|
371
|
+
it("skips final text already closed by idle streaming", async () => {
|
|
372
|
+
resolveFeishuAccountMock.mockReturnValue({
|
|
373
|
+
accountId: "main",
|
|
374
|
+
appId: "app_id",
|
|
375
|
+
appSecret: "app_secret",
|
|
376
|
+
domain: "feishu",
|
|
377
|
+
config: {
|
|
378
|
+
renderMode: "card",
|
|
379
|
+
streaming: true,
|
|
380
|
+
},
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
const { result, options } = createDispatcherHarness({
|
|
384
|
+
runtime: createRuntimeLogger(),
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
await options.onReplyStart?.();
|
|
388
|
+
result.replyOptions.onPartialReply?.({ text: "```md\nidle streamed reply\n```" });
|
|
389
|
+
await options.onIdle?.();
|
|
390
|
+
await options.deliver({ text: "```md\nidle streamed reply\n```" }, { kind: "final" });
|
|
391
|
+
|
|
392
|
+
expect(streamingInstances).toHaveLength(1);
|
|
393
|
+
expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
|
|
394
|
+
expect(streamingInstances[0].close).toHaveBeenCalledWith("```md\nidle streamed reply\n```", {
|
|
395
|
+
note: "Agent: agent",
|
|
396
|
+
});
|
|
397
|
+
expect(sendMessageFeishuMock).not.toHaveBeenCalled();
|
|
398
|
+
expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
|
|
399
|
+
expect(sendStructuredCardFeishuMock).not.toHaveBeenCalled();
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
it("skips distinct late final text after streaming card close", async () => {
|
|
403
|
+
resolveFeishuAccountMock.mockReturnValue({
|
|
404
|
+
accountId: "main",
|
|
405
|
+
appId: "app_id",
|
|
406
|
+
appSecret: "app_secret",
|
|
407
|
+
domain: "feishu",
|
|
408
|
+
config: {
|
|
409
|
+
renderMode: "card",
|
|
410
|
+
streaming: true,
|
|
411
|
+
},
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
const { options } = createDispatcherHarness({
|
|
415
|
+
runtime: createRuntimeLogger(),
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
await options.deliver({ text: "First complete answer" }, { kind: "final" });
|
|
419
|
+
await options.onIdle?.();
|
|
420
|
+
await options.deliver(
|
|
421
|
+
{ text: "Late tool-result final", mediaUrl: "https://example.com/a.png" },
|
|
422
|
+
{ kind: "final" },
|
|
423
|
+
);
|
|
424
|
+
await options.onIdle?.();
|
|
425
|
+
|
|
426
|
+
expect(streamingInstances).toHaveLength(1);
|
|
427
|
+
expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
|
|
428
|
+
expect(streamingInstances[0].close).toHaveBeenCalledWith("First complete answer", {
|
|
429
|
+
note: "Agent: agent",
|
|
430
|
+
});
|
|
431
|
+
expect(sendMessageFeishuMock).not.toHaveBeenCalled();
|
|
432
|
+
expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
|
|
433
|
+
expect(sendStructuredCardFeishuMock).not.toHaveBeenCalled();
|
|
434
|
+
expect(sendMediaFeishuMock).toHaveBeenCalledTimes(1);
|
|
435
|
+
expect(sendMediaFeishuMock).toHaveBeenCalledWith(
|
|
436
|
+
expect.objectContaining({
|
|
437
|
+
mediaUrl: "https://example.com/a.png",
|
|
438
|
+
}),
|
|
439
|
+
);
|
|
440
|
+
});
|
|
441
|
+
|
|
310
442
|
it("suppresses duplicate final text while still sending media", async () => {
|
|
311
443
|
const options = setupNonStreamingAutoDispatcher();
|
|
312
444
|
await options.deliver({ text: "plain final" }, { kind: "final" });
|
|
@@ -361,13 +493,100 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
|
|
|
361
493
|
runtime: createRuntimeLogger(),
|
|
362
494
|
});
|
|
363
495
|
await options.onReplyStart?.();
|
|
364
|
-
|
|
496
|
+
result.replyOptions.onPartialReply?.({ text: "hello" });
|
|
365
497
|
await options.deliver({ text: "lo world" }, { kind: "block" });
|
|
366
498
|
await options.onIdle?.();
|
|
367
499
|
|
|
368
500
|
expect(streamingInstances).toHaveLength(1);
|
|
369
501
|
expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
|
|
370
|
-
expect(streamingInstances[0].close).toHaveBeenCalledWith("hellolo world"
|
|
502
|
+
expect(streamingInstances[0].close).toHaveBeenCalledWith("hellolo world", {
|
|
503
|
+
note: "Agent: agent",
|
|
504
|
+
});
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
it("skips block payloads that exactly repeat the latest partial snapshot", async () => {
|
|
508
|
+
resolveFeishuAccountMock.mockReturnValue({
|
|
509
|
+
accountId: "main",
|
|
510
|
+
appId: "app_id",
|
|
511
|
+
appSecret: "app_secret",
|
|
512
|
+
domain: "feishu",
|
|
513
|
+
config: {
|
|
514
|
+
renderMode: "card",
|
|
515
|
+
streaming: true,
|
|
516
|
+
},
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
const { result, options } = createDispatcherHarness({
|
|
520
|
+
runtime: createRuntimeLogger(),
|
|
521
|
+
});
|
|
522
|
+
await options.onReplyStart?.();
|
|
523
|
+
result.replyOptions.onPartialReply?.({ text: "```md\npartial\n```" });
|
|
524
|
+
await options.deliver({ text: "```md\npartial\n```" }, { kind: "block" });
|
|
525
|
+
await options.onIdle?.();
|
|
526
|
+
|
|
527
|
+
expect(streamingInstances).toHaveLength(1);
|
|
528
|
+
expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
|
|
529
|
+
expect(streamingInstances[0].close).toHaveBeenCalledWith("```md\npartial\n```", {
|
|
530
|
+
note: "Agent: agent",
|
|
531
|
+
});
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
it("preserves previous generation blocks when partial snapshots reset after tools", async () => {
|
|
535
|
+
resolveFeishuAccountMock.mockReturnValue({
|
|
536
|
+
accountId: "main",
|
|
537
|
+
appId: "app_id",
|
|
538
|
+
appSecret: "app_secret",
|
|
539
|
+
domain: "feishu",
|
|
540
|
+
config: {
|
|
541
|
+
renderMode: "card",
|
|
542
|
+
streaming: true,
|
|
543
|
+
},
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
const { result, options } = createDispatcherHarness({
|
|
547
|
+
runtime: createRuntimeLogger(),
|
|
548
|
+
});
|
|
549
|
+
await options.onReplyStart?.();
|
|
550
|
+
result.replyOptions.onPartialReply?.({
|
|
551
|
+
text: "Preparing the lookup plan with enough text to count as one block.",
|
|
552
|
+
});
|
|
553
|
+
result.replyOptions.onPartialReply?.({ text: "Found" });
|
|
554
|
+
result.replyOptions.onPartialReply?.({ text: "Found the answer." });
|
|
555
|
+
await options.onIdle?.();
|
|
556
|
+
|
|
557
|
+
expect(streamingInstances).toHaveLength(1);
|
|
558
|
+
expect(streamingInstances[0].close).toHaveBeenCalledWith(
|
|
559
|
+
"Preparing the lookup plan with enough text to count as one block.Found the answer.",
|
|
560
|
+
{
|
|
561
|
+
note: "Agent: agent",
|
|
562
|
+
},
|
|
563
|
+
);
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
it("strips reasoning tags from streamed partial snapshots", async () => {
|
|
567
|
+
resolveFeishuAccountMock.mockReturnValue({
|
|
568
|
+
accountId: "main",
|
|
569
|
+
appId: "app_id",
|
|
570
|
+
appSecret: "app_secret",
|
|
571
|
+
domain: "feishu",
|
|
572
|
+
config: {
|
|
573
|
+
renderMode: "card",
|
|
574
|
+
streaming: true,
|
|
575
|
+
},
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
const { result, options } = createDispatcherHarness({
|
|
579
|
+
runtime: createRuntimeLogger(),
|
|
580
|
+
});
|
|
581
|
+
await options.onReplyStart?.();
|
|
582
|
+
result.replyOptions.onPartialReply?.({
|
|
583
|
+
text: "<thinking>private chain of thought</thinking>\nvisible answer",
|
|
584
|
+
});
|
|
585
|
+
await options.onIdle?.();
|
|
586
|
+
|
|
587
|
+
expect(streamingInstances[0].close).toHaveBeenCalledWith("visible answer", {
|
|
588
|
+
note: "Agent: agent",
|
|
589
|
+
});
|
|
371
590
|
});
|
|
372
591
|
|
|
373
592
|
it("sends media-only payloads as attachments", async () => {
|
|
@@ -385,6 +604,21 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
|
|
|
385
604
|
expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
|
|
386
605
|
});
|
|
387
606
|
|
|
607
|
+
it("passes audioAsVoice to media attachments", async () => {
|
|
608
|
+
const { options } = createDispatcherHarness();
|
|
609
|
+
await options.deliver(
|
|
610
|
+
{ mediaUrl: "https://example.com/reply.mp3", audioAsVoice: true },
|
|
611
|
+
{ kind: "final" },
|
|
612
|
+
);
|
|
613
|
+
|
|
614
|
+
expect(sendMediaFeishuMock).toHaveBeenCalledWith(
|
|
615
|
+
expect.objectContaining({
|
|
616
|
+
mediaUrl: "https://example.com/reply.mp3",
|
|
617
|
+
audioAsVoice: true,
|
|
618
|
+
}),
|
|
619
|
+
);
|
|
620
|
+
});
|
|
621
|
+
|
|
388
622
|
it("falls back to legacy mediaUrl when mediaUrls is an empty array", async () => {
|
|
389
623
|
const { options } = createDispatcherHarness();
|
|
390
624
|
await options.deliver(
|
|
@@ -409,6 +643,7 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
|
|
|
409
643
|
{ text: "```ts\nconst x = 1\n```", mediaUrls: ["https://example.com/a.png"] },
|
|
410
644
|
{ kind: "final" },
|
|
411
645
|
);
|
|
646
|
+
await options.onIdle?.();
|
|
412
647
|
|
|
413
648
|
expect(streamingInstances).toHaveLength(1);
|
|
414
649
|
expect(streamingInstances[0].start).toHaveBeenCalledTimes(1);
|
|
@@ -436,7 +671,7 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
|
|
|
436
671
|
);
|
|
437
672
|
});
|
|
438
673
|
|
|
439
|
-
it("passes replyInThread to
|
|
674
|
+
it("passes replyInThread to sendStructuredCardFeishu for card text", async () => {
|
|
440
675
|
resolveFeishuAccountMock.mockReturnValue({
|
|
441
676
|
accountId: "main",
|
|
442
677
|
appId: "app_id",
|
|
@@ -454,7 +689,7 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
|
|
|
454
689
|
});
|
|
455
690
|
await options.deliver({ text: "card text" }, { kind: "final" });
|
|
456
691
|
|
|
457
|
-
expect(
|
|
692
|
+
expect(sendStructuredCardFeishuMock).toHaveBeenCalledWith(
|
|
458
693
|
expect.objectContaining({
|
|
459
694
|
replyToMessageId: "om_msg",
|
|
460
695
|
replyInThread: true,
|
|
@@ -462,6 +697,143 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
|
|
|
462
697
|
);
|
|
463
698
|
});
|
|
464
699
|
|
|
700
|
+
it("streams reasoning content as blockquote before answer", async () => {
|
|
701
|
+
const { result, options } = createDispatcherHarness({
|
|
702
|
+
runtime: createRuntimeLogger(),
|
|
703
|
+
allowReasoningPreview: true,
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
await options.onReplyStart?.();
|
|
707
|
+
// Core agent sends pre-formatted text from formatReasoningMessage
|
|
708
|
+
result.replyOptions.onReasoningStream?.({ text: "Reasoning:\n_thinking step 1_" });
|
|
709
|
+
result.replyOptions.onReasoningStream?.({
|
|
710
|
+
text: "Reasoning:\n_thinking step 1_\n_step 2_",
|
|
711
|
+
});
|
|
712
|
+
result.replyOptions.onPartialReply?.({ text: "answer part" });
|
|
713
|
+
result.replyOptions.onReasoningEnd?.();
|
|
714
|
+
await options.deliver({ text: "answer part final" }, { kind: "final" });
|
|
715
|
+
await options.onIdle?.();
|
|
716
|
+
|
|
717
|
+
expect(streamingInstances).toHaveLength(1);
|
|
718
|
+
const updateCalls = streamingInstances[0].update.mock.calls.map((c: unknown[]) =>
|
|
719
|
+
typeof c[0] === "string" ? c[0] : "",
|
|
720
|
+
);
|
|
721
|
+
const reasoningUpdate = updateCalls.find((c) => c.includes("Thinking"));
|
|
722
|
+
expect(reasoningUpdate).toContain("> 💭 **Thinking**");
|
|
723
|
+
// formatReasoningPrefix strips "Reasoning:" prefix and italic markers
|
|
724
|
+
expect(reasoningUpdate).toContain("> thinking step");
|
|
725
|
+
expect(reasoningUpdate).not.toContain("Reasoning:");
|
|
726
|
+
expect(reasoningUpdate).not.toMatch(/> _.*_/);
|
|
727
|
+
|
|
728
|
+
const combinedUpdate = updateCalls.find((c) => c.includes("Thinking") && c.includes("---"));
|
|
729
|
+
expect(combinedUpdate).toBeDefined();
|
|
730
|
+
|
|
731
|
+
expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
|
|
732
|
+
const closeArg = streamingInstances[0].close.mock.calls[0][0] as string;
|
|
733
|
+
expect(closeArg).toContain("> 💭 **Thinking**");
|
|
734
|
+
expect(closeArg).toContain("---");
|
|
735
|
+
expect(closeArg).toContain("answer part final");
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
it("provides onReasoningStream and onReasoningEnd when reasoning previews are allowed", () => {
|
|
739
|
+
const { result } = createDispatcherHarness({
|
|
740
|
+
runtime: createRuntimeLogger(),
|
|
741
|
+
allowReasoningPreview: true,
|
|
742
|
+
});
|
|
743
|
+
|
|
744
|
+
expect(result.replyOptions.onReasoningStream).toBeTypeOf("function");
|
|
745
|
+
expect(result.replyOptions.onReasoningEnd).toBeTypeOf("function");
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
it("omits reasoning callbacks unless reasoning previews are allowed", () => {
|
|
749
|
+
const { result } = createDispatcherHarness({
|
|
750
|
+
runtime: createRuntimeLogger(),
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
expect(result.replyOptions.onReasoningStream).toBeUndefined();
|
|
754
|
+
expect(result.replyOptions.onReasoningEnd).toBeUndefined();
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
it("omits reasoning callbacks when streaming is disabled", () => {
|
|
758
|
+
resolveFeishuAccountMock.mockReturnValue({
|
|
759
|
+
accountId: "main",
|
|
760
|
+
appId: "app_id",
|
|
761
|
+
appSecret: "app_secret",
|
|
762
|
+
domain: "feishu",
|
|
763
|
+
config: {
|
|
764
|
+
renderMode: "auto",
|
|
765
|
+
streaming: false,
|
|
766
|
+
},
|
|
767
|
+
});
|
|
768
|
+
|
|
769
|
+
const { result } = createDispatcherHarness({
|
|
770
|
+
runtime: createRuntimeLogger(),
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
expect(result.replyOptions.onReasoningStream).toBeUndefined();
|
|
774
|
+
expect(result.replyOptions.onReasoningEnd).toBeUndefined();
|
|
775
|
+
});
|
|
776
|
+
|
|
777
|
+
it("renders reasoning-only card when no answer text arrives", async () => {
|
|
778
|
+
const { result, options } = createDispatcherHarness({
|
|
779
|
+
runtime: createRuntimeLogger(),
|
|
780
|
+
allowReasoningPreview: true,
|
|
781
|
+
});
|
|
782
|
+
|
|
783
|
+
await options.onReplyStart?.();
|
|
784
|
+
result.replyOptions.onReasoningStream?.({ text: "Reasoning:\n_deep thought_" });
|
|
785
|
+
result.replyOptions.onReasoningEnd?.();
|
|
786
|
+
await options.onIdle?.();
|
|
787
|
+
|
|
788
|
+
expect(streamingInstances).toHaveLength(1);
|
|
789
|
+
expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
|
|
790
|
+
const closeArg = streamingInstances[0].close.mock.calls[0][0] as string;
|
|
791
|
+
expect(closeArg).toContain("> 💭 **Thinking**");
|
|
792
|
+
expect(closeArg).toContain("> deep thought");
|
|
793
|
+
expect(closeArg).not.toContain("Reasoning:");
|
|
794
|
+
expect(closeArg).not.toContain("---");
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
it("ignores empty reasoning payloads", async () => {
|
|
798
|
+
const { result, options } = createDispatcherHarness({
|
|
799
|
+
runtime: createRuntimeLogger(),
|
|
800
|
+
allowReasoningPreview: true,
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
await options.onReplyStart?.();
|
|
804
|
+
result.replyOptions.onReasoningStream?.({ text: "" });
|
|
805
|
+
result.replyOptions.onPartialReply?.({ text: "```ts\ncode\n```" });
|
|
806
|
+
await options.deliver({ text: "```ts\ncode\n```" }, { kind: "final" });
|
|
807
|
+
await options.onIdle?.();
|
|
808
|
+
|
|
809
|
+
expect(streamingInstances).toHaveLength(1);
|
|
810
|
+
const closeArg = streamingInstances[0].close.mock.calls[0][0] as string;
|
|
811
|
+
expect(closeArg).not.toContain("Thinking");
|
|
812
|
+
expect(closeArg).toBe("```ts\ncode\n```");
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
it("deduplicates final text by raw answer payload, not combined card text", async () => {
|
|
816
|
+
const { result, options } = createDispatcherHarness({
|
|
817
|
+
runtime: createRuntimeLogger(),
|
|
818
|
+
allowReasoningPreview: true,
|
|
819
|
+
});
|
|
820
|
+
|
|
821
|
+
await options.onReplyStart?.();
|
|
822
|
+
result.replyOptions.onReasoningStream?.({ text: "Reasoning:\n_thought_" });
|
|
823
|
+
result.replyOptions.onReasoningEnd?.();
|
|
824
|
+
await options.deliver({ text: "```ts\nfinal answer\n```" }, { kind: "final" });
|
|
825
|
+
await options.onIdle?.();
|
|
826
|
+
|
|
827
|
+
expect(streamingInstances).toHaveLength(1);
|
|
828
|
+
expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
|
|
829
|
+
|
|
830
|
+
// Deliver the same raw answer text again — should be deduped
|
|
831
|
+
await options.deliver({ text: "```ts\nfinal answer\n```" }, { kind: "final" });
|
|
832
|
+
|
|
833
|
+
// No second streaming session since the raw answer text matches
|
|
834
|
+
expect(streamingInstances).toHaveLength(1);
|
|
835
|
+
});
|
|
836
|
+
|
|
465
837
|
it("passes replyToMessageId and replyInThread to streaming.start()", async () => {
|
|
466
838
|
const { options } = createDispatcherHarness({
|
|
467
839
|
runtime: createRuntimeLogger(),
|
|
@@ -471,13 +843,19 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
|
|
|
471
843
|
await options.deliver({ text: "```ts\nconst x = 1\n```" }, { kind: "final" });
|
|
472
844
|
|
|
473
845
|
expect(streamingInstances).toHaveLength(1);
|
|
474
|
-
expect(streamingInstances[0].start).toHaveBeenCalledWith(
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
846
|
+
expect(streamingInstances[0].start).toHaveBeenCalledWith(
|
|
847
|
+
"oc_chat",
|
|
848
|
+
"chat_id",
|
|
849
|
+
expect.objectContaining({
|
|
850
|
+
replyToMessageId: "om_msg",
|
|
851
|
+
replyInThread: true,
|
|
852
|
+
header: { title: "agent", template: "blue" },
|
|
853
|
+
note: "Agent: agent",
|
|
854
|
+
}),
|
|
855
|
+
);
|
|
478
856
|
});
|
|
479
857
|
|
|
480
|
-
it("
|
|
858
|
+
it("uses streaming cards for thread replies and keeps topic metadata", async () => {
|
|
481
859
|
const { options } = createDispatcherHarness({
|
|
482
860
|
runtime: createRuntimeLogger(),
|
|
483
861
|
replyToMessageId: "om_msg",
|
|
@@ -487,15 +865,209 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
|
|
|
487
865
|
});
|
|
488
866
|
await options.deliver({ text: "```ts\nconst x = 1\n```" }, { kind: "final" });
|
|
489
867
|
|
|
490
|
-
expect(streamingInstances).toHaveLength(
|
|
491
|
-
expect(
|
|
868
|
+
expect(streamingInstances).toHaveLength(1);
|
|
869
|
+
expect(streamingInstances[0].start).toHaveBeenCalledWith(
|
|
870
|
+
"oc_chat",
|
|
871
|
+
"chat_id",
|
|
492
872
|
expect.objectContaining({
|
|
493
873
|
replyToMessageId: "om_msg",
|
|
494
874
|
replyInThread: true,
|
|
875
|
+
rootId: "om_root_topic",
|
|
876
|
+
}),
|
|
877
|
+
);
|
|
878
|
+
expect(sendStructuredCardFeishuMock).not.toHaveBeenCalled();
|
|
879
|
+
});
|
|
880
|
+
|
|
881
|
+
it("omits the generic main header from streaming and static cards", async () => {
|
|
882
|
+
resolveFeishuAccountMock.mockReturnValue({
|
|
883
|
+
accountId: "main",
|
|
884
|
+
appId: "app_id",
|
|
885
|
+
appSecret: "app_secret",
|
|
886
|
+
domain: "feishu",
|
|
887
|
+
config: {
|
|
888
|
+
renderMode: "card",
|
|
889
|
+
streaming: true,
|
|
890
|
+
},
|
|
891
|
+
});
|
|
892
|
+
|
|
893
|
+
const { options } = createDispatcherHarness({
|
|
894
|
+
agentId: "main",
|
|
895
|
+
runtime: createRuntimeLogger(),
|
|
896
|
+
});
|
|
897
|
+
await options.deliver({ text: "streamed card" }, { kind: "final" });
|
|
898
|
+
await options.onIdle?.();
|
|
899
|
+
|
|
900
|
+
expect(streamingInstances[0].start).toHaveBeenCalledWith(
|
|
901
|
+
"oc_chat",
|
|
902
|
+
"chat_id",
|
|
903
|
+
expect.objectContaining({
|
|
904
|
+
header: undefined,
|
|
905
|
+
}),
|
|
906
|
+
);
|
|
907
|
+
|
|
908
|
+
resolveFeishuAccountMock.mockReturnValue({
|
|
909
|
+
accountId: "main",
|
|
910
|
+
appId: "app_id",
|
|
911
|
+
appSecret: "app_secret",
|
|
912
|
+
domain: "feishu",
|
|
913
|
+
config: {
|
|
914
|
+
renderMode: "card",
|
|
915
|
+
streaming: false,
|
|
916
|
+
},
|
|
917
|
+
});
|
|
918
|
+
|
|
919
|
+
const { options: staticOptions } = createDispatcherHarness({
|
|
920
|
+
agentId: "main",
|
|
921
|
+
runtime: createRuntimeLogger(),
|
|
922
|
+
});
|
|
923
|
+
await staticOptions.deliver({ text: "static card" }, { kind: "final" });
|
|
924
|
+
|
|
925
|
+
expect(sendStructuredCardFeishuMock).toHaveBeenCalledWith(
|
|
926
|
+
expect.objectContaining({
|
|
927
|
+
header: undefined,
|
|
495
928
|
}),
|
|
496
929
|
);
|
|
497
930
|
});
|
|
498
931
|
|
|
932
|
+
it("shows transient tool status on streaming cards but omits it from the final close", async () => {
|
|
933
|
+
resolveFeishuAccountMock.mockReturnValue({
|
|
934
|
+
accountId: "main",
|
|
935
|
+
appId: "app_id",
|
|
936
|
+
appSecret: "app_secret",
|
|
937
|
+
domain: "feishu",
|
|
938
|
+
config: {
|
|
939
|
+
renderMode: "card",
|
|
940
|
+
streaming: true,
|
|
941
|
+
},
|
|
942
|
+
});
|
|
943
|
+
|
|
944
|
+
const { result, options } = createDispatcherHarness({
|
|
945
|
+
runtime: createRuntimeLogger(),
|
|
946
|
+
});
|
|
947
|
+
await options.onReplyStart?.();
|
|
948
|
+
result.replyOptions.onToolStart?.({ name: "web_search" });
|
|
949
|
+
result.replyOptions.onPartialReply?.({ text: "final answer" });
|
|
950
|
+
await options.onIdle?.();
|
|
951
|
+
|
|
952
|
+
const updateTexts = streamingInstances[0].update.mock.calls.map((call: unknown[]) =>
|
|
953
|
+
typeof call[0] === "string" ? call[0] : "",
|
|
954
|
+
);
|
|
955
|
+
expect(updateTexts.some((text) => text.includes("Using: web_search"))).toBe(true);
|
|
956
|
+
expect(streamingInstances[0].close).toHaveBeenCalledWith("final answer", {
|
|
957
|
+
note: "Agent: agent",
|
|
958
|
+
});
|
|
959
|
+
});
|
|
960
|
+
|
|
961
|
+
it("does not suppress a later final after error closeout", async () => {
|
|
962
|
+
resolveFeishuAccountMock.mockReturnValue({
|
|
963
|
+
accountId: "main",
|
|
964
|
+
appId: "app_id",
|
|
965
|
+
appSecret: "app_secret",
|
|
966
|
+
domain: "feishu",
|
|
967
|
+
config: {
|
|
968
|
+
renderMode: "card",
|
|
969
|
+
streaming: true,
|
|
970
|
+
},
|
|
971
|
+
});
|
|
972
|
+
sendMediaFeishuMock.mockRejectedValueOnce(new Error("media failed"));
|
|
973
|
+
|
|
974
|
+
const { options } = createDispatcherHarness({
|
|
975
|
+
runtime: createRuntimeLogger(),
|
|
976
|
+
});
|
|
977
|
+
|
|
978
|
+
await expect(
|
|
979
|
+
options.deliver(
|
|
980
|
+
{ text: "First answer", mediaUrl: "https://example.com/a.png" },
|
|
981
|
+
{ kind: "final" },
|
|
982
|
+
),
|
|
983
|
+
).rejects.toThrow("media failed");
|
|
984
|
+
await Promise.all([
|
|
985
|
+
options.onError?.(new Error("media failed"), { kind: "final" }),
|
|
986
|
+
options.onIdle?.(),
|
|
987
|
+
]);
|
|
988
|
+
await options.deliver({ text: "Second answer" }, { kind: "final" });
|
|
989
|
+
await options.onIdle?.();
|
|
990
|
+
|
|
991
|
+
expect(streamingInstances).toHaveLength(2);
|
|
992
|
+
expect(streamingInstances[0].close).toHaveBeenCalledWith("First answer", {
|
|
993
|
+
note: "Agent: agent",
|
|
994
|
+
});
|
|
995
|
+
expect(streamingInstances[1].close).toHaveBeenCalledWith("Second answer", {
|
|
996
|
+
note: "Agent: agent",
|
|
997
|
+
});
|
|
998
|
+
expect(sendMessageFeishuMock).not.toHaveBeenCalled();
|
|
999
|
+
expect(sendStructuredCardFeishuMock).not.toHaveBeenCalled();
|
|
1000
|
+
});
|
|
1001
|
+
|
|
1002
|
+
it("does not suppress a recovery final after late media failure", async () => {
|
|
1003
|
+
resolveFeishuAccountMock.mockReturnValue({
|
|
1004
|
+
accountId: "main",
|
|
1005
|
+
appId: "app_id",
|
|
1006
|
+
appSecret: "app_secret",
|
|
1007
|
+
domain: "feishu",
|
|
1008
|
+
config: {
|
|
1009
|
+
renderMode: "card",
|
|
1010
|
+
streaming: true,
|
|
1011
|
+
},
|
|
1012
|
+
});
|
|
1013
|
+
|
|
1014
|
+
const { options } = createDispatcherHarness({
|
|
1015
|
+
runtime: createRuntimeLogger(),
|
|
1016
|
+
});
|
|
1017
|
+
|
|
1018
|
+
await options.deliver({ text: "First answer" }, { kind: "final" });
|
|
1019
|
+
await options.onIdle?.();
|
|
1020
|
+
sendMediaFeishuMock.mockRejectedValueOnce(new Error("media failed"));
|
|
1021
|
+
await expect(
|
|
1022
|
+
options.deliver(
|
|
1023
|
+
{ text: "Late attachment", mediaUrl: "https://example.com/a.png" },
|
|
1024
|
+
{ kind: "final" },
|
|
1025
|
+
),
|
|
1026
|
+
).rejects.toThrow("media failed");
|
|
1027
|
+
await options.onError?.(new Error("media failed"), { kind: "final" });
|
|
1028
|
+
await options.deliver({ text: "Recovered answer" }, { kind: "final" });
|
|
1029
|
+
await options.onIdle?.();
|
|
1030
|
+
|
|
1031
|
+
expect(streamingInstances).toHaveLength(2);
|
|
1032
|
+
expect(streamingInstances[0].close).toHaveBeenCalledWith("First answer", {
|
|
1033
|
+
note: "Agent: agent",
|
|
1034
|
+
});
|
|
1035
|
+
expect(streamingInstances[1].close).toHaveBeenCalledWith("Recovered answer", {
|
|
1036
|
+
note: "Agent: agent",
|
|
1037
|
+
});
|
|
1038
|
+
expect(sendStructuredCardFeishuMock).not.toHaveBeenCalled();
|
|
1039
|
+
});
|
|
1040
|
+
|
|
1041
|
+
it("cleans streaming state even when close throws", async () => {
|
|
1042
|
+
const origPush = streamingInstances.push.bind(streamingInstances);
|
|
1043
|
+
streamingInstances.push = (...args: StreamingSessionStub[]) => {
|
|
1044
|
+
if (args.length > 0 && streamingInstances.length === 0) {
|
|
1045
|
+
args[0].close = vi.fn(async () => {
|
|
1046
|
+
args[0].active = false;
|
|
1047
|
+
throw new Error("close failed");
|
|
1048
|
+
});
|
|
1049
|
+
}
|
|
1050
|
+
return origPush(...args);
|
|
1051
|
+
};
|
|
1052
|
+
|
|
1053
|
+
try {
|
|
1054
|
+
const { options } = createDispatcherHarness({
|
|
1055
|
+
runtime: createRuntimeLogger(),
|
|
1056
|
+
});
|
|
1057
|
+
await options.deliver({ text: "```md\nfirst\n```" }, { kind: "final" });
|
|
1058
|
+
await expect(options.onIdle?.()).rejects.toThrow("close failed");
|
|
1059
|
+
await options.deliver({ text: "```md\nsecond\n```" }, { kind: "final" });
|
|
1060
|
+
await options.onIdle?.();
|
|
1061
|
+
|
|
1062
|
+
expect(streamingInstances).toHaveLength(2);
|
|
1063
|
+
expect(streamingInstances[1].close).toHaveBeenCalledWith("```md\nsecond\n```", {
|
|
1064
|
+
note: "Agent: agent",
|
|
1065
|
+
});
|
|
1066
|
+
} finally {
|
|
1067
|
+
streamingInstances.push = origPush;
|
|
1068
|
+
}
|
|
1069
|
+
});
|
|
1070
|
+
|
|
499
1071
|
it("passes replyInThread to media attachments", async () => {
|
|
500
1072
|
const { options } = createDispatcherHarness({
|
|
501
1073
|
replyToMessageId: "om_msg",
|
|
@@ -510,4 +1082,63 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
|
|
|
510
1082
|
}),
|
|
511
1083
|
);
|
|
512
1084
|
});
|
|
1085
|
+
|
|
1086
|
+
it("backs off streaming retries after start() throws (HTTP 400)", async () => {
|
|
1087
|
+
const errorMock = vi.fn();
|
|
1088
|
+
let shouldFailStart = true;
|
|
1089
|
+
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(1_000);
|
|
1090
|
+
|
|
1091
|
+
// Intercept streaming instance creation to make first start() reject
|
|
1092
|
+
const origPush = streamingInstances.push.bind(streamingInstances);
|
|
1093
|
+
streamingInstances.push = (...args: StreamingSessionStub[]) => {
|
|
1094
|
+
if (shouldFailStart) {
|
|
1095
|
+
args[0].start = vi
|
|
1096
|
+
.fn()
|
|
1097
|
+
.mockRejectedValue(new Error("Create card request failed with HTTP 400"));
|
|
1098
|
+
shouldFailStart = false;
|
|
1099
|
+
}
|
|
1100
|
+
return origPush(...args);
|
|
1101
|
+
};
|
|
1102
|
+
|
|
1103
|
+
try {
|
|
1104
|
+
createFeishuReplyDispatcher({
|
|
1105
|
+
cfg: {} as never,
|
|
1106
|
+
agentId: "agent",
|
|
1107
|
+
runtime: { log: vi.fn(), error: errorMock } as never,
|
|
1108
|
+
chatId: "oc_chat",
|
|
1109
|
+
});
|
|
1110
|
+
|
|
1111
|
+
const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
|
|
1112
|
+
|
|
1113
|
+
// First deliver with markdown triggers startStreaming - which will fail
|
|
1114
|
+
await options.deliver({ text: "```ts\nconst x = 1\n```" }, { kind: "final" });
|
|
1115
|
+
|
|
1116
|
+
// Wait for the async error to propagate
|
|
1117
|
+
await vi.waitFor(() => {
|
|
1118
|
+
expect(errorMock).toHaveBeenCalledWith(expect.stringContaining("streaming start failed"));
|
|
1119
|
+
});
|
|
1120
|
+
expect(streamingInstances).toHaveLength(1);
|
|
1121
|
+
expect(sendStructuredCardFeishuMock).toHaveBeenCalledTimes(1);
|
|
1122
|
+
|
|
1123
|
+
// Immediate next markdown reply should skip a new streaming start and
|
|
1124
|
+
// fall back directly to a normal card instead of paying the 400 latency.
|
|
1125
|
+
await options.deliver({ text: "```ts\nconst y = 2\n```" }, { kind: "final" });
|
|
1126
|
+
|
|
1127
|
+
expect(streamingInstances).toHaveLength(1);
|
|
1128
|
+
expect(sendStructuredCardFeishuMock).toHaveBeenCalledTimes(2);
|
|
1129
|
+
|
|
1130
|
+
// After the short backoff expires, retry streaming so fixed permissions
|
|
1131
|
+
// or transient Feishu failures recover without a process restart.
|
|
1132
|
+
nowSpy.mockReturnValue(62_000);
|
|
1133
|
+
await options.deliver({ text: "```ts\nconst z = 3\n```" }, { kind: "final" });
|
|
1134
|
+
await options.onIdle?.();
|
|
1135
|
+
|
|
1136
|
+
expect(streamingInstances).toHaveLength(2);
|
|
1137
|
+
expect(streamingInstances[1].start).toHaveBeenCalled();
|
|
1138
|
+
expect(streamingInstances[1].close).toHaveBeenCalled();
|
|
1139
|
+
} finally {
|
|
1140
|
+
streamingInstances.push = origPush;
|
|
1141
|
+
nowSpy.mockRestore();
|
|
1142
|
+
}
|
|
1143
|
+
});
|
|
513
1144
|
});
|