@actagent/feishu 2026.6.2

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.
Files changed (207) hide show
  1. package/README.md +11 -0
  2. package/actagent.plugin.json +224 -0
  3. package/api.ts +33 -0
  4. package/channel-entry.ts +21 -0
  5. package/channel-plugin-api.ts +2 -0
  6. package/contract-api.ts +17 -0
  7. package/index.ts +83 -0
  8. package/legacy-state-migrations-api.ts +2 -0
  9. package/npm-shrinkwrap.json +539 -0
  10. package/package.json +64 -0
  11. package/runtime-api.ts +58 -0
  12. package/runtime-setter-api.ts +3 -0
  13. package/secret-contract-api.ts +6 -0
  14. package/security-contract-api.ts +2 -0
  15. package/session-key-api.ts +2 -0
  16. package/setup-api.ts +4 -0
  17. package/setup-entry.test.ts +33 -0
  18. package/setup-entry.ts +25 -0
  19. package/skills/feishu-doc/SKILL.md +211 -0
  20. package/skills/feishu-doc/references/block-types.md +103 -0
  21. package/skills/feishu-drive/SKILL.md +97 -0
  22. package/skills/feishu-perm/SKILL.md +119 -0
  23. package/skills/feishu-wiki/SKILL.md +113 -0
  24. package/src/accounts.test.ts +481 -0
  25. package/src/accounts.ts +380 -0
  26. package/src/agent-config.ts +22 -0
  27. package/src/app-registration.test.ts +62 -0
  28. package/src/app-registration.ts +355 -0
  29. package/src/approval-auth.test.ts +25 -0
  30. package/src/approval-auth.ts +26 -0
  31. package/src/async.test.ts +68 -0
  32. package/src/async.ts +109 -0
  33. package/src/audio-preflight.runtime.ts +10 -0
  34. package/src/bitable.test.ts +174 -0
  35. package/src/bitable.ts +781 -0
  36. package/src/bot-content.ts +488 -0
  37. package/src/bot-group-name.test.ts +148 -0
  38. package/src/bot-runtime-api.ts +13 -0
  39. package/src/bot-sender-name.test.ts +68 -0
  40. package/src/bot-sender-name.ts +137 -0
  41. package/src/bot.broadcast.test.ts +643 -0
  42. package/src/bot.card-action.test.ts +647 -0
  43. package/src/bot.checkBotMentioned.test.ts +266 -0
  44. package/src/bot.helpers.test.ts +136 -0
  45. package/src/bot.stripBotMention.test.ts +127 -0
  46. package/src/bot.test.ts +3817 -0
  47. package/src/bot.ts +1788 -0
  48. package/src/card-action.ts +515 -0
  49. package/src/card-interaction.test.ts +132 -0
  50. package/src/card-interaction.ts +160 -0
  51. package/src/card-test-helpers.ts +55 -0
  52. package/src/card-ux-approval.ts +66 -0
  53. package/src/card-ux-launcher.test.ts +126 -0
  54. package/src/card-ux-launcher.ts +136 -0
  55. package/src/card-ux-shared.ts +34 -0
  56. package/src/channel-runtime-api.ts +17 -0
  57. package/src/channel.runtime.ts +48 -0
  58. package/src/channel.test.ts +1337 -0
  59. package/src/channel.ts +1401 -0
  60. package/src/chat-schema.ts +30 -0
  61. package/src/chat.test.ts +295 -0
  62. package/src/chat.ts +198 -0
  63. package/src/client-timeout.ts +44 -0
  64. package/src/client.test.ts +463 -0
  65. package/src/client.ts +263 -0
  66. package/src/comment-dispatcher-runtime-api.ts +7 -0
  67. package/src/comment-dispatcher.test.ts +186 -0
  68. package/src/comment-dispatcher.ts +108 -0
  69. package/src/comment-handler-runtime-api.ts +4 -0
  70. package/src/comment-handler.test.ts +588 -0
  71. package/src/comment-handler.ts +304 -0
  72. package/src/comment-reaction.test.ts +139 -0
  73. package/src/comment-reaction.ts +260 -0
  74. package/src/comment-shared.test.ts +184 -0
  75. package/src/comment-shared.ts +405 -0
  76. package/src/comment-target.ts +45 -0
  77. package/src/config-schema.test.ts +327 -0
  78. package/src/config-schema.ts +338 -0
  79. package/src/conversation-id.test.ts +19 -0
  80. package/src/conversation-id.ts +199 -0
  81. package/src/dedup-migrations.test.ts +90 -0
  82. package/src/dedup-migrations.ts +103 -0
  83. package/src/dedup.test.ts +95 -0
  84. package/src/dedup.ts +304 -0
  85. package/src/dedupe-key.ts +68 -0
  86. package/src/directory.static.ts +62 -0
  87. package/src/directory.test.ts +142 -0
  88. package/src/directory.ts +125 -0
  89. package/src/doc-schema.ts +183 -0
  90. package/src/doctor.test.ts +382 -0
  91. package/src/doctor.ts +876 -0
  92. package/src/docx-batch-insert.test.ts +117 -0
  93. package/src/docx-batch-insert.ts +223 -0
  94. package/src/docx-color-text.ts +154 -0
  95. package/src/docx-table-ops.test.ts +54 -0
  96. package/src/docx-table-ops.ts +316 -0
  97. package/src/docx-types.ts +39 -0
  98. package/src/docx.account-selection.test.ts +96 -0
  99. package/src/docx.test.ts +706 -0
  100. package/src/docx.ts +1598 -0
  101. package/src/drive-schema.ts +93 -0
  102. package/src/drive.test.ts +1240 -0
  103. package/src/drive.ts +830 -0
  104. package/src/dynamic-agent.test.ts +156 -0
  105. package/src/dynamic-agent.ts +144 -0
  106. package/src/event-types.ts +46 -0
  107. package/src/external-keys.test.ts +21 -0
  108. package/src/external-keys.ts +20 -0
  109. package/src/lifecycle.test-support.ts +223 -0
  110. package/src/media.test.ts +956 -0
  111. package/src/media.ts +1106 -0
  112. package/src/mention-target.types.ts +6 -0
  113. package/src/mention.ts +115 -0
  114. package/src/message-action-contract.ts +14 -0
  115. package/src/monitor-state-runtime-api.ts +8 -0
  116. package/src/monitor-transport-runtime-api.ts +11 -0
  117. package/src/monitor.account.ts +501 -0
  118. package/src/monitor.acp-init-failure.lifecycle.test-support.ts +215 -0
  119. package/src/monitor.bot-identity.ts +87 -0
  120. package/src/monitor.bot-menu-handler.ts +164 -0
  121. package/src/monitor.bot-menu.lifecycle.test-support.ts +221 -0
  122. package/src/monitor.bot-menu.test.ts +200 -0
  123. package/src/monitor.broadcast.reply-once.lifecycle.test-support.ts +265 -0
  124. package/src/monitor.card-action.lifecycle.test-support.ts +418 -0
  125. package/src/monitor.cleanup.test.ts +384 -0
  126. package/src/monitor.comment-notice-handler.ts +106 -0
  127. package/src/monitor.comment.test.ts +968 -0
  128. package/src/monitor.comment.ts +1386 -0
  129. package/src/monitor.lifecycle.test.ts +5 -0
  130. package/src/monitor.message-handler.ts +346 -0
  131. package/src/monitor.reaction.test.ts +770 -0
  132. package/src/monitor.startup.test.ts +232 -0
  133. package/src/monitor.startup.ts +76 -0
  134. package/src/monitor.state.defaults.test.ts +47 -0
  135. package/src/monitor.state.ts +171 -0
  136. package/src/monitor.synthetic-error.ts +19 -0
  137. package/src/monitor.test-mocks.ts +47 -0
  138. package/src/monitor.transport.ts +451 -0
  139. package/src/monitor.ts +104 -0
  140. package/src/monitor.webhook-e2e.test.ts +284 -0
  141. package/src/monitor.webhook-security.test.ts +394 -0
  142. package/src/monitor.webhook.test-helpers.ts +138 -0
  143. package/src/outbound-runtime-api.ts +2 -0
  144. package/src/outbound.test.ts +1255 -0
  145. package/src/outbound.ts +742 -0
  146. package/src/perm-schema.ts +53 -0
  147. package/src/perm.ts +171 -0
  148. package/src/pins.ts +109 -0
  149. package/src/policy.test.ts +224 -0
  150. package/src/policy.ts +322 -0
  151. package/src/post.test.ts +106 -0
  152. package/src/post.ts +276 -0
  153. package/src/presentation-card.ts +204 -0
  154. package/src/probe.test.ts +310 -0
  155. package/src/probe.ts +181 -0
  156. package/src/processing-claims.ts +60 -0
  157. package/src/qr-terminal.ts +2 -0
  158. package/src/reactions.ts +124 -0
  159. package/src/reasoning-preview.test.ts +114 -0
  160. package/src/reasoning-preview.ts +29 -0
  161. package/src/reply-dispatcher-runtime-api.ts +8 -0
  162. package/src/reply-dispatcher.test.ts +2009 -0
  163. package/src/reply-dispatcher.ts +865 -0
  164. package/src/runtime.ts +10 -0
  165. package/src/secret-contract.ts +146 -0
  166. package/src/secret-input.ts +2 -0
  167. package/src/security-audit-shared.ts +70 -0
  168. package/src/security-audit.test.ts +60 -0
  169. package/src/security-audit.ts +2 -0
  170. package/src/send-result.ts +81 -0
  171. package/src/send-target.test.ts +87 -0
  172. package/src/send-target.ts +36 -0
  173. package/src/send.reply-fallback.test.ts +418 -0
  174. package/src/send.test.ts +661 -0
  175. package/src/send.ts +860 -0
  176. package/src/sequential-key.test.ts +73 -0
  177. package/src/sequential-key.ts +29 -0
  178. package/src/sequential-queue.test.ts +184 -0
  179. package/src/sequential-queue.ts +90 -0
  180. package/src/session-conversation.ts +42 -0
  181. package/src/session-route.ts +49 -0
  182. package/src/setup-core.ts +52 -0
  183. package/src/setup-surface.test.ts +485 -0
  184. package/src/setup-surface.ts +620 -0
  185. package/src/streaming-card.test.ts +549 -0
  186. package/src/streaming-card.ts +611 -0
  187. package/src/subagent-hooks.test.ts +632 -0
  188. package/src/subagent-hooks.ts +414 -0
  189. package/src/targets.ts +98 -0
  190. package/src/test-support/lifecycle-test-support.ts +459 -0
  191. package/src/thread-bindings.test.ts +181 -0
  192. package/src/thread-bindings.ts +332 -0
  193. package/src/tool-account-routing.test.ts +419 -0
  194. package/src/tool-account.test.ts +45 -0
  195. package/src/tool-account.ts +98 -0
  196. package/src/tool-factory-test-harness.ts +83 -0
  197. package/src/tool-result.test.ts +33 -0
  198. package/src/tool-result.ts +17 -0
  199. package/src/tools-config.test.ts +52 -0
  200. package/src/tools-config.ts +29 -0
  201. package/src/types.ts +111 -0
  202. package/src/typing.test.ts +145 -0
  203. package/src/typing.ts +215 -0
  204. package/src/wiki-schema.ts +70 -0
  205. package/src/wiki.ts +271 -0
  206. package/subagent-hooks-api.ts +22 -0
  207. package/tsconfig.json +16 -0
@@ -0,0 +1,2009 @@
1
+ // Feishu tests cover reply dispatcher plugin behavior.
2
+ import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
3
+
4
+ type StreamingSessionStub = {
5
+ active: boolean;
6
+ start: ReturnType<typeof vi.fn>;
7
+ update: ReturnType<typeof vi.fn>;
8
+ close: ReturnType<typeof vi.fn>;
9
+ discard: ReturnType<typeof vi.fn>;
10
+ isActive: ReturnType<typeof vi.fn>;
11
+ };
12
+
13
+ const resolveFeishuAccountMock = vi.hoisted(() => vi.fn());
14
+ const getFeishuRuntimeMock = vi.hoisted(() => vi.fn());
15
+ const sendMessageFeishuMock = vi.hoisted(() => vi.fn());
16
+ const sendMarkdownCardFeishuMock = vi.hoisted(() => vi.fn());
17
+ const sendStructuredCardFeishuMock = vi.hoisted(() => vi.fn());
18
+ const sendMediaFeishuMock = vi.hoisted(() => vi.fn());
19
+ const createFeishuClientMock = vi.hoisted(() => vi.fn());
20
+ const resolveReceiveIdTypeMock = vi.hoisted(() => vi.fn());
21
+ const createReplyDispatcherWithTypingMock = vi.hoisted(() => vi.fn());
22
+ const addTypingIndicatorMock = vi.hoisted(() => vi.fn(async () => ({ messageId: "om_msg" })));
23
+ const removeTypingIndicatorMock = vi.hoisted(() => vi.fn(async () => {}));
24
+ const streamingInstances = vi.hoisted((): StreamingSessionStub[] => []);
25
+ const shouldSuppressFeishuTextForVoiceMediaMock = vi.hoisted(
26
+ () => (params: { mediaUrl?: string; audioAsVoice?: boolean }) =>
27
+ params.audioAsVoice === true || /\.(?:ogg|opus)(?:[?#]|$)/i.test(params.mediaUrl ?? ""),
28
+ );
29
+
30
+ function mergeStreamingText(
31
+ previousText: string | undefined,
32
+ nextText: string | undefined,
33
+ ): string {
34
+ const previous = typeof previousText === "string" ? previousText : "";
35
+ const next = typeof nextText === "string" ? nextText : "";
36
+ if (!next) {
37
+ return previous;
38
+ }
39
+ if (!previous || next === previous) {
40
+ return next;
41
+ }
42
+ if (next.startsWith(previous) || next.includes(previous)) {
43
+ return next;
44
+ }
45
+ if (previous.startsWith(next) || previous.includes(next)) {
46
+ return previous;
47
+ }
48
+ const maxOverlap = Math.min(previous.length, next.length);
49
+ for (let overlap = maxOverlap; overlap > 0; overlap -= 1) {
50
+ if (previous.slice(-overlap) === next.slice(0, overlap)) {
51
+ return `${previous}${next.slice(overlap)}`;
52
+ }
53
+ }
54
+ return `${previous}${next}`;
55
+ }
56
+
57
+ vi.mock("./accounts.js", () => ({
58
+ resolveFeishuAccount: resolveFeishuAccountMock,
59
+ resolveFeishuRuntimeAccount: resolveFeishuAccountMock,
60
+ }));
61
+ vi.mock("./runtime.js", () => ({ getFeishuRuntime: getFeishuRuntimeMock }));
62
+ vi.mock("./send.js", () => ({
63
+ sendMessageFeishu: sendMessageFeishuMock,
64
+ sendMarkdownCardFeishu: sendMarkdownCardFeishuMock,
65
+ sendStructuredCardFeishu: sendStructuredCardFeishuMock,
66
+ }));
67
+ vi.mock("./media.js", () => ({
68
+ sendMediaFeishu: sendMediaFeishuMock,
69
+ shouldSuppressFeishuTextForVoiceMedia: shouldSuppressFeishuTextForVoiceMediaMock,
70
+ }));
71
+ vi.mock("./client.js", () => ({ createFeishuClient: createFeishuClientMock }));
72
+ vi.mock("./targets.js", () => ({ resolveReceiveIdType: resolveReceiveIdTypeMock }));
73
+ vi.mock("./typing.js", () => ({
74
+ addTypingIndicator: addTypingIndicatorMock,
75
+ removeTypingIndicator: removeTypingIndicatorMock,
76
+ }));
77
+ vi.mock("./streaming-card.js", () => {
78
+ return {
79
+ mergeStreamingText,
80
+ FeishuStreamingSession: class {
81
+ active = false;
82
+ start = vi.fn(async () => {
83
+ this.active = true;
84
+ });
85
+ update = vi.fn(async () => {});
86
+ close = vi.fn(async (text?: string) => {
87
+ this.active = false;
88
+ return Boolean(text?.trim());
89
+ });
90
+ discard = vi.fn(async () => {
91
+ this.active = false;
92
+ });
93
+ isActive = vi.fn(() => this.active);
94
+
95
+ constructor() {
96
+ streamingInstances.push(this);
97
+ }
98
+ },
99
+ };
100
+ });
101
+
102
+ import {
103
+ clearFeishuStreamingStartBackoffForTests,
104
+ createFeishuReplyDispatcher,
105
+ } from "./reply-dispatcher.js";
106
+
107
+ afterAll(() => {
108
+ vi.doUnmock("./accounts.js");
109
+ vi.doUnmock("./runtime.js");
110
+ vi.doUnmock("./send.js");
111
+ vi.doUnmock("./media.js");
112
+ vi.doUnmock("./client.js");
113
+ vi.doUnmock("./targets.js");
114
+ vi.doUnmock("./typing.js");
115
+ vi.doUnmock("./streaming-card.js");
116
+ vi.resetModules();
117
+ });
118
+
119
+ describe("createFeishuReplyDispatcher streaming behavior", () => {
120
+ type ReplyDispatcherArgs = Parameters<typeof createFeishuReplyDispatcher>[0];
121
+ type TypingDispatcherOptions = {
122
+ onReplyStart?: () => Promise<void> | void;
123
+ onIdle?: () => Promise<void> | void;
124
+ deliver: (
125
+ payload: { text?: string; mediaUrl?: string; mediaUrls?: string[]; audioAsVoice?: boolean },
126
+ meta: { kind: string },
127
+ ) => Promise<void> | void;
128
+ };
129
+
130
+ beforeEach(() => {
131
+ vi.clearAllMocks();
132
+ clearFeishuStreamingStartBackoffForTests();
133
+ streamingInstances.length = 0;
134
+ sendMediaFeishuMock.mockResolvedValue(undefined);
135
+ sendStructuredCardFeishuMock.mockResolvedValue(undefined);
136
+
137
+ resolveFeishuAccountMock.mockReturnValue({
138
+ accountId: "main",
139
+ appId: "app_id",
140
+ appSecret: "app_secret",
141
+ domain: "feishu",
142
+ config: {
143
+ renderMode: "auto",
144
+ streaming: true,
145
+ },
146
+ });
147
+
148
+ resolveReceiveIdTypeMock.mockReturnValue("chat_id");
149
+ createFeishuClientMock.mockReturnValue({});
150
+
151
+ createReplyDispatcherWithTypingMock.mockImplementation((opts) => ({
152
+ dispatcher: {},
153
+ replyOptions: {},
154
+ markDispatchIdle: vi.fn(),
155
+ _opts: opts,
156
+ }));
157
+
158
+ getFeishuRuntimeMock.mockReturnValue({
159
+ channel: {
160
+ text: {
161
+ resolveTextChunkLimit: vi.fn(() => 4000),
162
+ resolveChunkMode: vi.fn(() => "line"),
163
+ resolveMarkdownTableMode: vi.fn(() => "preserve"),
164
+ convertMarkdownTables: vi.fn((text) => text),
165
+ chunkTextWithMode: vi.fn((text) => [text]),
166
+ chunkMarkdownTextWithMode: vi.fn((text) => [text]),
167
+ },
168
+ reply: {
169
+ createReplyDispatcherWithTyping: createReplyDispatcherWithTypingMock,
170
+ resolveHumanDelayConfig: vi.fn(() => undefined),
171
+ },
172
+ },
173
+ });
174
+ });
175
+
176
+ function useNonStreamingAutoAccount() {
177
+ resolveFeishuAccountMock.mockReturnValue({
178
+ accountId: "main",
179
+ appId: "app_id",
180
+ appSecret: "app_secret",
181
+ domain: "feishu",
182
+ config: {
183
+ renderMode: "auto",
184
+ streaming: false,
185
+ },
186
+ });
187
+ }
188
+
189
+ function setupNonStreamingAutoDispatcher() {
190
+ useNonStreamingAutoAccount();
191
+
192
+ createFeishuReplyDispatcher({
193
+ cfg: {} as never,
194
+ agentId: "agent",
195
+ runtime: { log: vi.fn(), error: vi.fn() } as never,
196
+ chatId: "oc_chat",
197
+ });
198
+
199
+ return firstMockArg(createReplyDispatcherWithTypingMock, "reply dispatcher options");
200
+ }
201
+
202
+ function createRuntimeLogger() {
203
+ return { log: vi.fn(), error: vi.fn() } as never;
204
+ }
205
+
206
+ function createDispatcherHarness(overrides: Partial<ReplyDispatcherArgs> = {}) {
207
+ const result = createFeishuReplyDispatcher({
208
+ cfg: {} as never,
209
+ agentId: "agent",
210
+ runtime: {} as never,
211
+ chatId: "oc_chat",
212
+ ...overrides,
213
+ });
214
+
215
+ return {
216
+ result,
217
+ options: createReplyDispatcherWithTypingMock.mock.calls.at(-1)?.[0],
218
+ };
219
+ }
220
+
221
+ function isRecord(value: unknown): value is Record<string, unknown> {
222
+ return typeof value === "object" && value !== null && !Array.isArray(value);
223
+ }
224
+
225
+ function requireRecord(value: unknown, label: string): Record<string, unknown> {
226
+ expect(isRecord(value), `${label} must be an object`).toBe(true);
227
+ return value as Record<string, unknown>;
228
+ }
229
+
230
+ function expectRecordFields(
231
+ value: unknown,
232
+ label: string,
233
+ expected: Record<string, unknown>,
234
+ ): Record<string, unknown> {
235
+ const record = requireRecord(value, label);
236
+ for (const [key, expectedValue] of Object.entries(expected)) {
237
+ expect(record[key], `${label}.${key}`).toEqual(expectedValue);
238
+ }
239
+ return record;
240
+ }
241
+
242
+ function expectMockArgFields(
243
+ mock: ReturnType<typeof vi.fn>,
244
+ label: string,
245
+ expected: Record<string, unknown>,
246
+ callIndex = 0,
247
+ argIndex = 0,
248
+ ): Record<string, unknown> {
249
+ return expectRecordFields(mockArg(mock, callIndex, argIndex, label), label, expected);
250
+ }
251
+
252
+ function mockArg(
253
+ mock: ReturnType<typeof vi.fn>,
254
+ callIndex: number,
255
+ argIndex: number,
256
+ label: string,
257
+ ) {
258
+ const call = mock.mock.calls[callIndex];
259
+ if (!call) {
260
+ throw new Error(`missing ${label} call ${callIndex + 1}`);
261
+ }
262
+ return call[argIndex];
263
+ }
264
+
265
+ function firstMockArg(mock: ReturnType<typeof vi.fn>, label: string, argIndex = 0) {
266
+ return mockArg(mock, 0, argIndex, label);
267
+ }
268
+
269
+ function firstTypingDispatcherOptions(): TypingDispatcherOptions {
270
+ return firstMockArg(
271
+ createReplyDispatcherWithTypingMock,
272
+ "reply dispatcher options",
273
+ ) as TypingDispatcherOptions;
274
+ }
275
+
276
+ function firstStreamingCloseText(instanceIndex = 0): string {
277
+ const close = streamingInstances[instanceIndex]?.close;
278
+ if (!close) {
279
+ throw new Error(`Expected streaming instance ${instanceIndex}`);
280
+ }
281
+ return String(firstMockArg(close, "streaming close"));
282
+ }
283
+
284
+ function expectLastMockArgFields(
285
+ mock: ReturnType<typeof vi.fn>,
286
+ label: string,
287
+ expected: Record<string, unknown>,
288
+ argIndex = 0,
289
+ ): Record<string, unknown> {
290
+ const callIndex = mock.mock.calls.length - 1;
291
+ return expectMockArgFields(mock, label, expected, callIndex, argIndex);
292
+ }
293
+
294
+ function expectStreamingStartOptions(
295
+ instanceIndex: number,
296
+ expected: Record<string, unknown>,
297
+ ): Record<string, unknown> {
298
+ const start = streamingInstances[instanceIndex]?.start;
299
+ if (!start) {
300
+ throw new Error(`Expected streaming instance ${instanceIndex}`);
301
+ }
302
+ expect(firstMockArg(start, "streaming start")).toBe("oc_chat");
303
+ expect(firstMockArg(start, "streaming start", 1)).toBe("chat_id");
304
+ return expectRecordFields(
305
+ firstMockArg(start, "streaming start", 2),
306
+ "streaming start options",
307
+ expected,
308
+ );
309
+ }
310
+
311
+ function streamingUpdateTexts(instanceIndex = 0): string[] {
312
+ return streamingInstances[instanceIndex].update.mock.calls.map((call: unknown[]) =>
313
+ typeof call[0] === "string" ? call[0] : "",
314
+ );
315
+ }
316
+
317
+ it("skips typing indicator when account typingIndicator is disabled", async () => {
318
+ resolveFeishuAccountMock.mockReturnValue({
319
+ accountId: "main",
320
+ appId: "app_id",
321
+ appSecret: "app_secret",
322
+ domain: "feishu",
323
+ config: {
324
+ renderMode: "auto",
325
+ streaming: true,
326
+ typingIndicator: false,
327
+ },
328
+ });
329
+
330
+ createFeishuReplyDispatcher({
331
+ cfg: {} as never,
332
+ agentId: "agent",
333
+ runtime: {} as never,
334
+ chatId: "oc_chat",
335
+ replyToMessageId: "om_parent",
336
+ });
337
+
338
+ const options = firstTypingDispatcherOptions();
339
+ await options.onReplyStart?.();
340
+
341
+ expect(addTypingIndicatorMock).not.toHaveBeenCalled();
342
+ });
343
+
344
+ it("skips typing indicator for stale replayed messages", async () => {
345
+ createFeishuReplyDispatcher({
346
+ cfg: {} as never,
347
+ agentId: "agent",
348
+ runtime: {} as never,
349
+ chatId: "oc_chat",
350
+ replyToMessageId: "om_parent",
351
+ messageCreateTimeMs: Date.now() - 3 * 60_000,
352
+ });
353
+
354
+ const options = firstTypingDispatcherOptions();
355
+ await options.onReplyStart?.();
356
+
357
+ expect(addTypingIndicatorMock).not.toHaveBeenCalled();
358
+ });
359
+
360
+ it("treats second-based timestamps as stale for typing suppression", async () => {
361
+ createFeishuReplyDispatcher({
362
+ cfg: {} as never,
363
+ agentId: "agent",
364
+ runtime: {} as never,
365
+ chatId: "oc_chat",
366
+ replyToMessageId: "om_parent",
367
+ messageCreateTimeMs: Math.floor((Date.now() - 3 * 60_000) / 1000),
368
+ });
369
+
370
+ const options = firstTypingDispatcherOptions();
371
+ await options.onReplyStart?.();
372
+
373
+ expect(addTypingIndicatorMock).not.toHaveBeenCalled();
374
+ });
375
+
376
+ it("keeps typing indicator for fresh messages", async () => {
377
+ createFeishuReplyDispatcher({
378
+ cfg: {} as never,
379
+ agentId: "agent",
380
+ runtime: {} as never,
381
+ chatId: "oc_chat",
382
+ replyToMessageId: "om_parent",
383
+ messageCreateTimeMs: Date.now() - 30_000,
384
+ });
385
+
386
+ const options = firstTypingDispatcherOptions();
387
+ await options.onReplyStart?.();
388
+
389
+ expect(addTypingIndicatorMock).toHaveBeenCalledTimes(1);
390
+ expectMockArgFields(addTypingIndicatorMock, "typing indicator params", {
391
+ messageId: "om_parent",
392
+ });
393
+ });
394
+
395
+ it("streams auto mode plain final text when streaming is enabled", async () => {
396
+ const { options } = createDispatcherHarness();
397
+ await options.deliver({ text: "plain text" }, { kind: "final" });
398
+ await options.onIdle?.();
399
+
400
+ expect(streamingInstances).toHaveLength(1);
401
+ expect(streamingInstances[0].close).toHaveBeenCalledWith("plain text", {
402
+ note: "Agent: agent",
403
+ });
404
+ expect(sendMessageFeishuMock).not.toHaveBeenCalled();
405
+ expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
406
+ });
407
+
408
+ it("keeps oversized auto mode plain final text on the chunked message path", async () => {
409
+ const runtime = getFeishuRuntimeMock();
410
+ runtime.channel.text.resolveTextChunkLimit.mockReturnValue(10);
411
+ runtime.channel.text.chunkTextWithMode.mockReturnValue(["0123456789", "abcdefghij"]);
412
+
413
+ const { options } = createDispatcherHarness();
414
+ await options.deliver({ text: "0123456789abcdefghij" }, { kind: "final" });
415
+ await options.onIdle?.();
416
+
417
+ expect(streamingInstances).toHaveLength(0);
418
+ expect(sendMessageFeishuMock).toHaveBeenCalledTimes(2);
419
+ expectMockArgFields(sendMessageFeishuMock, "first message send params", {
420
+ text: "0123456789",
421
+ });
422
+ expectMockArgFields(
423
+ sendMessageFeishuMock,
424
+ "second message send params",
425
+ {
426
+ text: "abcdefghij",
427
+ },
428
+ 1,
429
+ );
430
+ expect(sendStructuredCardFeishuMock).not.toHaveBeenCalled();
431
+ });
432
+
433
+ it("keeps oversized auto mode markdown final text on the chunked card path", async () => {
434
+ const runtime = getFeishuRuntimeMock();
435
+ runtime.channel.text.resolveTextChunkLimit.mockReturnValue(10);
436
+ runtime.channel.text.chunkMarkdownTextWithMode.mockReturnValue(["```ts\nx\n```", "tail"]);
437
+
438
+ const { options } = createDispatcherHarness({ runtime: createRuntimeLogger() });
439
+ await options.deliver({ text: "```ts\nconst x = 1\n```\ntail" }, { kind: "final" });
440
+ await options.onIdle?.();
441
+
442
+ expect(streamingInstances).toHaveLength(0);
443
+ expect(runtime.channel.text.chunkMarkdownTextWithMode).toHaveBeenCalledTimes(1);
444
+ expect(runtime.channel.text.chunkTextWithMode).not.toHaveBeenCalled();
445
+ expect(sendStructuredCardFeishuMock).toHaveBeenCalledTimes(2);
446
+ expectMockArgFields(sendStructuredCardFeishuMock, "first card send params", {
447
+ text: "```ts\nx\n```",
448
+ });
449
+ expectMockArgFields(
450
+ sendStructuredCardFeishuMock,
451
+ "second card send params",
452
+ {
453
+ text: "tail",
454
+ },
455
+ 1,
456
+ );
457
+ expect(sendMessageFeishuMock).not.toHaveBeenCalled();
458
+ });
459
+
460
+ it("discards partial streaming preview before oversized final text fallback", async () => {
461
+ const runtime = getFeishuRuntimeMock();
462
+ runtime.channel.text.resolveTextChunkLimit.mockReturnValue(10);
463
+ runtime.channel.text.chunkTextWithMode.mockReturnValue(["final text", " overflow"]);
464
+
465
+ const { result, options } = createDispatcherHarness({ runtime: createRuntimeLogger() });
466
+ result.replyOptions.onPartialReply?.({ text: "partial" });
467
+ await options.deliver({ text: "final text overflow" }, { kind: "final" });
468
+ await options.onIdle?.();
469
+
470
+ expect(streamingInstances).toHaveLength(1);
471
+ expect(streamingInstances[0].discard).toHaveBeenCalledTimes(1);
472
+ expect(streamingInstances[0].close).not.toHaveBeenCalled();
473
+ expect(sendMessageFeishuMock).toHaveBeenCalledTimes(2);
474
+ expectMockArgFields(sendMessageFeishuMock, "first message send params", {
475
+ text: "final text",
476
+ });
477
+ expectMockArgFields(
478
+ sendMessageFeishuMock,
479
+ "second message send params",
480
+ {
481
+ text: " overflow",
482
+ },
483
+ 1,
484
+ );
485
+ });
486
+
487
+ it("keeps auto mode plain tool text on the message path when streaming is enabled", async () => {
488
+ const { options } = createDispatcherHarness();
489
+ await options.deliver({ text: "tool summary" }, { kind: "tool" });
490
+
491
+ expect(streamingInstances).toHaveLength(0);
492
+ expect(sendMessageFeishuMock).toHaveBeenCalledTimes(1);
493
+ expectMockArgFields(sendMessageFeishuMock, "message send params", {
494
+ text: "tool summary",
495
+ });
496
+ expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
497
+ });
498
+
499
+ it("keeps active auto mode streaming sessions from swallowing tool text", async () => {
500
+ const { result, options } = createDispatcherHarness({
501
+ runtime: createRuntimeLogger(),
502
+ });
503
+
504
+ await options.onReplyStart?.();
505
+ result.replyOptions.onAssistantMessageStart?.();
506
+ await options.deliver({ text: "tool summary" }, { kind: "tool" });
507
+ await options.deliver({ text: "plain final answer" }, { kind: "final" });
508
+ await options.onIdle?.();
509
+
510
+ expect(streamingInstances).toHaveLength(1);
511
+ expect(streamingInstances[0].start).toHaveBeenCalledTimes(1);
512
+ expect(sendMessageFeishuMock).toHaveBeenCalledTimes(1);
513
+ expectMockArgFields(sendMessageFeishuMock, "message send params", {
514
+ text: "tool summary",
515
+ });
516
+ expect(streamingInstances[0].close).toHaveBeenCalledWith("plain final answer", {
517
+ note: "Agent: agent",
518
+ });
519
+ expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
520
+ });
521
+
522
+ it("keeps auto mode plain text on the message path when streaming is disabled", async () => {
523
+ const options = setupNonStreamingAutoDispatcher();
524
+ await options.deliver({ text: "plain text" }, { kind: "final" });
525
+
526
+ expect(streamingInstances).toHaveLength(0);
527
+ expect(sendMessageFeishuMock).toHaveBeenCalledTimes(1);
528
+ expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
529
+ });
530
+
531
+ it("does not attach automatic mentions to non-streaming plain text replies", async () => {
532
+ useNonStreamingAutoAccount();
533
+
534
+ const { options } = createDispatcherHarness({
535
+ replyToMessageId: "om_msg",
536
+ });
537
+ await options.deliver({ text: "plain text" }, { kind: "final" });
538
+
539
+ expect(sendMessageFeishuMock).toHaveBeenCalledTimes(1);
540
+ expect(firstMockArg(sendMessageFeishuMock, "send message params")).not.toHaveProperty(
541
+ "mentions",
542
+ );
543
+ });
544
+
545
+ it("does not attach automatic mentions to card replies", async () => {
546
+ resolveFeishuAccountMock.mockReturnValue({
547
+ accountId: "main",
548
+ appId: "app_id",
549
+ appSecret: "app_secret",
550
+ domain: "feishu",
551
+ config: {
552
+ renderMode: "card",
553
+ streaming: false,
554
+ },
555
+ });
556
+
557
+ const { options } = createDispatcherHarness({
558
+ replyToMessageId: "om_msg",
559
+ });
560
+ await options.deliver({ text: "card text" }, { kind: "final" });
561
+
562
+ expect(sendStructuredCardFeishuMock).toHaveBeenCalledTimes(1);
563
+ expect(firstMockArg(sendStructuredCardFeishuMock, "structured card params")).not.toHaveProperty(
564
+ "mentions",
565
+ );
566
+ });
567
+
568
+ it("suppresses internal block payload delivery", async () => {
569
+ const { options } = createDispatcherHarness();
570
+ await options.deliver({ text: "internal reasoning chunk" }, { kind: "block" });
571
+
572
+ expect(streamingInstances).toHaveLength(0);
573
+ expect(sendMessageFeishuMock).not.toHaveBeenCalled();
574
+ expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
575
+ expect(sendMediaFeishuMock).not.toHaveBeenCalled();
576
+ });
577
+
578
+ it("disables block streaming by default to prevent silent reply drops", () => {
579
+ const result = createFeishuReplyDispatcher({
580
+ cfg: {} as never,
581
+ agentId: "agent",
582
+ runtime: {} as never,
583
+ chatId: "oc_chat",
584
+ });
585
+
586
+ expect(result.replyOptions).toHaveProperty("disableBlockStreaming", true);
587
+ });
588
+
589
+ it("enables core block streaming when Feishu blockStreaming is explicitly true", async () => {
590
+ resolveFeishuAccountMock.mockReturnValue({
591
+ accountId: "main",
592
+ appId: "app_id",
593
+ appSecret: "app_secret",
594
+ domain: "feishu",
595
+ config: {
596
+ renderMode: "auto",
597
+ streaming: true,
598
+ blockStreaming: true,
599
+ },
600
+ });
601
+
602
+ const { result, options } = createDispatcherHarness();
603
+ expect(result.replyOptions).toHaveProperty("disableBlockStreaming", false);
604
+
605
+ await options.deliver({ text: "plain block" }, { kind: "block" });
606
+ await options.onIdle?.();
607
+
608
+ expect(streamingInstances).toHaveLength(1);
609
+ expect(streamingInstances[0].close).toHaveBeenCalledWith("plain block", {
610
+ note: "Agent: agent",
611
+ });
612
+ });
613
+
614
+ it("does not prepend automatic mentions to streaming card closes", async () => {
615
+ const overrides = {
616
+ runtime: createRuntimeLogger(),
617
+ mentionTargets: [{ openId: "ou-target", name: "Target User", key: "@_user_1" }],
618
+ } as Partial<ReplyDispatcherArgs>;
619
+ const { options } = createDispatcherHarness(overrides);
620
+ await options.deliver({ text: "```md\nanswer\n```" }, { kind: "final" });
621
+ await options.onIdle?.();
622
+
623
+ expect(streamingInstances).toHaveLength(1);
624
+ expect(streamingInstances[0].close).toHaveBeenCalledWith("```md\nanswer\n```", {
625
+ note: "Agent: agent",
626
+ });
627
+ });
628
+
629
+ it("keeps core block streaming disabled when Feishu blockStreaming is explicitly false", async () => {
630
+ resolveFeishuAccountMock.mockReturnValue({
631
+ accountId: "main",
632
+ appId: "app_id",
633
+ appSecret: "app_secret",
634
+ domain: "feishu",
635
+ config: {
636
+ renderMode: "auto",
637
+ streaming: true,
638
+ blockStreaming: false,
639
+ },
640
+ });
641
+
642
+ const result = createFeishuReplyDispatcher({
643
+ cfg: {} as never,
644
+ agentId: "agent",
645
+ runtime: {} as never,
646
+ chatId: "oc_chat",
647
+ });
648
+
649
+ expect(result.replyOptions).toHaveProperty("disableBlockStreaming", true);
650
+ });
651
+
652
+ it("uses streaming session for auto mode markdown payloads", async () => {
653
+ const { options } = createDispatcherHarness({
654
+ runtime: createRuntimeLogger(),
655
+ rootId: "om_root_topic",
656
+ });
657
+ await options.deliver({ text: "```ts\nconst x = 1\n```" }, { kind: "final" });
658
+ await options.onIdle?.();
659
+
660
+ expect(streamingInstances).toHaveLength(1);
661
+ expect(streamingInstances[0].start).toHaveBeenCalledTimes(1);
662
+ expectStreamingStartOptions(0, {
663
+ replyToMessageId: undefined,
664
+ replyInThread: undefined,
665
+ rootId: "om_root_topic",
666
+ header: { title: "agent", template: "blue" },
667
+ note: "Agent: agent",
668
+ });
669
+ expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
670
+ expect(sendMessageFeishuMock).not.toHaveBeenCalled();
671
+ expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
672
+ });
673
+
674
+ it("closes streaming with block text when final reply is missing", async () => {
675
+ const { options } = createDispatcherHarness({
676
+ runtime: createRuntimeLogger(),
677
+ });
678
+ await options.deliver({ text: "```md\npartial answer\n```" }, { kind: "block" });
679
+ await options.onIdle?.();
680
+
681
+ expect(streamingInstances).toHaveLength(1);
682
+ expect(streamingInstances[0].start).toHaveBeenCalledTimes(1);
683
+ expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
684
+ expect(streamingInstances[0].close).toHaveBeenCalledWith("```md\npartial answer\n```", {
685
+ note: "Agent: agent",
686
+ });
687
+ });
688
+
689
+ it("coalesces distinct final payloads into one streaming card until idle", async () => {
690
+ const { options } = createDispatcherHarness({
691
+ runtime: createRuntimeLogger(),
692
+ });
693
+ await options.deliver({ text: "```md\n完整回复第一段\n```" }, { kind: "final" });
694
+ await options.deliver({ text: "```md\n完整回复第一段 + 第二段\n```" }, { kind: "final" });
695
+ await options.onIdle?.();
696
+
697
+ expect(streamingInstances).toHaveLength(1);
698
+ expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
699
+ expect(streamingInstances[0].close).toHaveBeenCalledWith(
700
+ "```md\n完整回复第一段 + 第二段\n```",
701
+ {
702
+ note: "Agent: agent",
703
+ },
704
+ );
705
+ expect(sendMessageFeishuMock).not.toHaveBeenCalled();
706
+ expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
707
+ });
708
+
709
+ it("skips exact duplicate final text after streaming close", async () => {
710
+ const { options } = createDispatcherHarness({
711
+ runtime: createRuntimeLogger(),
712
+ });
713
+ await options.deliver({ text: "```md\n同一条回复\n```" }, { kind: "final" });
714
+ await options.onIdle?.();
715
+ await options.deliver({ text: "```md\n同一条回复\n```" }, { kind: "final" });
716
+
717
+ expect(streamingInstances).toHaveLength(1);
718
+ expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
719
+ expect(streamingInstances[0].close).toHaveBeenCalledWith("```md\n同一条回复\n```", {
720
+ note: "Agent: agent",
721
+ });
722
+ expect(sendMessageFeishuMock).not.toHaveBeenCalled();
723
+ expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
724
+ });
725
+
726
+ it("skips final text already closed by idle streaming", async () => {
727
+ resolveFeishuAccountMock.mockReturnValue({
728
+ accountId: "main",
729
+ appId: "app_id",
730
+ appSecret: "app_secret",
731
+ domain: "feishu",
732
+ config: {
733
+ renderMode: "card",
734
+ streaming: true,
735
+ },
736
+ });
737
+
738
+ const { result, options } = createDispatcherHarness({
739
+ runtime: createRuntimeLogger(),
740
+ });
741
+
742
+ await options.onReplyStart?.();
743
+ result.replyOptions.onPartialReply?.({ text: "```md\nidle streamed reply\n```" });
744
+ await options.onIdle?.();
745
+ await options.deliver({ text: "```md\nidle streamed reply\n```" }, { kind: "final" });
746
+
747
+ expect(streamingInstances).toHaveLength(1);
748
+ expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
749
+ expect(streamingInstances[0].close).toHaveBeenCalledWith("```md\nidle streamed reply\n```", {
750
+ note: "Agent: agent",
751
+ });
752
+ expect(sendMessageFeishuMock).not.toHaveBeenCalled();
753
+ expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
754
+ expect(sendStructuredCardFeishuMock).not.toHaveBeenCalled();
755
+ });
756
+
757
+ it("waits for deliverable text before starting a card after assistant message start", async () => {
758
+ const { result, options } = createDispatcherHarness({
759
+ runtime: createRuntimeLogger(),
760
+ });
761
+
762
+ await options.onReplyStart?.();
763
+ result.replyOptions.onAssistantMessageStart?.();
764
+ await options.deliver({ text: "plain final answer" }, { kind: "final" });
765
+ await options.onIdle?.();
766
+
767
+ expect(streamingInstances).toHaveLength(1);
768
+ expect(streamingInstances[0].start).toHaveBeenCalledTimes(1);
769
+ expect(streamingInstances[0].close).toHaveBeenCalledWith("plain final answer", {
770
+ note: "Agent: agent",
771
+ });
772
+ expect(sendMessageFeishuMock).not.toHaveBeenCalled();
773
+ });
774
+
775
+ it("does not create an empty card when assistant message start has no deliverable final", async () => {
776
+ const { result, options } = createDispatcherHarness({
777
+ runtime: createRuntimeLogger(),
778
+ });
779
+
780
+ await options.onReplyStart?.();
781
+ result.replyOptions.onAssistantMessageStart?.();
782
+ await options.onIdle?.();
783
+
784
+ expect(streamingInstances).toHaveLength(0);
785
+ expect(sendMessageFeishuMock).not.toHaveBeenCalled();
786
+ expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
787
+ expect(sendStructuredCardFeishuMock).not.toHaveBeenCalled();
788
+ });
789
+
790
+ it("starts a streaming card from partial snapshots in auto mode", async () => {
791
+ const { result, options } = createDispatcherHarness({
792
+ runtime: createRuntimeLogger(),
793
+ });
794
+
795
+ result.replyOptions.onPartialReply?.({ text: "plain" });
796
+ result.replyOptions.onPartialReply?.({ text: "plain streamed answer" });
797
+ await options.onIdle?.();
798
+
799
+ expect(streamingInstances).toHaveLength(1);
800
+ expect(streamingInstances[0].close).toHaveBeenCalledWith("plain streamed answer", {
801
+ note: "Agent: agent",
802
+ });
803
+ expect(sendMessageFeishuMock).not.toHaveBeenCalled();
804
+ });
805
+
806
+ it("skips distinct late final text after streaming card close", async () => {
807
+ resolveFeishuAccountMock.mockReturnValue({
808
+ accountId: "main",
809
+ appId: "app_id",
810
+ appSecret: "app_secret",
811
+ domain: "feishu",
812
+ config: {
813
+ renderMode: "card",
814
+ streaming: true,
815
+ },
816
+ });
817
+
818
+ const { options } = createDispatcherHarness({
819
+ runtime: createRuntimeLogger(),
820
+ });
821
+
822
+ await options.deliver({ text: "First complete answer" }, { kind: "final" });
823
+ await options.onIdle?.();
824
+ await options.deliver(
825
+ { text: "Late tool-result final", mediaUrl: "https://example.com/a.png" },
826
+ { kind: "final" },
827
+ );
828
+ await options.onIdle?.();
829
+
830
+ expect(streamingInstances).toHaveLength(1);
831
+ expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
832
+ expect(streamingInstances[0].close).toHaveBeenCalledWith("First complete answer", {
833
+ note: "Agent: agent",
834
+ });
835
+ expect(sendMessageFeishuMock).not.toHaveBeenCalled();
836
+ expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
837
+ expect(sendStructuredCardFeishuMock).not.toHaveBeenCalled();
838
+ expect(sendMediaFeishuMock).toHaveBeenCalledTimes(1);
839
+ expectMockArgFields(sendMediaFeishuMock, "media send params", {
840
+ mediaUrl: "https://example.com/a.png",
841
+ });
842
+ });
843
+
844
+ it("skips oversized late final text after streaming card close", async () => {
845
+ const runtime = getFeishuRuntimeMock();
846
+ runtime.channel.text.resolveTextChunkLimit.mockReturnValue(10);
847
+ runtime.channel.text.chunkTextWithMode.mockReturnValue(["oversized ", "late final"]);
848
+
849
+ const { options } = createDispatcherHarness({
850
+ runtime: createRuntimeLogger(),
851
+ });
852
+
853
+ await options.deliver({ text: "First" }, { kind: "final" });
854
+ await options.onIdle?.();
855
+ await options.deliver(
856
+ { text: "oversized late final", mediaUrl: "https://example.com/a.png" },
857
+ { kind: "final" },
858
+ );
859
+ await options.onIdle?.();
860
+
861
+ expect(streamingInstances).toHaveLength(1);
862
+ expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
863
+ expect(sendMessageFeishuMock).not.toHaveBeenCalled();
864
+ expect(sendStructuredCardFeishuMock).not.toHaveBeenCalled();
865
+ expect(sendMediaFeishuMock).toHaveBeenCalledTimes(1);
866
+ expectMockArgFields(sendMediaFeishuMock, "media send params", {
867
+ mediaUrl: "https://example.com/a.png",
868
+ });
869
+ });
870
+
871
+ it("suppresses duplicate final text while still sending media", async () => {
872
+ const options = setupNonStreamingAutoDispatcher();
873
+ await options.deliver({ text: "plain final" }, { kind: "final" });
874
+ await options.deliver(
875
+ { text: "plain final", mediaUrl: "https://example.com/a.png" },
876
+ { kind: "final" },
877
+ );
878
+
879
+ expect(sendMessageFeishuMock).toHaveBeenCalledTimes(1);
880
+ expectLastMockArgFields(sendMessageFeishuMock, "message send params", {
881
+ text: "plain final",
882
+ });
883
+ expect(sendMediaFeishuMock).toHaveBeenCalledTimes(1);
884
+ expectMockArgFields(sendMediaFeishuMock, "media send params", {
885
+ mediaUrl: "https://example.com/a.png",
886
+ });
887
+ });
888
+
889
+ it("keeps distinct non-streaming final payloads", async () => {
890
+ const options = setupNonStreamingAutoDispatcher();
891
+ await options.deliver({ text: "notice header" }, { kind: "final" });
892
+ await options.deliver({ text: "actual answer body" }, { kind: "final" });
893
+
894
+ expect(sendMessageFeishuMock).toHaveBeenCalledTimes(2);
895
+ expectMockArgFields(sendMessageFeishuMock, "first message send params", {
896
+ text: "notice header",
897
+ });
898
+ expectMockArgFields(
899
+ sendMessageFeishuMock,
900
+ "second message send params",
901
+ {
902
+ text: "actual answer body",
903
+ },
904
+ 1,
905
+ );
906
+ });
907
+
908
+ it("treats block updates as delta chunks", async () => {
909
+ resolveFeishuAccountMock.mockReturnValue({
910
+ accountId: "main",
911
+ appId: "app_id",
912
+ appSecret: "app_secret",
913
+ domain: "feishu",
914
+ config: {
915
+ renderMode: "card",
916
+ streaming: true,
917
+ },
918
+ });
919
+
920
+ const { result, options } = createDispatcherHarness({
921
+ runtime: createRuntimeLogger(),
922
+ });
923
+ await options.onReplyStart?.();
924
+ result.replyOptions.onPartialReply?.({ text: "hello" });
925
+ await options.deliver({ text: "lo world" }, { kind: "block" });
926
+ await options.onIdle?.();
927
+
928
+ expect(streamingInstances).toHaveLength(1);
929
+ expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
930
+ expect(streamingInstances[0].close).toHaveBeenCalledWith("hellolo world", {
931
+ note: "Agent: agent",
932
+ });
933
+ });
934
+
935
+ it("skips block payloads that exactly repeat the latest partial snapshot", async () => {
936
+ resolveFeishuAccountMock.mockReturnValue({
937
+ accountId: "main",
938
+ appId: "app_id",
939
+ appSecret: "app_secret",
940
+ domain: "feishu",
941
+ config: {
942
+ renderMode: "card",
943
+ streaming: true,
944
+ },
945
+ });
946
+
947
+ const { result, options } = createDispatcherHarness({
948
+ runtime: createRuntimeLogger(),
949
+ });
950
+ await options.onReplyStart?.();
951
+ result.replyOptions.onPartialReply?.({ text: "```md\npartial\n```" });
952
+ await options.deliver({ text: "```md\npartial\n```" }, { kind: "block" });
953
+ await options.onIdle?.();
954
+
955
+ expect(streamingInstances).toHaveLength(1);
956
+ expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
957
+ expect(streamingInstances[0].close).toHaveBeenCalledWith("```md\npartial\n```", {
958
+ note: "Agent: agent",
959
+ });
960
+ });
961
+
962
+ it("preserves previous generation blocks when partial snapshots reset after tools", async () => {
963
+ resolveFeishuAccountMock.mockReturnValue({
964
+ accountId: "main",
965
+ appId: "app_id",
966
+ appSecret: "app_secret",
967
+ domain: "feishu",
968
+ config: {
969
+ renderMode: "card",
970
+ streaming: true,
971
+ },
972
+ });
973
+
974
+ const { result, options } = createDispatcherHarness({
975
+ runtime: createRuntimeLogger(),
976
+ });
977
+ await options.onReplyStart?.();
978
+ result.replyOptions.onPartialReply?.({
979
+ text: "Preparing the lookup plan with enough text to count as one block.",
980
+ });
981
+ result.replyOptions.onPartialReply?.({ text: "Found" });
982
+ result.replyOptions.onPartialReply?.({ text: "Found the answer." });
983
+ await options.onIdle?.();
984
+
985
+ expect(streamingInstances).toHaveLength(1);
986
+ expect(streamingInstances[0].close).toHaveBeenCalledWith(
987
+ "Preparing the lookup plan with enough text to count as one block.Found the answer.",
988
+ {
989
+ note: "Agent: agent",
990
+ },
991
+ );
992
+ });
993
+
994
+ it("strips reasoning tags from streamed partial snapshots", async () => {
995
+ resolveFeishuAccountMock.mockReturnValue({
996
+ accountId: "main",
997
+ appId: "app_id",
998
+ appSecret: "app_secret",
999
+ domain: "feishu",
1000
+ config: {
1001
+ renderMode: "card",
1002
+ streaming: true,
1003
+ },
1004
+ });
1005
+
1006
+ const { result, options } = createDispatcherHarness({
1007
+ runtime: createRuntimeLogger(),
1008
+ });
1009
+ await options.onReplyStart?.();
1010
+ result.replyOptions.onPartialReply?.({
1011
+ text: "<thinking>private chain of thought</thinking>\nvisible answer",
1012
+ });
1013
+ await options.onIdle?.();
1014
+
1015
+ expect(streamingInstances[0].close).toHaveBeenCalledWith("visible answer", {
1016
+ note: "Agent: agent",
1017
+ });
1018
+ });
1019
+
1020
+ it("sends media-only payloads as attachments", async () => {
1021
+ const { options } = createDispatcherHarness();
1022
+ await options.deliver({ mediaUrl: "https://example.com/a.png" }, { kind: "final" });
1023
+
1024
+ expect(sendMediaFeishuMock).toHaveBeenCalledTimes(1);
1025
+ expectMockArgFields(sendMediaFeishuMock, "media send params", {
1026
+ to: "oc_chat",
1027
+ mediaUrl: "https://example.com/a.png",
1028
+ });
1029
+ expect(sendMessageFeishuMock).not.toHaveBeenCalled();
1030
+ expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
1031
+ });
1032
+
1033
+ it("passes audioAsVoice to media attachments", async () => {
1034
+ const { options } = createDispatcherHarness();
1035
+ await options.deliver(
1036
+ { mediaUrl: "https://example.com/reply.mp3", audioAsVoice: true },
1037
+ { kind: "final" },
1038
+ );
1039
+
1040
+ expectMockArgFields(sendMediaFeishuMock, "media send params", {
1041
+ mediaUrl: "https://example.com/reply.mp3",
1042
+ audioAsVoice: true,
1043
+ });
1044
+ });
1045
+
1046
+ it("suppresses duplicate text when final replies send voice media", async () => {
1047
+ const { options } = createDispatcherHarness();
1048
+ await options.deliver(
1049
+ {
1050
+ text: "spoken reply",
1051
+ mediaUrl: "https://example.com/reply.mp3",
1052
+ audioAsVoice: true,
1053
+ },
1054
+ { kind: "final" },
1055
+ );
1056
+
1057
+ expect(sendMessageFeishuMock).not.toHaveBeenCalled();
1058
+ expect(sendStructuredCardFeishuMock).not.toHaveBeenCalled();
1059
+ expect(sendMediaFeishuMock).toHaveBeenCalledTimes(1);
1060
+ expectMockArgFields(sendMediaFeishuMock, "media send params", {
1061
+ mediaUrl: "https://example.com/reply.mp3",
1062
+ audioAsVoice: true,
1063
+ });
1064
+ });
1065
+
1066
+ it("discards partial streaming text when final replies send voice media", async () => {
1067
+ const { result, options } = createDispatcherHarness({
1068
+ runtime: createRuntimeLogger(),
1069
+ });
1070
+
1071
+ result.replyOptions.onPartialReply?.({ text: "spoken reply" });
1072
+ await options.deliver(
1073
+ {
1074
+ text: "spoken reply",
1075
+ mediaUrl: "https://example.com/reply.mp3",
1076
+ audioAsVoice: true,
1077
+ },
1078
+ { kind: "final" },
1079
+ );
1080
+ await options.onIdle?.();
1081
+
1082
+ expect(streamingInstances).toHaveLength(1);
1083
+ expect(streamingInstances[0].discard).toHaveBeenCalledTimes(1);
1084
+ expect(streamingInstances[0].close).not.toHaveBeenCalled();
1085
+ expect(sendMessageFeishuMock).not.toHaveBeenCalled();
1086
+ expect(sendStructuredCardFeishuMock).not.toHaveBeenCalled();
1087
+ expect(sendMediaFeishuMock).toHaveBeenCalledTimes(1);
1088
+ expectMockArgFields(sendMediaFeishuMock, "media send params", {
1089
+ mediaUrl: "https://example.com/reply.mp3",
1090
+ audioAsVoice: true,
1091
+ });
1092
+ });
1093
+
1094
+ it("keeps partial streaming text when final replies send regular media only", async () => {
1095
+ const { result, options } = createDispatcherHarness({
1096
+ runtime: createRuntimeLogger(),
1097
+ });
1098
+
1099
+ result.replyOptions.onPartialReply?.({ text: "caption from stream" });
1100
+ await options.deliver(
1101
+ {
1102
+ mediaUrl: "https://example.com/image.png",
1103
+ },
1104
+ { kind: "final" },
1105
+ );
1106
+ await options.onIdle?.();
1107
+
1108
+ expect(streamingInstances).toHaveLength(1);
1109
+ expect(streamingInstances[0].discard).not.toHaveBeenCalled();
1110
+ expect(streamingInstances[0].close).toHaveBeenCalledWith("caption from stream", {
1111
+ note: "Agent: agent",
1112
+ });
1113
+ expect(sendMessageFeishuMock).not.toHaveBeenCalled();
1114
+ expect(sendStructuredCardFeishuMock).not.toHaveBeenCalled();
1115
+ expect(sendMediaFeishuMock).toHaveBeenCalledTimes(1);
1116
+ expectMockArgFields(sendMediaFeishuMock, "media send params", {
1117
+ mediaUrl: "https://example.com/image.png",
1118
+ });
1119
+ });
1120
+
1121
+ it("sends skipped voice text when final voice media degrades to a file attachment", async () => {
1122
+ sendMediaFeishuMock.mockResolvedValueOnce({
1123
+ messageId: "file_msg",
1124
+ voiceIntentDegradedToFile: true,
1125
+ });
1126
+
1127
+ const { options } = createDispatcherHarness();
1128
+ await options.deliver(
1129
+ {
1130
+ text: "spoken reply",
1131
+ mediaUrl: "https://example.com/reply.mp3",
1132
+ audioAsVoice: true,
1133
+ },
1134
+ { kind: "final" },
1135
+ );
1136
+
1137
+ expect(sendMediaFeishuMock).toHaveBeenCalledTimes(1);
1138
+ expectMockArgFields(sendMediaFeishuMock, "media send params", {
1139
+ mediaUrl: "https://example.com/reply.mp3",
1140
+ audioAsVoice: true,
1141
+ });
1142
+ expect(sendMessageFeishuMock).toHaveBeenCalledTimes(1);
1143
+ expectMockArgFields(sendMessageFeishuMock, "message send params", {
1144
+ text: "spoken reply",
1145
+ });
1146
+ });
1147
+
1148
+ it("suppresses duplicate text for native voice media without audioAsVoice", async () => {
1149
+ const { options } = createDispatcherHarness();
1150
+ await options.deliver(
1151
+ {
1152
+ text: "spoken reply",
1153
+ mediaUrl: "https://example.com/reply.opus?download=1",
1154
+ },
1155
+ { kind: "final" },
1156
+ );
1157
+
1158
+ expect(sendMessageFeishuMock).not.toHaveBeenCalled();
1159
+ expect(sendMediaFeishuMock).toHaveBeenCalledTimes(1);
1160
+ expectMockArgFields(sendMediaFeishuMock, "media send params", {
1161
+ mediaUrl: "https://example.com/reply.opus?download=1",
1162
+ });
1163
+ });
1164
+
1165
+ it("preserves captions for regular audio attachments", async () => {
1166
+ useNonStreamingAutoAccount();
1167
+ const { options } = createDispatcherHarness();
1168
+ await options.deliver(
1169
+ {
1170
+ text: "caption text",
1171
+ mediaUrl: "https://example.com/song.mp3",
1172
+ },
1173
+ { kind: "final" },
1174
+ );
1175
+
1176
+ expect(sendMessageFeishuMock).toHaveBeenCalledTimes(1);
1177
+ expectMockArgFields(sendMessageFeishuMock, "message send params", {
1178
+ text: "caption text",
1179
+ });
1180
+ expect(sendMediaFeishuMock).toHaveBeenCalledTimes(1);
1181
+ expectMockArgFields(sendMediaFeishuMock, "media send params", {
1182
+ mediaUrl: "https://example.com/song.mp3",
1183
+ });
1184
+ });
1185
+
1186
+ it("keeps skipped voice text in the upload failure fallback", async () => {
1187
+ sendMediaFeishuMock.mockRejectedValueOnce(new Error("media failed"));
1188
+
1189
+ const { options } = createDispatcherHarness();
1190
+ await options.deliver(
1191
+ {
1192
+ text: "spoken reply",
1193
+ mediaUrl: "https://example.com/reply.mp3",
1194
+ audioAsVoice: true,
1195
+ },
1196
+ { kind: "final" },
1197
+ );
1198
+
1199
+ expect(sendMessageFeishuMock).toHaveBeenCalledTimes(1);
1200
+ expectMockArgFields(sendMessageFeishuMock, "message send params", {
1201
+ text: "spoken reply\n\n📎 https://example.com/reply.mp3",
1202
+ });
1203
+ });
1204
+
1205
+ it("falls back to legacy mediaUrl when mediaUrls is an empty array", async () => {
1206
+ useNonStreamingAutoAccount();
1207
+ const { options } = createDispatcherHarness();
1208
+ await options.deliver(
1209
+ { text: "caption", mediaUrl: "https://example.com/a.png", mediaUrls: [] },
1210
+ { kind: "final" },
1211
+ );
1212
+
1213
+ expect(sendMessageFeishuMock).toHaveBeenCalledTimes(1);
1214
+ expect(sendMediaFeishuMock).toHaveBeenCalledTimes(1);
1215
+ expectMockArgFields(sendMediaFeishuMock, "media send params", {
1216
+ mediaUrl: "https://example.com/a.png",
1217
+ });
1218
+ });
1219
+
1220
+ it("sends attachments after streaming final markdown replies", async () => {
1221
+ const { options } = createDispatcherHarness({
1222
+ runtime: createRuntimeLogger(),
1223
+ });
1224
+ await options.deliver(
1225
+ { text: "```ts\nconst x = 1\n```", mediaUrls: ["https://example.com/a.png"] },
1226
+ { kind: "final" },
1227
+ );
1228
+ await options.onIdle?.();
1229
+
1230
+ expect(streamingInstances).toHaveLength(1);
1231
+ expect(streamingInstances[0].start).toHaveBeenCalledTimes(1);
1232
+ expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
1233
+ expect(sendMediaFeishuMock).toHaveBeenCalledTimes(1);
1234
+ expectMockArgFields(sendMediaFeishuMock, "media send params", {
1235
+ mediaUrl: "https://example.com/a.png",
1236
+ });
1237
+ });
1238
+
1239
+ it("passes replyInThread to sendMessageFeishu for plain text", async () => {
1240
+ useNonStreamingAutoAccount();
1241
+ const { options } = createDispatcherHarness({
1242
+ replyToMessageId: "om_msg",
1243
+ replyInThread: true,
1244
+ });
1245
+ await options.deliver({ text: "plain text" }, { kind: "final" });
1246
+
1247
+ expectMockArgFields(sendMessageFeishuMock, "message send params", {
1248
+ replyToMessageId: "om_msg",
1249
+ replyInThread: true,
1250
+ });
1251
+ });
1252
+
1253
+ it("allows top-level fallback for normal group quoted replies", async () => {
1254
+ useNonStreamingAutoAccount();
1255
+ const { options } = createDispatcherHarness({
1256
+ replyToMessageId: "om_quote_reply",
1257
+ replyInThread: true,
1258
+ threadReply: true,
1259
+ rootId: "om_original_msg",
1260
+ });
1261
+ await options.deliver({ text: "plain text" }, { kind: "final" });
1262
+
1263
+ expectMockArgFields(sendMessageFeishuMock, "message send params", {
1264
+ replyToMessageId: "om_quote_reply",
1265
+ replyInThread: true,
1266
+ allowTopLevelReplyFallback: true,
1267
+ });
1268
+ });
1269
+
1270
+ it("keeps native topic replies opted out of top-level fallback", async () => {
1271
+ useNonStreamingAutoAccount();
1272
+ const { options } = createDispatcherHarness({
1273
+ replyToMessageId: "om_topic_root",
1274
+ replyInThread: true,
1275
+ threadReply: true,
1276
+ rootId: "om_topic_root",
1277
+ });
1278
+ await options.deliver({ text: "plain text" }, { kind: "final" });
1279
+
1280
+ expectMockArgFields(sendMessageFeishuMock, "message send params", {
1281
+ replyToMessageId: "om_topic_root",
1282
+ replyInThread: true,
1283
+ allowTopLevelReplyFallback: false,
1284
+ });
1285
+ });
1286
+
1287
+ it("passes replyInThread to sendStructuredCardFeishu for card text", async () => {
1288
+ resolveFeishuAccountMock.mockReturnValue({
1289
+ accountId: "main",
1290
+ appId: "app_id",
1291
+ appSecret: "app_secret",
1292
+ domain: "feishu",
1293
+ config: {
1294
+ renderMode: "card",
1295
+ streaming: false,
1296
+ },
1297
+ });
1298
+
1299
+ const { options } = createDispatcherHarness({
1300
+ replyToMessageId: "om_msg",
1301
+ replyInThread: true,
1302
+ });
1303
+ await options.deliver({ text: "card text" }, { kind: "final" });
1304
+
1305
+ expectMockArgFields(sendStructuredCardFeishuMock, "structured card params", {
1306
+ replyToMessageId: "om_msg",
1307
+ replyInThread: true,
1308
+ });
1309
+ });
1310
+
1311
+ it("streams reasoning content as blockquote before answer", async () => {
1312
+ const { result, options } = createDispatcherHarness({
1313
+ runtime: createRuntimeLogger(),
1314
+ allowReasoningPreview: true,
1315
+ });
1316
+
1317
+ await options.onReplyStart?.();
1318
+ result.replyOptions.onReasoningStream?.({ text: "thinking step 1" });
1319
+ result.replyOptions.onReasoningStream?.({
1320
+ text: "thinking step 1\nstep 2",
1321
+ });
1322
+ result.replyOptions.onPartialReply?.({ text: "answer part" });
1323
+ result.replyOptions.onReasoningEnd?.();
1324
+ await options.deliver({ text: "answer part final" }, { kind: "final" });
1325
+ await options.onIdle?.();
1326
+
1327
+ expect(streamingInstances).toHaveLength(1);
1328
+ const updateCalls = streamingInstances[0].update.mock.calls.map((c: unknown[]) =>
1329
+ typeof c[0] === "string" ? c[0] : "",
1330
+ );
1331
+ const reasoningUpdate = updateCalls.find((c) => c.includes("Thinking"));
1332
+ expect(reasoningUpdate).toContain("> 💭 **Thinking**");
1333
+ // formatReasoningPrefix strips "Reasoning:" prefix and italic markers
1334
+ expect(reasoningUpdate).toContain("> thinking step");
1335
+ expect(reasoningUpdate).not.toContain("Reasoning:");
1336
+ expect(reasoningUpdate).not.toMatch(/> _.*_/);
1337
+
1338
+ const combinedUpdate = updateCalls.find((c) => c.includes("Thinking") && c.includes("---"));
1339
+ if (!combinedUpdate) {
1340
+ throw new Error("expected combined reasoning and final-answer streaming update");
1341
+ }
1342
+
1343
+ expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
1344
+ const closeArg = firstStreamingCloseText();
1345
+ expect(closeArg).toContain("> 💭 **Thinking**");
1346
+ expect(closeArg).toContain("---");
1347
+ expect(closeArg).toContain("answer part final");
1348
+ });
1349
+
1350
+ it("provides onReasoningStream and onReasoningEnd when reasoning previews are allowed", () => {
1351
+ const { result } = createDispatcherHarness({
1352
+ runtime: createRuntimeLogger(),
1353
+ allowReasoningPreview: true,
1354
+ });
1355
+
1356
+ expect(result.replyOptions.onReasoningStream).toBeTypeOf("function");
1357
+ expect(result.replyOptions.onReasoningEnd).toBeTypeOf("function");
1358
+ });
1359
+
1360
+ it("omits reasoning callbacks unless reasoning previews are allowed", () => {
1361
+ const { result } = createDispatcherHarness({
1362
+ runtime: createRuntimeLogger(),
1363
+ });
1364
+
1365
+ expect(result.replyOptions.onReasoningStream).toBeUndefined();
1366
+ expect(result.replyOptions.onReasoningEnd).toBeUndefined();
1367
+ });
1368
+
1369
+ it("omits reasoning callbacks when streaming is disabled", () => {
1370
+ resolveFeishuAccountMock.mockReturnValue({
1371
+ accountId: "main",
1372
+ appId: "app_id",
1373
+ appSecret: "app_secret",
1374
+ domain: "feishu",
1375
+ config: {
1376
+ renderMode: "auto",
1377
+ streaming: false,
1378
+ },
1379
+ });
1380
+
1381
+ const { result } = createDispatcherHarness({
1382
+ runtime: createRuntimeLogger(),
1383
+ });
1384
+
1385
+ expect(result.replyOptions.onReasoningStream).toBeUndefined();
1386
+ expect(result.replyOptions.onReasoningEnd).toBeUndefined();
1387
+ });
1388
+
1389
+ it("renders reasoning-only card when no answer text arrives", async () => {
1390
+ const { result, options } = createDispatcherHarness({
1391
+ runtime: createRuntimeLogger(),
1392
+ allowReasoningPreview: true,
1393
+ });
1394
+
1395
+ await options.onReplyStart?.();
1396
+ result.replyOptions.onReasoningStream?.({ text: "deep thought" });
1397
+ result.replyOptions.onReasoningEnd?.();
1398
+ await options.onIdle?.();
1399
+
1400
+ expect(streamingInstances).toHaveLength(1);
1401
+ expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
1402
+ const closeArg = firstStreamingCloseText();
1403
+ expect(closeArg).toContain("> 💭 **Thinking**");
1404
+ expect(closeArg).toContain("> deep thought");
1405
+ expect(closeArg).not.toContain("Reasoning:");
1406
+ expect(closeArg).not.toContain("---");
1407
+ });
1408
+
1409
+ it("ignores empty reasoning payloads", async () => {
1410
+ const { result, options } = createDispatcherHarness({
1411
+ runtime: createRuntimeLogger(),
1412
+ allowReasoningPreview: true,
1413
+ });
1414
+
1415
+ await options.onReplyStart?.();
1416
+ result.replyOptions.onReasoningStream?.({ text: "" });
1417
+ result.replyOptions.onPartialReply?.({ text: "```ts\ncode\n```" });
1418
+ await options.deliver({ text: "```ts\ncode\n```" }, { kind: "final" });
1419
+ await options.onIdle?.();
1420
+
1421
+ expect(streamingInstances).toHaveLength(1);
1422
+ const closeArg = firstStreamingCloseText();
1423
+ expect(closeArg).not.toContain("Thinking");
1424
+ expect(closeArg).toBe("```ts\ncode\n```");
1425
+ });
1426
+
1427
+ it("deduplicates final text by raw answer payload, not combined card text", async () => {
1428
+ const { result, options } = createDispatcherHarness({
1429
+ runtime: createRuntimeLogger(),
1430
+ allowReasoningPreview: true,
1431
+ });
1432
+
1433
+ await options.onReplyStart?.();
1434
+ result.replyOptions.onReasoningStream?.({ text: "thought" });
1435
+ result.replyOptions.onReasoningEnd?.();
1436
+ await options.deliver({ text: "```ts\nfinal answer\n```" }, { kind: "final" });
1437
+ await options.onIdle?.();
1438
+
1439
+ expect(streamingInstances).toHaveLength(1);
1440
+ expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
1441
+
1442
+ // Deliver the same raw answer text again — should be deduped
1443
+ await options.deliver({ text: "```ts\nfinal answer\n```" }, { kind: "final" });
1444
+
1445
+ // No second streaming session since the raw answer text matches
1446
+ expect(streamingInstances).toHaveLength(1);
1447
+ });
1448
+
1449
+ it("passes replyToMessageId and replyInThread to streaming.start()", async () => {
1450
+ const { options } = createDispatcherHarness({
1451
+ runtime: createRuntimeLogger(),
1452
+ replyToMessageId: "om_msg",
1453
+ replyInThread: true,
1454
+ });
1455
+ await options.deliver({ text: "```ts\nconst x = 1\n```" }, { kind: "final" });
1456
+
1457
+ expect(streamingInstances).toHaveLength(1);
1458
+ expectStreamingStartOptions(0, {
1459
+ replyToMessageId: "om_msg",
1460
+ replyInThread: true,
1461
+ header: { title: "agent", template: "blue" },
1462
+ note: "Agent: agent",
1463
+ });
1464
+ });
1465
+
1466
+ it("uses streaming cards for thread replies and keeps topic metadata", async () => {
1467
+ const { options } = createDispatcherHarness({
1468
+ runtime: createRuntimeLogger(),
1469
+ replyToMessageId: "om_msg",
1470
+ replyInThread: false,
1471
+ threadReply: true,
1472
+ rootId: "om_root_topic",
1473
+ });
1474
+ await options.deliver({ text: "```ts\nconst x = 1\n```" }, { kind: "final" });
1475
+
1476
+ expect(streamingInstances).toHaveLength(1);
1477
+ expectStreamingStartOptions(0, {
1478
+ replyToMessageId: "om_msg",
1479
+ replyInThread: true,
1480
+ rootId: "om_root_topic",
1481
+ });
1482
+ expect(sendStructuredCardFeishuMock).not.toHaveBeenCalled();
1483
+ });
1484
+
1485
+ it("omits the generic main header from streaming and static cards", async () => {
1486
+ resolveFeishuAccountMock.mockReturnValue({
1487
+ accountId: "main",
1488
+ appId: "app_id",
1489
+ appSecret: "app_secret",
1490
+ domain: "feishu",
1491
+ config: {
1492
+ renderMode: "card",
1493
+ streaming: true,
1494
+ },
1495
+ });
1496
+
1497
+ const { options } = createDispatcherHarness({
1498
+ agentId: "main",
1499
+ runtime: createRuntimeLogger(),
1500
+ });
1501
+ await options.deliver({ text: "streamed card" }, { kind: "final" });
1502
+ await options.onIdle?.();
1503
+
1504
+ expectStreamingStartOptions(0, {
1505
+ header: undefined,
1506
+ });
1507
+
1508
+ resolveFeishuAccountMock.mockReturnValue({
1509
+ accountId: "main",
1510
+ appId: "app_id",
1511
+ appSecret: "app_secret",
1512
+ domain: "feishu",
1513
+ config: {
1514
+ renderMode: "card",
1515
+ streaming: false,
1516
+ },
1517
+ });
1518
+
1519
+ const { options: staticOptions } = createDispatcherHarness({
1520
+ agentId: "main",
1521
+ runtime: createRuntimeLogger(),
1522
+ });
1523
+ await staticOptions.deliver({ text: "static card" }, { kind: "final" });
1524
+
1525
+ expectLastMockArgFields(sendStructuredCardFeishuMock, "structured card params", {
1526
+ header: undefined,
1527
+ });
1528
+ });
1529
+
1530
+ it("shows shared transient tool status on streaming cards but omits it from the final close", async () => {
1531
+ resolveFeishuAccountMock.mockReturnValue({
1532
+ accountId: "main",
1533
+ appId: "app_id",
1534
+ appSecret: "app_secret",
1535
+ domain: "feishu",
1536
+ config: {
1537
+ renderMode: "card",
1538
+ streaming: true,
1539
+ },
1540
+ });
1541
+
1542
+ const { result, options } = createDispatcherHarness({
1543
+ runtime: createRuntimeLogger(),
1544
+ });
1545
+ await options.onReplyStart?.();
1546
+ result.replyOptions.onToolStart?.({ name: "web_search" });
1547
+ result.replyOptions.onPartialReply?.({ text: "final answer" });
1548
+ await options.onIdle?.();
1549
+
1550
+ const updateTexts = streamingUpdateTexts();
1551
+ expect(updateTexts.join("\n")).toContain("🔎 Web Search");
1552
+ expect(streamingInstances[0].close).toHaveBeenCalledWith("final answer", {
1553
+ note: "Agent: agent",
1554
+ });
1555
+ });
1556
+
1557
+ it("shows raw command detail in streaming card tool status", async () => {
1558
+ resolveFeishuAccountMock.mockReturnValue({
1559
+ accountId: "main",
1560
+ appId: "app_id",
1561
+ appSecret: "app_secret",
1562
+ domain: "feishu",
1563
+ config: {
1564
+ renderMode: "card",
1565
+ streaming: true,
1566
+ },
1567
+ });
1568
+
1569
+ const { result, options } = createDispatcherHarness({
1570
+ runtime: createRuntimeLogger(),
1571
+ });
1572
+ await options.onReplyStart?.();
1573
+ result.replyOptions.onToolStart?.({
1574
+ name: "exec",
1575
+ args: { command: "pnpm test -- --watch=false" },
1576
+ detailMode: "raw",
1577
+ });
1578
+ result.replyOptions.onPartialReply?.({ text: "final answer" });
1579
+ await options.onIdle?.();
1580
+
1581
+ const updateTexts = streamingUpdateTexts();
1582
+ expect(updateTexts.join("\n")).toContain("🛠️ run tests, `pnpm test -- --watch=false`");
1583
+ });
1584
+
1585
+ it("omits message-like tools from streaming card status", async () => {
1586
+ resolveFeishuAccountMock.mockReturnValue({
1587
+ accountId: "main",
1588
+ appId: "app_id",
1589
+ appSecret: "app_secret",
1590
+ domain: "feishu",
1591
+ config: {
1592
+ renderMode: "card",
1593
+ streaming: true,
1594
+ },
1595
+ });
1596
+
1597
+ const { result, options } = createDispatcherHarness({
1598
+ runtime: createRuntimeLogger(),
1599
+ });
1600
+ await options.onReplyStart?.();
1601
+ result.replyOptions.onToolStart?.({ name: "message" });
1602
+ result.replyOptions.onPartialReply?.({ text: "final answer" });
1603
+ await options.onIdle?.();
1604
+
1605
+ const updateTexts = streamingUpdateTexts();
1606
+ expect(updateTexts.join("\n")).not.toContain("Message");
1607
+ });
1608
+
1609
+ it("does not suppress a later final after error closeout", async () => {
1610
+ resolveFeishuAccountMock.mockReturnValue({
1611
+ accountId: "main",
1612
+ appId: "app_id",
1613
+ appSecret: "app_secret",
1614
+ domain: "feishu",
1615
+ config: {
1616
+ renderMode: "card",
1617
+ streaming: true,
1618
+ },
1619
+ });
1620
+ sendMediaFeishuMock.mockRejectedValueOnce(new Error("media failed"));
1621
+
1622
+ const { options } = createDispatcherHarness({
1623
+ runtime: createRuntimeLogger(),
1624
+ });
1625
+
1626
+ await expect(
1627
+ options.deliver(
1628
+ { text: "First answer", mediaUrl: "https://example.com/a.png" },
1629
+ { kind: "final" },
1630
+ ),
1631
+ ).rejects.toThrow("media failed");
1632
+ await Promise.all([
1633
+ options.onError?.(new Error("media failed"), { kind: "final" }),
1634
+ options.onIdle?.(),
1635
+ ]);
1636
+ await options.deliver({ text: "Second answer" }, { kind: "final" });
1637
+ await options.onIdle?.();
1638
+
1639
+ expect(streamingInstances).toHaveLength(2);
1640
+ expect(streamingInstances[0].close).toHaveBeenCalledWith("First answer", {
1641
+ note: "Agent: agent",
1642
+ });
1643
+ expect(streamingInstances[1].close).toHaveBeenCalledWith("Second answer", {
1644
+ note: "Agent: agent",
1645
+ });
1646
+ expect(sendMessageFeishuMock).not.toHaveBeenCalled();
1647
+ expect(sendStructuredCardFeishuMock).not.toHaveBeenCalled();
1648
+ });
1649
+
1650
+ it("does not suppress a recovery final after late media failure", async () => {
1651
+ resolveFeishuAccountMock.mockReturnValue({
1652
+ accountId: "main",
1653
+ appId: "app_id",
1654
+ appSecret: "app_secret",
1655
+ domain: "feishu",
1656
+ config: {
1657
+ renderMode: "card",
1658
+ streaming: true,
1659
+ },
1660
+ });
1661
+
1662
+ const { options } = createDispatcherHarness({
1663
+ runtime: createRuntimeLogger(),
1664
+ });
1665
+
1666
+ await options.deliver({ text: "First answer" }, { kind: "final" });
1667
+ await options.onIdle?.();
1668
+ sendMediaFeishuMock.mockRejectedValueOnce(new Error("media failed"));
1669
+ await expect(
1670
+ options.deliver(
1671
+ { text: "Late attachment", mediaUrl: "https://example.com/a.png" },
1672
+ { kind: "final" },
1673
+ ),
1674
+ ).rejects.toThrow("media failed");
1675
+ await options.onError?.(new Error("media failed"), { kind: "final" });
1676
+ await options.deliver({ text: "Recovered answer" }, { kind: "final" });
1677
+ await options.onIdle?.();
1678
+
1679
+ expect(streamingInstances).toHaveLength(2);
1680
+ expect(streamingInstances[0].close).toHaveBeenCalledWith("First answer", {
1681
+ note: "Agent: agent",
1682
+ });
1683
+ expect(streamingInstances[1].close).toHaveBeenCalledWith("Recovered answer", {
1684
+ note: "Agent: agent",
1685
+ });
1686
+ expect(sendStructuredCardFeishuMock).not.toHaveBeenCalled();
1687
+ });
1688
+
1689
+ it("sends a no-visible-reply fallback when no visible output was delivered", async () => {
1690
+ const runtime = createRuntimeLogger();
1691
+ const { result } = createDispatcherHarness({ runtime });
1692
+
1693
+ await expect(result.ensureNoVisibleReplyFallback("empty-complete")).resolves.toBe(true);
1694
+
1695
+ expect(sendMessageFeishuMock).toHaveBeenCalledTimes(1);
1696
+ expect(String(firstMockArg(sendMessageFeishuMock, "send message params").text)).toContain(
1697
+ "without visible content",
1698
+ );
1699
+ expect(result.getVisibleReplyState()).toEqual({
1700
+ visibleReplySent: true,
1701
+ skippedFinalReason: null,
1702
+ });
1703
+ });
1704
+
1705
+ it("does not send no-visible-reply fallback after an intentional silent final", async () => {
1706
+ const runtime = createRuntimeLogger();
1707
+ const { result, options } = createDispatcherHarness({ runtime, sessionKey: "main" });
1708
+
1709
+ options.onSkip?.({ text: "NO_REPLY" }, { kind: "final", reason: "silent" });
1710
+ await expect(result.ensureNoVisibleReplyFallback("empty-complete")).resolves.toBe(false);
1711
+
1712
+ expect(sendMessageFeishuMock).not.toHaveBeenCalled();
1713
+ expect(result.getVisibleReplyState()).toEqual({
1714
+ visibleReplySent: false,
1715
+ skippedFinalReason: "silent",
1716
+ });
1717
+ });
1718
+
1719
+ it("sends no-visible-reply fallback when a final fails after an earlier silent skip", async () => {
1720
+ useNonStreamingAutoAccount();
1721
+ const runtime = createRuntimeLogger();
1722
+ const { result, options } = createDispatcherHarness({ runtime, sessionKey: "main" });
1723
+
1724
+ options.onSkip?.({ text: "NO_REPLY" }, { kind: "final", reason: "silent" });
1725
+ sendMessageFeishuMock.mockRejectedValueOnce(new Error("send failed"));
1726
+
1727
+ await expect(
1728
+ options.deliver({ text: "Later visible final" }, { kind: "final" }),
1729
+ ).rejects.toThrow("send failed");
1730
+ await expect(result.ensureNoVisibleReplyFallback("failed-final")).resolves.toBe(true);
1731
+
1732
+ expect(sendMessageFeishuMock).toHaveBeenCalledTimes(2);
1733
+ expect(String(firstMockArg(sendMessageFeishuMock, "send message params").text)).toBe(
1734
+ "Later visible final",
1735
+ );
1736
+ expect(String(sendMessageFeishuMock.mock.calls[1]?.[0]?.text)).toContain(
1737
+ "without visible content",
1738
+ );
1739
+ expect(result.getVisibleReplyState()).toEqual({
1740
+ visibleReplySent: true,
1741
+ skippedFinalReason: null,
1742
+ });
1743
+ });
1744
+
1745
+ it("does not send no-visible-reply fallback after visible streaming close", async () => {
1746
+ const runtime = createRuntimeLogger();
1747
+ const { result, options } = createDispatcherHarness({ runtime });
1748
+
1749
+ await options.deliver({ text: "```md\nvisible answer\n```" }, { kind: "final" });
1750
+ await options.onIdle?.();
1751
+ await expect(result.ensureNoVisibleReplyFallback("zero-final-count")).resolves.toBe(false);
1752
+
1753
+ expect(streamingInstances).toHaveLength(1);
1754
+ expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
1755
+ expect(sendMessageFeishuMock).not.toHaveBeenCalled();
1756
+ expect(result.getVisibleReplyState()).toEqual({
1757
+ visibleReplySent: true,
1758
+ skippedFinalReason: null,
1759
+ });
1760
+ });
1761
+
1762
+ it("sends no-visible-reply fallback when streaming close accepts no content", async () => {
1763
+ const runtime = createRuntimeLogger();
1764
+ const { result, options } = createDispatcherHarness({ runtime });
1765
+
1766
+ await options.deliver({ text: "```md\nvisible answer\n```" }, { kind: "final" });
1767
+ streamingInstances[0].close = vi.fn(async () => {
1768
+ streamingInstances[0].active = false;
1769
+ return false;
1770
+ });
1771
+
1772
+ await options.onIdle?.();
1773
+ await expect(result.ensureNoVisibleReplyFallback("zero-final-count")).resolves.toBe(true);
1774
+
1775
+ expect(streamingInstances[0].close).toHaveBeenCalledWith("```md\nvisible answer\n```", {
1776
+ note: "Agent: agent",
1777
+ });
1778
+ expect(sendMessageFeishuMock).toHaveBeenCalledTimes(1);
1779
+ expect(String(firstMockArg(sendMessageFeishuMock, "send message params").text)).toContain(
1780
+ "without visible content",
1781
+ );
1782
+ expect(result.getVisibleReplyState()).toEqual({
1783
+ visibleReplySent: true,
1784
+ skippedFinalReason: null,
1785
+ });
1786
+ });
1787
+
1788
+ it("waits for pending streaming close before no-visible-reply fallback", async () => {
1789
+ const runtime = createRuntimeLogger();
1790
+ const { result, options } = createDispatcherHarness({ runtime });
1791
+
1792
+ await options.deliver({ text: "```md\nvisible answer\n```" }, { kind: "final" });
1793
+
1794
+ const streamingSession = streamingInstances[0];
1795
+ let releaseClose: () => void = () => {};
1796
+ const closeMock = vi.fn(async () => {
1797
+ await new Promise<void>((resolve) => {
1798
+ releaseClose = resolve;
1799
+ });
1800
+ streamingSession.active = false;
1801
+ return true;
1802
+ });
1803
+ streamingSession.close = closeMock;
1804
+
1805
+ const idlePromise = options.onIdle?.();
1806
+ const fallbackPromise = result.ensureNoVisibleReplyFallback("zero-final-count");
1807
+
1808
+ for (let attempt = 0; attempt < 20 && closeMock.mock.calls.length === 0; attempt += 1) {
1809
+ await new Promise((resolve) => {
1810
+ setTimeout(resolve, 0);
1811
+ });
1812
+ }
1813
+ expect(closeMock).toHaveBeenCalledTimes(1);
1814
+ expect(sendMessageFeishuMock).not.toHaveBeenCalled();
1815
+
1816
+ releaseClose();
1817
+ await idlePromise;
1818
+ await expect(fallbackPromise).resolves.toBe(false);
1819
+
1820
+ expect(closeMock).toHaveBeenCalledWith("```md\nvisible answer\n```", {
1821
+ note: "Agent: agent",
1822
+ });
1823
+ expect(sendMessageFeishuMock).not.toHaveBeenCalled();
1824
+ expect(result.getVisibleReplyState()).toEqual({
1825
+ visibleReplySent: true,
1826
+ skippedFinalReason: null,
1827
+ });
1828
+ });
1829
+
1830
+ it("does not send no-visible-reply fallback after media-only output", async () => {
1831
+ const runtime = createRuntimeLogger();
1832
+ const { result, options } = createDispatcherHarness({ runtime });
1833
+
1834
+ await options.deliver({ mediaUrl: "https://example.com/a.png" }, { kind: "block" });
1835
+ await expect(result.ensureNoVisibleReplyFallback("zero-final-count")).resolves.toBe(false);
1836
+
1837
+ expect(sendMediaFeishuMock).toHaveBeenCalledTimes(1);
1838
+ expect(sendMessageFeishuMock).not.toHaveBeenCalled();
1839
+ expect(result.getVisibleReplyState()).toEqual({
1840
+ visibleReplySent: true,
1841
+ skippedFinalReason: null,
1842
+ });
1843
+ });
1844
+
1845
+ it("sends no-visible-reply fallback after an empty card streaming close", async () => {
1846
+ resolveFeishuAccountMock.mockReturnValue({
1847
+ accountId: "main",
1848
+ appId: "app_id",
1849
+ appSecret: "app_secret",
1850
+ domain: "feishu",
1851
+ config: {
1852
+ renderMode: "card",
1853
+ streaming: true,
1854
+ },
1855
+ });
1856
+ const runtime = createRuntimeLogger();
1857
+ const { result, options } = createDispatcherHarness({ runtime });
1858
+
1859
+ await options.onReplyStart?.();
1860
+ await options.onIdle?.();
1861
+ await expect(result.ensureNoVisibleReplyFallback("zero-final-count")).resolves.toBe(true);
1862
+
1863
+ expect(streamingInstances).toHaveLength(1);
1864
+ expect(streamingInstances[0].close).toHaveBeenCalledWith("", { note: "Agent: agent" });
1865
+ expect(sendMessageFeishuMock).toHaveBeenCalledTimes(1);
1866
+ expect(result.getVisibleReplyState()).toEqual({
1867
+ visibleReplySent: true,
1868
+ skippedFinalReason: null,
1869
+ });
1870
+ });
1871
+
1872
+ it("resets no-visible-reply state on the first reply start", async () => {
1873
+ const runtime = createRuntimeLogger();
1874
+ const { result, options } = createDispatcherHarness({ runtime });
1875
+
1876
+ options.onSkip?.({ text: "NO_REPLY" }, { kind: "final", reason: "silent" });
1877
+ expect(result.getVisibleReplyState()).toEqual({
1878
+ visibleReplySent: false,
1879
+ skippedFinalReason: "silent",
1880
+ });
1881
+
1882
+ await options.onReplyStart?.();
1883
+
1884
+ expect(result.getVisibleReplyState()).toEqual({
1885
+ visibleReplySent: false,
1886
+ skippedFinalReason: null,
1887
+ });
1888
+ });
1889
+
1890
+ it("keeps visible reply state across repeated reply-start keepalives", async () => {
1891
+ const runtime = createRuntimeLogger();
1892
+ const { result, options } = createDispatcherHarness({ runtime });
1893
+
1894
+ await options.onReplyStart?.();
1895
+ await options.deliver({ mediaUrl: "https://example.com/a.png" }, { kind: "block" });
1896
+ await options.onReplyStart?.();
1897
+
1898
+ await expect(result.ensureNoVisibleReplyFallback("zero-final-count")).resolves.toBe(false);
1899
+ expect(sendMessageFeishuMock).not.toHaveBeenCalled();
1900
+ expect(result.getVisibleReplyState()).toEqual({
1901
+ visibleReplySent: true,
1902
+ skippedFinalReason: null,
1903
+ });
1904
+ });
1905
+
1906
+ it("cleans streaming state even when close throws", async () => {
1907
+ const origPush = streamingInstances.push.bind(streamingInstances);
1908
+ streamingInstances.push = (...args: StreamingSessionStub[]) => {
1909
+ if (args.length > 0 && streamingInstances.length === 0) {
1910
+ args[0].close = vi.fn(async () => {
1911
+ args[0].active = false;
1912
+ throw new Error("close failed");
1913
+ });
1914
+ }
1915
+ return origPush(...args);
1916
+ };
1917
+
1918
+ try {
1919
+ const { options } = createDispatcherHarness({
1920
+ runtime: createRuntimeLogger(),
1921
+ });
1922
+ await options.deliver({ text: "```md\nfirst\n```" }, { kind: "final" });
1923
+ await expect(options.onIdle?.()).rejects.toThrow("close failed");
1924
+ await options.deliver({ text: "```md\nsecond\n```" }, { kind: "final" });
1925
+ await options.onIdle?.();
1926
+
1927
+ expect(streamingInstances).toHaveLength(2);
1928
+ expect(streamingInstances[1].close).toHaveBeenCalledWith("```md\nsecond\n```", {
1929
+ note: "Agent: agent",
1930
+ });
1931
+ } finally {
1932
+ streamingInstances.push = origPush;
1933
+ }
1934
+ });
1935
+
1936
+ it("passes replyInThread to media attachments", async () => {
1937
+ const { options } = createDispatcherHarness({
1938
+ replyToMessageId: "om_msg",
1939
+ replyInThread: true,
1940
+ });
1941
+ await options.deliver({ mediaUrl: "https://example.com/a.png" }, { kind: "final" });
1942
+
1943
+ expectMockArgFields(sendMediaFeishuMock, "media send params", {
1944
+ replyToMessageId: "om_msg",
1945
+ replyInThread: true,
1946
+ });
1947
+ });
1948
+
1949
+ it("backs off streaming retries after start() throws (HTTP 400)", async () => {
1950
+ const errorMock = vi.fn();
1951
+ let shouldFailStart = true;
1952
+ const nowSpy = vi.spyOn(Date, "now").mockReturnValue(1_000);
1953
+
1954
+ // Intercept streaming instance creation to make first start() reject
1955
+ const origPush = streamingInstances.push.bind(streamingInstances);
1956
+ streamingInstances.push = (...args: StreamingSessionStub[]) => {
1957
+ if (shouldFailStart) {
1958
+ args[0].start = vi
1959
+ .fn()
1960
+ .mockRejectedValue(new Error("Create card request failed with HTTP 400"));
1961
+ shouldFailStart = false;
1962
+ }
1963
+ return origPush(...args);
1964
+ };
1965
+
1966
+ try {
1967
+ createFeishuReplyDispatcher({
1968
+ cfg: {} as never,
1969
+ agentId: "agent",
1970
+ runtime: { log: vi.fn(), error: errorMock } as never,
1971
+ chatId: "oc_chat",
1972
+ });
1973
+
1974
+ const options = firstTypingDispatcherOptions();
1975
+
1976
+ // First deliver with markdown triggers startStreaming - which will fail
1977
+ await options.deliver({ text: "```ts\nconst x = 1\n```" }, { kind: "final" });
1978
+
1979
+ // Wait for the async error to propagate
1980
+ await vi.waitFor(() => {
1981
+ expect(errorMock.mock.calls.map(([message]) => String(message)).join("\n")).toContain(
1982
+ "streaming start failed",
1983
+ );
1984
+ });
1985
+ expect(streamingInstances).toHaveLength(1);
1986
+ expect(sendStructuredCardFeishuMock).toHaveBeenCalledTimes(1);
1987
+
1988
+ // Immediate next markdown reply should skip a new streaming start and
1989
+ // fall back directly to a normal card instead of paying the 400 latency.
1990
+ await options.deliver({ text: "```ts\nconst y = 2\n```" }, { kind: "final" });
1991
+
1992
+ expect(streamingInstances).toHaveLength(1);
1993
+ expect(sendStructuredCardFeishuMock).toHaveBeenCalledTimes(2);
1994
+
1995
+ // After the short backoff expires, retry streaming so fixed permissions
1996
+ // or transient Feishu failures recover without a process restart.
1997
+ nowSpy.mockReturnValue(62_000);
1998
+ await options.deliver({ text: "```ts\nconst z = 3\n```" }, { kind: "final" });
1999
+ await options.onIdle?.();
2000
+
2001
+ expect(streamingInstances).toHaveLength(2);
2002
+ expect(streamingInstances[1].start).toHaveBeenCalled();
2003
+ expect(streamingInstances[1].close).toHaveBeenCalled();
2004
+ } finally {
2005
+ streamingInstances.push = origPush;
2006
+ nowSpy.mockRestore();
2007
+ }
2008
+ });
2009
+ });