@kodelyth/feishu 2026.5.42 → 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 (192) hide show
  1. package/klaw.plugin.json +1712 -47
  2. package/package.json +19 -6
  3. package/api.ts +0 -32
  4. package/channel-entry.ts +0 -20
  5. package/channel-plugin-api.ts +0 -1
  6. package/contract-api.ts +0 -16
  7. package/index.ts +0 -82
  8. package/runtime-api.ts +0 -52
  9. package/secret-contract-api.ts +0 -5
  10. package/security-contract-api.ts +0 -1
  11. package/session-key-api.ts +0 -1
  12. package/setup-api.ts +0 -3
  13. package/setup-entry.test.ts +0 -19
  14. package/setup-entry.ts +0 -13
  15. package/src/accounts.test.ts +0 -480
  16. package/src/accounts.ts +0 -333
  17. package/src/agent-config.ts +0 -21
  18. package/src/app-registration.ts +0 -331
  19. package/src/approval-auth.test.ts +0 -24
  20. package/src/approval-auth.ts +0 -25
  21. package/src/async.test.ts +0 -35
  22. package/src/async.ts +0 -104
  23. package/src/audio-preflight.runtime.ts +0 -9
  24. package/src/bitable.test.ts +0 -136
  25. package/src/bitable.ts +0 -762
  26. package/src/bot-content.ts +0 -485
  27. package/src/bot-group-name.test.ts +0 -116
  28. package/src/bot-runtime-api.ts +0 -12
  29. package/src/bot-sender-name.ts +0 -125
  30. package/src/bot.broadcast.test.ts +0 -523
  31. package/src/bot.card-action.test.ts +0 -552
  32. package/src/bot.checkBotMentioned.test.ts +0 -265
  33. package/src/bot.helpers.test.ts +0 -135
  34. package/src/bot.stripBotMention.test.ts +0 -126
  35. package/src/bot.test.ts +0 -3671
  36. package/src/bot.ts +0 -1703
  37. package/src/card-action.ts +0 -447
  38. package/src/card-interaction.test.ts +0 -131
  39. package/src/card-interaction.ts +0 -159
  40. package/src/card-test-helpers.ts +0 -54
  41. package/src/card-ux-approval.ts +0 -65
  42. package/src/card-ux-launcher.test.ts +0 -106
  43. package/src/card-ux-launcher.ts +0 -121
  44. package/src/card-ux-shared.ts +0 -33
  45. package/src/channel-runtime-api.ts +0 -16
  46. package/src/channel.runtime.ts +0 -47
  47. package/src/channel.test.ts +0 -1151
  48. package/src/channel.ts +0 -1423
  49. package/src/chat-schema.ts +0 -25
  50. package/src/chat.test.ts +0 -240
  51. package/src/chat.ts +0 -188
  52. package/src/client-timeout.ts +0 -42
  53. package/src/client.test.ts +0 -447
  54. package/src/client.ts +0 -262
  55. package/src/comment-dispatcher-runtime-api.ts +0 -6
  56. package/src/comment-dispatcher.test.ts +0 -185
  57. package/src/comment-dispatcher.ts +0 -107
  58. package/src/comment-handler-runtime-api.ts +0 -3
  59. package/src/comment-handler.test.ts +0 -592
  60. package/src/comment-handler.ts +0 -303
  61. package/src/comment-reaction.test.ts +0 -138
  62. package/src/comment-reaction.ts +0 -259
  63. package/src/comment-shared.test.ts +0 -183
  64. package/src/comment-shared.ts +0 -406
  65. package/src/comment-target.ts +0 -44
  66. package/src/config-schema.test.ts +0 -326
  67. package/src/config-schema.ts +0 -335
  68. package/src/conversation-id.test.ts +0 -18
  69. package/src/conversation-id.ts +0 -199
  70. package/src/dedup-runtime-api.ts +0 -1
  71. package/src/dedup.ts +0 -141
  72. package/src/dedupe-key.ts +0 -72
  73. package/src/directory.static.ts +0 -61
  74. package/src/directory.test.ts +0 -141
  75. package/src/directory.ts +0 -124
  76. package/src/doc-schema.ts +0 -182
  77. package/src/docx-batch-insert.test.ts +0 -116
  78. package/src/docx-batch-insert.ts +0 -223
  79. package/src/docx-color-text.ts +0 -154
  80. package/src/docx-table-ops.test.ts +0 -53
  81. package/src/docx-table-ops.ts +0 -316
  82. package/src/docx-types.ts +0 -38
  83. package/src/docx.account-selection.test.ts +0 -95
  84. package/src/docx.test.ts +0 -701
  85. package/src/docx.ts +0 -1596
  86. package/src/drive-schema.ts +0 -92
  87. package/src/drive.test.ts +0 -1237
  88. package/src/drive.ts +0 -829
  89. package/src/dynamic-agent.test.ts +0 -155
  90. package/src/dynamic-agent.ts +0 -143
  91. package/src/event-types.ts +0 -45
  92. package/src/external-keys.test.ts +0 -20
  93. package/src/external-keys.ts +0 -19
  94. package/src/lifecycle.test-support.ts +0 -220
  95. package/src/media.test.ts +0 -955
  96. package/src/media.ts +0 -1105
  97. package/src/mention-target.types.ts +0 -5
  98. package/src/mention.ts +0 -114
  99. package/src/message-action-contract.ts +0 -13
  100. package/src/monitor-state-runtime-api.ts +0 -7
  101. package/src/monitor-transport-runtime-api.ts +0 -10
  102. package/src/monitor.account.ts +0 -492
  103. package/src/monitor.acp-init-failure.lifecycle.test-support.ts +0 -219
  104. package/src/monitor.bot-identity.ts +0 -86
  105. package/src/monitor.bot-menu-handler.ts +0 -165
  106. package/src/monitor.bot-menu.lifecycle.test-support.ts +0 -224
  107. package/src/monitor.bot-menu.test.ts +0 -188
  108. package/src/monitor.broadcast.reply-once.lifecycle.test-support.ts +0 -264
  109. package/src/monitor.card-action.lifecycle.test-support.ts +0 -421
  110. package/src/monitor.cleanup.test.ts +0 -383
  111. package/src/monitor.comment-notice-handler.ts +0 -105
  112. package/src/monitor.comment.test.ts +0 -967
  113. package/src/monitor.comment.ts +0 -1386
  114. package/src/monitor.lifecycle.test.ts +0 -4
  115. package/src/monitor.message-handler.ts +0 -350
  116. package/src/monitor.reaction.lifecycle.test-support.ts +0 -68
  117. package/src/monitor.reaction.test.ts +0 -739
  118. package/src/monitor.startup.test.ts +0 -213
  119. package/src/monitor.startup.ts +0 -74
  120. package/src/monitor.state.defaults.test.ts +0 -46
  121. package/src/monitor.state.ts +0 -170
  122. package/src/monitor.synthetic-error.ts +0 -18
  123. package/src/monitor.test-mocks.ts +0 -46
  124. package/src/monitor.transport.ts +0 -451
  125. package/src/monitor.ts +0 -100
  126. package/src/monitor.webhook-e2e.test.ts +0 -279
  127. package/src/monitor.webhook-security.test.ts +0 -389
  128. package/src/monitor.webhook.test-helpers.ts +0 -116
  129. package/src/outbound-runtime-api.ts +0 -1
  130. package/src/outbound.test.ts +0 -1118
  131. package/src/outbound.ts +0 -785
  132. package/src/perm-schema.ts +0 -52
  133. package/src/perm.ts +0 -170
  134. package/src/pins.ts +0 -108
  135. package/src/policy.test.ts +0 -223
  136. package/src/policy.ts +0 -318
  137. package/src/post.test.ts +0 -105
  138. package/src/post.ts +0 -275
  139. package/src/probe.test.ts +0 -283
  140. package/src/probe.ts +0 -166
  141. package/src/processing-claims.ts +0 -59
  142. package/src/qr-terminal.ts +0 -1
  143. package/src/reactions.ts +0 -123
  144. package/src/reasoning-preview.test.ts +0 -113
  145. package/src/reasoning-preview.ts +0 -28
  146. package/src/reply-dispatcher-runtime-api.ts +0 -7
  147. package/src/reply-dispatcher.test.ts +0 -1513
  148. package/src/reply-dispatcher.ts +0 -748
  149. package/src/runtime.ts +0 -9
  150. package/src/secret-contract.ts +0 -145
  151. package/src/secret-input.ts +0 -1
  152. package/src/security-audit-shared.ts +0 -69
  153. package/src/security-audit.test.ts +0 -59
  154. package/src/security-audit.ts +0 -1
  155. package/src/send-result.ts +0 -80
  156. package/src/send-target.test.ts +0 -86
  157. package/src/send-target.ts +0 -35
  158. package/src/send.reply-fallback.test.ts +0 -417
  159. package/src/send.test.ts +0 -621
  160. package/src/send.ts +0 -861
  161. package/src/sequential-key.test.ts +0 -72
  162. package/src/sequential-key.ts +0 -25
  163. package/src/sequential-queue.test.ts +0 -165
  164. package/src/sequential-queue.ts +0 -86
  165. package/src/session-conversation.ts +0 -42
  166. package/src/session-route.ts +0 -48
  167. package/src/setup-core.ts +0 -51
  168. package/src/setup-surface.test.ts +0 -484
  169. package/src/setup-surface.ts +0 -618
  170. package/src/streaming-card.test.ts +0 -397
  171. package/src/streaming-card.ts +0 -571
  172. package/src/subagent-hooks.test.ts +0 -627
  173. package/src/subagent-hooks.ts +0 -413
  174. package/src/targets.ts +0 -97
  175. package/src/test-support/lifecycle-test-support.ts +0 -454
  176. package/src/thread-bindings.test.ts +0 -180
  177. package/src/thread-bindings.ts +0 -331
  178. package/src/tool-account-routing.test.ts +0 -250
  179. package/src/tool-account.test.ts +0 -44
  180. package/src/tool-account.ts +0 -93
  181. package/src/tool-factory-test-harness.ts +0 -79
  182. package/src/tool-result.test.ts +0 -32
  183. package/src/tool-result.ts +0 -16
  184. package/src/tools-config.test.ts +0 -21
  185. package/src/tools-config.ts +0 -22
  186. package/src/types.ts +0 -106
  187. package/src/typing.test.ts +0 -144
  188. package/src/typing.ts +0 -214
  189. package/src/wiki-schema.ts +0 -69
  190. package/src/wiki.ts +0 -270
  191. package/subagent-hooks-api.ts +0 -31
  192. package/tsconfig.json +0 -16
@@ -1,1513 +0,0 @@
1
- import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
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
-
11
- const resolveFeishuAccountMock = vi.hoisted(() => vi.fn());
12
- const getFeishuRuntimeMock = vi.hoisted(() => vi.fn());
13
- const sendMessageFeishuMock = vi.hoisted(() => vi.fn());
14
- const sendMarkdownCardFeishuMock = vi.hoisted(() => vi.fn());
15
- const sendStructuredCardFeishuMock = vi.hoisted(() => vi.fn());
16
- const sendMediaFeishuMock = vi.hoisted(() => vi.fn());
17
- const createFeishuClientMock = vi.hoisted(() => vi.fn());
18
- const resolveReceiveIdTypeMock = vi.hoisted(() => vi.fn());
19
- const createReplyDispatcherWithTypingMock = vi.hoisted(() => vi.fn());
20
- const addTypingIndicatorMock = vi.hoisted(() => vi.fn(async () => ({ messageId: "om_msg" })));
21
- const removeTypingIndicatorMock = vi.hoisted(() => vi.fn(async () => {}));
22
- const streamingInstances = vi.hoisted((): StreamingSessionStub[] => []);
23
- const shouldSuppressFeishuTextForVoiceMediaMock = vi.hoisted(
24
- () => (params: { mediaUrl?: string; audioAsVoice?: boolean }) =>
25
- params.audioAsVoice === true || /\.(?:ogg|opus)(?:[?#]|$)/i.test(params.mediaUrl ?? ""),
26
- );
27
-
28
- function mergeStreamingText(
29
- previousText: string | undefined,
30
- nextText: string | undefined,
31
- ): string {
32
- const previous = typeof previousText === "string" ? previousText : "";
33
- const next = typeof nextText === "string" ? nextText : "";
34
- if (!next) {
35
- return previous;
36
- }
37
- if (!previous || next === previous) {
38
- return next;
39
- }
40
- if (next.startsWith(previous) || next.includes(previous)) {
41
- return next;
42
- }
43
- if (previous.startsWith(next) || previous.includes(next)) {
44
- return previous;
45
- }
46
- const maxOverlap = Math.min(previous.length, next.length);
47
- for (let overlap = maxOverlap; overlap > 0; overlap -= 1) {
48
- if (previous.slice(-overlap) === next.slice(0, overlap)) {
49
- return `${previous}${next.slice(overlap)}`;
50
- }
51
- }
52
- return `${previous}${next}`;
53
- }
54
-
55
- vi.mock("./accounts.js", () => ({
56
- resolveFeishuAccount: resolveFeishuAccountMock,
57
- resolveFeishuRuntimeAccount: resolveFeishuAccountMock,
58
- }));
59
- vi.mock("./runtime.js", () => ({ getFeishuRuntime: getFeishuRuntimeMock }));
60
- vi.mock("./send.js", () => ({
61
- sendMessageFeishu: sendMessageFeishuMock,
62
- sendMarkdownCardFeishu: sendMarkdownCardFeishuMock,
63
- sendStructuredCardFeishu: sendStructuredCardFeishuMock,
64
- }));
65
- vi.mock("./media.js", () => ({
66
- sendMediaFeishu: sendMediaFeishuMock,
67
- shouldSuppressFeishuTextForVoiceMedia: shouldSuppressFeishuTextForVoiceMediaMock,
68
- }));
69
- vi.mock("./client.js", () => ({ createFeishuClient: createFeishuClientMock }));
70
- vi.mock("./targets.js", () => ({ resolveReceiveIdType: resolveReceiveIdTypeMock }));
71
- vi.mock("./typing.js", () => ({
72
- addTypingIndicator: addTypingIndicatorMock,
73
- removeTypingIndicator: removeTypingIndicatorMock,
74
- }));
75
- vi.mock("./streaming-card.js", () => {
76
- return {
77
- mergeStreamingText,
78
- FeishuStreamingSession: class {
79
- active = false;
80
- start = vi.fn(async () => {
81
- this.active = true;
82
- });
83
- update = vi.fn(async () => {});
84
- close = vi.fn(async () => {
85
- this.active = false;
86
- });
87
- isActive = vi.fn(() => this.active);
88
-
89
- constructor() {
90
- streamingInstances.push(this);
91
- }
92
- },
93
- };
94
- });
95
-
96
- import {
97
- clearFeishuStreamingStartBackoffForTests,
98
- createFeishuReplyDispatcher,
99
- } from "./reply-dispatcher.js";
100
-
101
- afterAll(() => {
102
- vi.doUnmock("./accounts.js");
103
- vi.doUnmock("./runtime.js");
104
- vi.doUnmock("./send.js");
105
- vi.doUnmock("./media.js");
106
- vi.doUnmock("./client.js");
107
- vi.doUnmock("./targets.js");
108
- vi.doUnmock("./typing.js");
109
- vi.doUnmock("./streaming-card.js");
110
- vi.resetModules();
111
- });
112
-
113
- describe("createFeishuReplyDispatcher streaming behavior", () => {
114
- type ReplyDispatcherArgs = Parameters<typeof createFeishuReplyDispatcher>[0];
115
- type TypingDispatcherOptions = {
116
- onReplyStart?: () => Promise<void> | void;
117
- onIdle?: () => Promise<void> | void;
118
- deliver: (payload: { text: string }, meta: { kind: string }) => Promise<void> | void;
119
- };
120
-
121
- beforeEach(() => {
122
- vi.clearAllMocks();
123
- clearFeishuStreamingStartBackoffForTests();
124
- streamingInstances.length = 0;
125
- sendMediaFeishuMock.mockResolvedValue(undefined);
126
- sendStructuredCardFeishuMock.mockResolvedValue(undefined);
127
-
128
- resolveFeishuAccountMock.mockReturnValue({
129
- accountId: "main",
130
- appId: "app_id",
131
- appSecret: "app_secret",
132
- domain: "feishu",
133
- config: {
134
- renderMode: "auto",
135
- streaming: true,
136
- },
137
- });
138
-
139
- resolveReceiveIdTypeMock.mockReturnValue("chat_id");
140
- createFeishuClientMock.mockReturnValue({});
141
-
142
- createReplyDispatcherWithTypingMock.mockImplementation((opts) => ({
143
- dispatcher: {},
144
- replyOptions: {},
145
- markDispatchIdle: vi.fn(),
146
- _opts: opts,
147
- }));
148
-
149
- getFeishuRuntimeMock.mockReturnValue({
150
- channel: {
151
- text: {
152
- resolveTextChunkLimit: vi.fn(() => 4000),
153
- resolveChunkMode: vi.fn(() => "line"),
154
- resolveMarkdownTableMode: vi.fn(() => "preserve"),
155
- convertMarkdownTables: vi.fn((text) => text),
156
- chunkTextWithMode: vi.fn((text) => [text]),
157
- },
158
- reply: {
159
- createReplyDispatcherWithTyping: createReplyDispatcherWithTypingMock,
160
- resolveHumanDelayConfig: vi.fn(() => undefined),
161
- },
162
- },
163
- });
164
- });
165
-
166
- function setupNonStreamingAutoDispatcher() {
167
- resolveFeishuAccountMock.mockReturnValue({
168
- accountId: "main",
169
- appId: "app_id",
170
- appSecret: "app_secret",
171
- domain: "feishu",
172
- config: {
173
- renderMode: "auto",
174
- streaming: false,
175
- },
176
- });
177
-
178
- createFeishuReplyDispatcher({
179
- cfg: {} as never,
180
- agentId: "agent",
181
- runtime: { log: vi.fn(), error: vi.fn() } as never,
182
- chatId: "oc_chat",
183
- });
184
-
185
- return firstMockArg(createReplyDispatcherWithTypingMock, "reply dispatcher options");
186
- }
187
-
188
- function createRuntimeLogger() {
189
- return { log: vi.fn(), error: vi.fn() } as never;
190
- }
191
-
192
- function createDispatcherHarness(overrides: Partial<ReplyDispatcherArgs> = {}) {
193
- const result = createFeishuReplyDispatcher({
194
- cfg: {} as never,
195
- agentId: "agent",
196
- runtime: {} as never,
197
- chatId: "oc_chat",
198
- ...overrides,
199
- });
200
-
201
- return {
202
- result,
203
- options: createReplyDispatcherWithTypingMock.mock.calls.at(-1)?.[0],
204
- };
205
- }
206
-
207
- function isRecord(value: unknown): value is Record<string, unknown> {
208
- return typeof value === "object" && value !== null && !Array.isArray(value);
209
- }
210
-
211
- function requireRecord(value: unknown, label: string): Record<string, unknown> {
212
- expect(isRecord(value), `${label} must be an object`).toBe(true);
213
- return value as Record<string, unknown>;
214
- }
215
-
216
- function expectRecordFields(
217
- value: unknown,
218
- label: string,
219
- expected: Record<string, unknown>,
220
- ): Record<string, unknown> {
221
- const record = requireRecord(value, label);
222
- for (const [key, expectedValue] of Object.entries(expected)) {
223
- expect(record[key], `${label}.${key}`).toEqual(expectedValue);
224
- }
225
- return record;
226
- }
227
-
228
- function expectMockArgFields(
229
- mock: ReturnType<typeof vi.fn>,
230
- label: string,
231
- expected: Record<string, unknown>,
232
- callIndex = 0,
233
- argIndex = 0,
234
- ): Record<string, unknown> {
235
- return expectRecordFields(mockArg(mock, callIndex, argIndex, label), label, expected);
236
- }
237
-
238
- function mockArg(
239
- mock: ReturnType<typeof vi.fn>,
240
- callIndex: number,
241
- argIndex: number,
242
- label: string,
243
- ) {
244
- const call = mock.mock.calls[callIndex];
245
- if (!call) {
246
- throw new Error(`missing ${label} call ${callIndex + 1}`);
247
- }
248
- return call[argIndex];
249
- }
250
-
251
- function firstMockArg(mock: ReturnType<typeof vi.fn>, label: string, argIndex = 0) {
252
- return mockArg(mock, 0, argIndex, label);
253
- }
254
-
255
- function firstTypingDispatcherOptions(): TypingDispatcherOptions {
256
- return firstMockArg(
257
- createReplyDispatcherWithTypingMock,
258
- "reply dispatcher options",
259
- ) as TypingDispatcherOptions;
260
- }
261
-
262
- function firstStreamingCloseText(instanceIndex = 0): string {
263
- const close = streamingInstances[instanceIndex]?.close;
264
- if (!close) {
265
- throw new Error(`Expected streaming instance ${instanceIndex}`);
266
- }
267
- return String(firstMockArg(close, "streaming close"));
268
- }
269
-
270
- function expectLastMockArgFields(
271
- mock: ReturnType<typeof vi.fn>,
272
- label: string,
273
- expected: Record<string, unknown>,
274
- argIndex = 0,
275
- ): Record<string, unknown> {
276
- const callIndex = mock.mock.calls.length - 1;
277
- return expectMockArgFields(mock, label, expected, callIndex, argIndex);
278
- }
279
-
280
- function expectStreamingStartOptions(
281
- instanceIndex: number,
282
- expected: Record<string, unknown>,
283
- ): Record<string, unknown> {
284
- const start = streamingInstances[instanceIndex]?.start;
285
- if (!start) {
286
- throw new Error(`Expected streaming instance ${instanceIndex}`);
287
- }
288
- expect(firstMockArg(start, "streaming start")).toBe("oc_chat");
289
- expect(firstMockArg(start, "streaming start", 1)).toBe("chat_id");
290
- return expectRecordFields(
291
- firstMockArg(start, "streaming start", 2),
292
- "streaming start options",
293
- expected,
294
- );
295
- }
296
-
297
- function streamingUpdateTexts(instanceIndex = 0): string[] {
298
- return streamingInstances[instanceIndex].update.mock.calls.map((call: unknown[]) =>
299
- typeof call[0] === "string" ? call[0] : "",
300
- );
301
- }
302
-
303
- it("skips typing indicator when account typingIndicator is disabled", async () => {
304
- resolveFeishuAccountMock.mockReturnValue({
305
- accountId: "main",
306
- appId: "app_id",
307
- appSecret: "app_secret",
308
- domain: "feishu",
309
- config: {
310
- renderMode: "auto",
311
- streaming: true,
312
- typingIndicator: false,
313
- },
314
- });
315
-
316
- createFeishuReplyDispatcher({
317
- cfg: {} as never,
318
- agentId: "agent",
319
- runtime: {} as never,
320
- chatId: "oc_chat",
321
- replyToMessageId: "om_parent",
322
- });
323
-
324
- const options = firstTypingDispatcherOptions();
325
- await options.onReplyStart?.();
326
-
327
- expect(addTypingIndicatorMock).not.toHaveBeenCalled();
328
- });
329
-
330
- it("skips typing indicator for stale replayed messages", async () => {
331
- createFeishuReplyDispatcher({
332
- cfg: {} as never,
333
- agentId: "agent",
334
- runtime: {} as never,
335
- chatId: "oc_chat",
336
- replyToMessageId: "om_parent",
337
- messageCreateTimeMs: Date.now() - 3 * 60_000,
338
- });
339
-
340
- const options = firstTypingDispatcherOptions();
341
- await options.onReplyStart?.();
342
-
343
- expect(addTypingIndicatorMock).not.toHaveBeenCalled();
344
- });
345
-
346
- it("treats second-based timestamps as stale for typing suppression", async () => {
347
- createFeishuReplyDispatcher({
348
- cfg: {} as never,
349
- agentId: "agent",
350
- runtime: {} as never,
351
- chatId: "oc_chat",
352
- replyToMessageId: "om_parent",
353
- messageCreateTimeMs: Math.floor((Date.now() - 3 * 60_000) / 1000),
354
- });
355
-
356
- const options = firstTypingDispatcherOptions();
357
- await options.onReplyStart?.();
358
-
359
- expect(addTypingIndicatorMock).not.toHaveBeenCalled();
360
- });
361
-
362
- it("keeps typing indicator for fresh messages", async () => {
363
- createFeishuReplyDispatcher({
364
- cfg: {} as never,
365
- agentId: "agent",
366
- runtime: {} as never,
367
- chatId: "oc_chat",
368
- replyToMessageId: "om_parent",
369
- messageCreateTimeMs: Date.now() - 30_000,
370
- });
371
-
372
- const options = firstTypingDispatcherOptions();
373
- await options.onReplyStart?.();
374
-
375
- expect(addTypingIndicatorMock).toHaveBeenCalledTimes(1);
376
- expectMockArgFields(addTypingIndicatorMock, "typing indicator params", {
377
- messageId: "om_parent",
378
- });
379
- });
380
-
381
- it("keeps auto mode plain text on non-streaming send path", async () => {
382
- const { options } = createDispatcherHarness();
383
- await options.deliver({ text: "plain text" }, { kind: "final" });
384
-
385
- expect(streamingInstances).toHaveLength(0);
386
- expect(sendMessageFeishuMock).toHaveBeenCalledTimes(1);
387
- expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
388
- });
389
-
390
- it("does not attach automatic mentions to plain text replies", async () => {
391
- const { options } = createDispatcherHarness({
392
- replyToMessageId: "om_msg",
393
- });
394
- await options.deliver({ text: "plain text" }, { kind: "final" });
395
-
396
- expect(sendMessageFeishuMock).toHaveBeenCalledTimes(1);
397
- expect(firstMockArg(sendMessageFeishuMock, "send message params")).not.toHaveProperty(
398
- "mentions",
399
- );
400
- });
401
-
402
- it("does not attach automatic mentions to card replies", 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: false,
411
- },
412
- });
413
-
414
- const { options } = createDispatcherHarness({
415
- replyToMessageId: "om_msg",
416
- });
417
- await options.deliver({ text: "card text" }, { kind: "final" });
418
-
419
- expect(sendStructuredCardFeishuMock).toHaveBeenCalledTimes(1);
420
- expect(firstMockArg(sendStructuredCardFeishuMock, "structured card params")).not.toHaveProperty(
421
- "mentions",
422
- );
423
- });
424
-
425
- it("suppresses internal block payload delivery", async () => {
426
- const { options } = createDispatcherHarness();
427
- await options.deliver({ text: "internal reasoning chunk" }, { kind: "block" });
428
-
429
- expect(streamingInstances).toHaveLength(0);
430
- expect(sendMessageFeishuMock).not.toHaveBeenCalled();
431
- expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
432
- expect(sendMediaFeishuMock).not.toHaveBeenCalled();
433
- });
434
-
435
- it("disables block streaming by default to prevent silent reply drops", () => {
436
- const result = createFeishuReplyDispatcher({
437
- cfg: {} as never,
438
- agentId: "agent",
439
- runtime: {} as never,
440
- chatId: "oc_chat",
441
- });
442
-
443
- expect(result.replyOptions).toHaveProperty("disableBlockStreaming", true);
444
- });
445
-
446
- it("enables core block streaming when Feishu blockStreaming is explicitly true", async () => {
447
- resolveFeishuAccountMock.mockReturnValue({
448
- accountId: "main",
449
- appId: "app_id",
450
- appSecret: "app_secret",
451
- domain: "feishu",
452
- config: {
453
- renderMode: "auto",
454
- streaming: true,
455
- blockStreaming: true,
456
- },
457
- });
458
-
459
- const { result, options } = createDispatcherHarness();
460
- expect(result.replyOptions).toHaveProperty("disableBlockStreaming", false);
461
-
462
- await options.deliver({ text: "plain block" }, { kind: "block" });
463
- await options.onIdle?.();
464
-
465
- expect(streamingInstances).toHaveLength(1);
466
- expect(streamingInstances[0].close).toHaveBeenCalledWith("plain block", {
467
- note: "Agent: agent",
468
- });
469
- });
470
-
471
- it("does not prepend automatic mentions to streaming card closes", async () => {
472
- const overrides = {
473
- runtime: createRuntimeLogger(),
474
- mentionTargets: [{ openId: "ou-target", name: "Target User", key: "@_user_1" }],
475
- } as Partial<ReplyDispatcherArgs>;
476
- const { options } = createDispatcherHarness(overrides);
477
- await options.deliver({ text: "```md\nanswer\n```" }, { kind: "final" });
478
- await options.onIdle?.();
479
-
480
- expect(streamingInstances).toHaveLength(1);
481
- expect(streamingInstances[0].close).toHaveBeenCalledWith("```md\nanswer\n```", {
482
- note: "Agent: agent",
483
- });
484
- });
485
-
486
- it("keeps core block streaming disabled when Feishu blockStreaming is explicitly false", async () => {
487
- resolveFeishuAccountMock.mockReturnValue({
488
- accountId: "main",
489
- appId: "app_id",
490
- appSecret: "app_secret",
491
- domain: "feishu",
492
- config: {
493
- renderMode: "auto",
494
- streaming: true,
495
- blockStreaming: false,
496
- },
497
- });
498
-
499
- const result = createFeishuReplyDispatcher({
500
- cfg: {} as never,
501
- agentId: "agent",
502
- runtime: {} as never,
503
- chatId: "oc_chat",
504
- });
505
-
506
- expect(result.replyOptions).toHaveProperty("disableBlockStreaming", true);
507
- });
508
-
509
- it("uses streaming session for auto mode markdown payloads", async () => {
510
- const { options } = createDispatcherHarness({
511
- runtime: createRuntimeLogger(),
512
- rootId: "om_root_topic",
513
- });
514
- await options.deliver({ text: "```ts\nconst x = 1\n```" }, { kind: "final" });
515
- await options.onIdle?.();
516
-
517
- expect(streamingInstances).toHaveLength(1);
518
- expect(streamingInstances[0].start).toHaveBeenCalledTimes(1);
519
- expectStreamingStartOptions(0, {
520
- replyToMessageId: undefined,
521
- replyInThread: undefined,
522
- rootId: "om_root_topic",
523
- header: { title: "agent", template: "blue" },
524
- note: "Agent: agent",
525
- });
526
- expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
527
- expect(sendMessageFeishuMock).not.toHaveBeenCalled();
528
- expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
529
- });
530
-
531
- it("closes streaming with block text when final reply is missing", async () => {
532
- const { options } = createDispatcherHarness({
533
- runtime: createRuntimeLogger(),
534
- });
535
- await options.deliver({ text: "```md\npartial answer\n```" }, { kind: "block" });
536
- await options.onIdle?.();
537
-
538
- expect(streamingInstances).toHaveLength(1);
539
- expect(streamingInstances[0].start).toHaveBeenCalledTimes(1);
540
- expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
541
- expect(streamingInstances[0].close).toHaveBeenCalledWith("```md\npartial answer\n```", {
542
- note: "Agent: agent",
543
- });
544
- });
545
-
546
- it("coalesces distinct final payloads into one streaming card until idle", async () => {
547
- const { options } = createDispatcherHarness({
548
- runtime: createRuntimeLogger(),
549
- });
550
- await options.deliver({ text: "```md\n完整回复第一段\n```" }, { kind: "final" });
551
- await options.deliver({ text: "```md\n完整回复第一段 + 第二段\n```" }, { kind: "final" });
552
- await options.onIdle?.();
553
-
554
- expect(streamingInstances).toHaveLength(1);
555
- expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
556
- expect(streamingInstances[0].close).toHaveBeenCalledWith(
557
- "```md\n完整回复第一段 + 第二段\n```",
558
- {
559
- note: "Agent: agent",
560
- },
561
- );
562
- expect(sendMessageFeishuMock).not.toHaveBeenCalled();
563
- expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
564
- });
565
-
566
- it("skips exact duplicate final text after streaming close", async () => {
567
- const { options } = createDispatcherHarness({
568
- runtime: createRuntimeLogger(),
569
- });
570
- await options.deliver({ text: "```md\n同一条回复\n```" }, { kind: "final" });
571
- await options.onIdle?.();
572
- await options.deliver({ text: "```md\n同一条回复\n```" }, { kind: "final" });
573
-
574
- expect(streamingInstances).toHaveLength(1);
575
- expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
576
- expect(streamingInstances[0].close).toHaveBeenCalledWith("```md\n同一条回复\n```", {
577
- note: "Agent: agent",
578
- });
579
- expect(sendMessageFeishuMock).not.toHaveBeenCalled();
580
- expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
581
- });
582
-
583
- it("skips final text already closed by idle streaming", async () => {
584
- resolveFeishuAccountMock.mockReturnValue({
585
- accountId: "main",
586
- appId: "app_id",
587
- appSecret: "app_secret",
588
- domain: "feishu",
589
- config: {
590
- renderMode: "card",
591
- streaming: true,
592
- },
593
- });
594
-
595
- const { result, options } = createDispatcherHarness({
596
- runtime: createRuntimeLogger(),
597
- });
598
-
599
- await options.onReplyStart?.();
600
- result.replyOptions.onPartialReply?.({ text: "```md\nidle streamed reply\n```" });
601
- await options.onIdle?.();
602
- await options.deliver({ text: "```md\nidle streamed reply\n```" }, { kind: "final" });
603
-
604
- expect(streamingInstances).toHaveLength(1);
605
- expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
606
- expect(streamingInstances[0].close).toHaveBeenCalledWith("```md\nidle streamed reply\n```", {
607
- note: "Agent: agent",
608
- });
609
- expect(sendMessageFeishuMock).not.toHaveBeenCalled();
610
- expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
611
- expect(sendStructuredCardFeishuMock).not.toHaveBeenCalled();
612
- });
613
-
614
- it("skips distinct late final text after streaming card close", async () => {
615
- resolveFeishuAccountMock.mockReturnValue({
616
- accountId: "main",
617
- appId: "app_id",
618
- appSecret: "app_secret",
619
- domain: "feishu",
620
- config: {
621
- renderMode: "card",
622
- streaming: true,
623
- },
624
- });
625
-
626
- const { options } = createDispatcherHarness({
627
- runtime: createRuntimeLogger(),
628
- });
629
-
630
- await options.deliver({ text: "First complete answer" }, { kind: "final" });
631
- await options.onIdle?.();
632
- await options.deliver(
633
- { text: "Late tool-result final", mediaUrl: "https://example.com/a.png" },
634
- { kind: "final" },
635
- );
636
- await options.onIdle?.();
637
-
638
- expect(streamingInstances).toHaveLength(1);
639
- expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
640
- expect(streamingInstances[0].close).toHaveBeenCalledWith("First complete answer", {
641
- note: "Agent: agent",
642
- });
643
- expect(sendMessageFeishuMock).not.toHaveBeenCalled();
644
- expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
645
- expect(sendStructuredCardFeishuMock).not.toHaveBeenCalled();
646
- expect(sendMediaFeishuMock).toHaveBeenCalledTimes(1);
647
- expectMockArgFields(sendMediaFeishuMock, "media send params", {
648
- mediaUrl: "https://example.com/a.png",
649
- });
650
- });
651
-
652
- it("suppresses duplicate final text while still sending media", async () => {
653
- const options = setupNonStreamingAutoDispatcher();
654
- await options.deliver({ text: "plain final" }, { kind: "final" });
655
- await options.deliver(
656
- { text: "plain final", mediaUrl: "https://example.com/a.png" },
657
- { kind: "final" },
658
- );
659
-
660
- expect(sendMessageFeishuMock).toHaveBeenCalledTimes(1);
661
- expectLastMockArgFields(sendMessageFeishuMock, "message send params", {
662
- text: "plain final",
663
- });
664
- expect(sendMediaFeishuMock).toHaveBeenCalledTimes(1);
665
- expectMockArgFields(sendMediaFeishuMock, "media send params", {
666
- mediaUrl: "https://example.com/a.png",
667
- });
668
- });
669
-
670
- it("keeps distinct non-streaming final payloads", async () => {
671
- const options = setupNonStreamingAutoDispatcher();
672
- await options.deliver({ text: "notice header" }, { kind: "final" });
673
- await options.deliver({ text: "actual answer body" }, { kind: "final" });
674
-
675
- expect(sendMessageFeishuMock).toHaveBeenCalledTimes(2);
676
- expectMockArgFields(sendMessageFeishuMock, "first message send params", {
677
- text: "notice header",
678
- });
679
- expectMockArgFields(
680
- sendMessageFeishuMock,
681
- "second message send params",
682
- {
683
- text: "actual answer body",
684
- },
685
- 1,
686
- );
687
- });
688
-
689
- it("treats block updates as delta chunks", async () => {
690
- resolveFeishuAccountMock.mockReturnValue({
691
- accountId: "main",
692
- appId: "app_id",
693
- appSecret: "app_secret",
694
- domain: "feishu",
695
- config: {
696
- renderMode: "card",
697
- streaming: true,
698
- },
699
- });
700
-
701
- const { result, options } = createDispatcherHarness({
702
- runtime: createRuntimeLogger(),
703
- });
704
- await options.onReplyStart?.();
705
- result.replyOptions.onPartialReply?.({ text: "hello" });
706
- await options.deliver({ text: "lo world" }, { kind: "block" });
707
- await options.onIdle?.();
708
-
709
- expect(streamingInstances).toHaveLength(1);
710
- expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
711
- expect(streamingInstances[0].close).toHaveBeenCalledWith("hellolo world", {
712
- note: "Agent: agent",
713
- });
714
- });
715
-
716
- it("skips block payloads that exactly repeat the latest partial snapshot", async () => {
717
- resolveFeishuAccountMock.mockReturnValue({
718
- accountId: "main",
719
- appId: "app_id",
720
- appSecret: "app_secret",
721
- domain: "feishu",
722
- config: {
723
- renderMode: "card",
724
- streaming: true,
725
- },
726
- });
727
-
728
- const { result, options } = createDispatcherHarness({
729
- runtime: createRuntimeLogger(),
730
- });
731
- await options.onReplyStart?.();
732
- result.replyOptions.onPartialReply?.({ text: "```md\npartial\n```" });
733
- await options.deliver({ text: "```md\npartial\n```" }, { kind: "block" });
734
- await options.onIdle?.();
735
-
736
- expect(streamingInstances).toHaveLength(1);
737
- expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
738
- expect(streamingInstances[0].close).toHaveBeenCalledWith("```md\npartial\n```", {
739
- note: "Agent: agent",
740
- });
741
- });
742
-
743
- it("preserves previous generation blocks when partial snapshots reset after tools", async () => {
744
- resolveFeishuAccountMock.mockReturnValue({
745
- accountId: "main",
746
- appId: "app_id",
747
- appSecret: "app_secret",
748
- domain: "feishu",
749
- config: {
750
- renderMode: "card",
751
- streaming: true,
752
- },
753
- });
754
-
755
- const { result, options } = createDispatcherHarness({
756
- runtime: createRuntimeLogger(),
757
- });
758
- await options.onReplyStart?.();
759
- result.replyOptions.onPartialReply?.({
760
- text: "Preparing the lookup plan with enough text to count as one block.",
761
- });
762
- result.replyOptions.onPartialReply?.({ text: "Found" });
763
- result.replyOptions.onPartialReply?.({ text: "Found the answer." });
764
- await options.onIdle?.();
765
-
766
- expect(streamingInstances).toHaveLength(1);
767
- expect(streamingInstances[0].close).toHaveBeenCalledWith(
768
- "Preparing the lookup plan with enough text to count as one block.Found the answer.",
769
- {
770
- note: "Agent: agent",
771
- },
772
- );
773
- });
774
-
775
- it("strips reasoning tags from streamed partial snapshots", async () => {
776
- resolveFeishuAccountMock.mockReturnValue({
777
- accountId: "main",
778
- appId: "app_id",
779
- appSecret: "app_secret",
780
- domain: "feishu",
781
- config: {
782
- renderMode: "card",
783
- streaming: true,
784
- },
785
- });
786
-
787
- const { result, options } = createDispatcherHarness({
788
- runtime: createRuntimeLogger(),
789
- });
790
- await options.onReplyStart?.();
791
- result.replyOptions.onPartialReply?.({
792
- text: "<thinking>private chain of thought</thinking>\nvisible answer",
793
- });
794
- await options.onIdle?.();
795
-
796
- expect(streamingInstances[0].close).toHaveBeenCalledWith("visible answer", {
797
- note: "Agent: agent",
798
- });
799
- });
800
-
801
- it("sends media-only payloads as attachments", async () => {
802
- const { options } = createDispatcherHarness();
803
- await options.deliver({ mediaUrl: "https://example.com/a.png" }, { kind: "final" });
804
-
805
- expect(sendMediaFeishuMock).toHaveBeenCalledTimes(1);
806
- expectMockArgFields(sendMediaFeishuMock, "media send params", {
807
- to: "oc_chat",
808
- mediaUrl: "https://example.com/a.png",
809
- });
810
- expect(sendMessageFeishuMock).not.toHaveBeenCalled();
811
- expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
812
- });
813
-
814
- it("passes audioAsVoice to media attachments", async () => {
815
- const { options } = createDispatcherHarness();
816
- await options.deliver(
817
- { mediaUrl: "https://example.com/reply.mp3", audioAsVoice: true },
818
- { kind: "final" },
819
- );
820
-
821
- expectMockArgFields(sendMediaFeishuMock, "media send params", {
822
- mediaUrl: "https://example.com/reply.mp3",
823
- audioAsVoice: true,
824
- });
825
- });
826
-
827
- it("suppresses duplicate text when final replies send voice media", async () => {
828
- const { options } = createDispatcherHarness();
829
- await options.deliver(
830
- {
831
- text: "spoken reply",
832
- mediaUrl: "https://example.com/reply.mp3",
833
- audioAsVoice: true,
834
- },
835
- { kind: "final" },
836
- );
837
-
838
- expect(sendMessageFeishuMock).not.toHaveBeenCalled();
839
- expect(sendStructuredCardFeishuMock).not.toHaveBeenCalled();
840
- expect(sendMediaFeishuMock).toHaveBeenCalledTimes(1);
841
- expectMockArgFields(sendMediaFeishuMock, "media send params", {
842
- mediaUrl: "https://example.com/reply.mp3",
843
- audioAsVoice: true,
844
- });
845
- });
846
-
847
- it("sends skipped voice text when final voice media degrades to a file attachment", async () => {
848
- sendMediaFeishuMock.mockResolvedValueOnce({
849
- messageId: "file_msg",
850
- voiceIntentDegradedToFile: true,
851
- });
852
-
853
- const { options } = createDispatcherHarness();
854
- await options.deliver(
855
- {
856
- text: "spoken reply",
857
- mediaUrl: "https://example.com/reply.mp3",
858
- audioAsVoice: true,
859
- },
860
- { kind: "final" },
861
- );
862
-
863
- expect(sendMediaFeishuMock).toHaveBeenCalledTimes(1);
864
- expectMockArgFields(sendMediaFeishuMock, "media send params", {
865
- mediaUrl: "https://example.com/reply.mp3",
866
- audioAsVoice: true,
867
- });
868
- expect(sendMessageFeishuMock).toHaveBeenCalledTimes(1);
869
- expectMockArgFields(sendMessageFeishuMock, "message send params", {
870
- text: "spoken reply",
871
- });
872
- });
873
-
874
- it("suppresses duplicate text for native voice media without audioAsVoice", async () => {
875
- const { options } = createDispatcherHarness();
876
- await options.deliver(
877
- {
878
- text: "spoken reply",
879
- mediaUrl: "https://example.com/reply.opus?download=1",
880
- },
881
- { kind: "final" },
882
- );
883
-
884
- expect(sendMessageFeishuMock).not.toHaveBeenCalled();
885
- expect(sendMediaFeishuMock).toHaveBeenCalledTimes(1);
886
- expectMockArgFields(sendMediaFeishuMock, "media send params", {
887
- mediaUrl: "https://example.com/reply.opus?download=1",
888
- });
889
- });
890
-
891
- it("preserves captions for regular audio attachments", async () => {
892
- const { options } = createDispatcherHarness();
893
- await options.deliver(
894
- {
895
- text: "caption text",
896
- mediaUrl: "https://example.com/song.mp3",
897
- },
898
- { kind: "final" },
899
- );
900
-
901
- expect(sendMessageFeishuMock).toHaveBeenCalledTimes(1);
902
- expectMockArgFields(sendMessageFeishuMock, "message send params", {
903
- text: "caption text",
904
- });
905
- expect(sendMediaFeishuMock).toHaveBeenCalledTimes(1);
906
- expectMockArgFields(sendMediaFeishuMock, "media send params", {
907
- mediaUrl: "https://example.com/song.mp3",
908
- });
909
- });
910
-
911
- it("keeps skipped voice text in the upload failure fallback", async () => {
912
- sendMediaFeishuMock.mockRejectedValueOnce(new Error("media failed"));
913
-
914
- const { options } = createDispatcherHarness();
915
- await options.deliver(
916
- {
917
- text: "spoken reply",
918
- mediaUrl: "https://example.com/reply.mp3",
919
- audioAsVoice: true,
920
- },
921
- { kind: "final" },
922
- );
923
-
924
- expect(sendMessageFeishuMock).toHaveBeenCalledTimes(1);
925
- expectMockArgFields(sendMessageFeishuMock, "message send params", {
926
- text: "spoken reply\n\n📎 https://example.com/reply.mp3",
927
- });
928
- });
929
-
930
- it("falls back to legacy mediaUrl when mediaUrls is an empty array", async () => {
931
- const { options } = createDispatcherHarness();
932
- await options.deliver(
933
- { text: "caption", mediaUrl: "https://example.com/a.png", mediaUrls: [] },
934
- { kind: "final" },
935
- );
936
-
937
- expect(sendMessageFeishuMock).toHaveBeenCalledTimes(1);
938
- expect(sendMediaFeishuMock).toHaveBeenCalledTimes(1);
939
- expectMockArgFields(sendMediaFeishuMock, "media send params", {
940
- mediaUrl: "https://example.com/a.png",
941
- });
942
- });
943
-
944
- it("sends attachments after streaming final markdown replies", async () => {
945
- const { options } = createDispatcherHarness({
946
- runtime: createRuntimeLogger(),
947
- });
948
- await options.deliver(
949
- { text: "```ts\nconst x = 1\n```", mediaUrls: ["https://example.com/a.png"] },
950
- { kind: "final" },
951
- );
952
- await options.onIdle?.();
953
-
954
- expect(streamingInstances).toHaveLength(1);
955
- expect(streamingInstances[0].start).toHaveBeenCalledTimes(1);
956
- expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
957
- expect(sendMediaFeishuMock).toHaveBeenCalledTimes(1);
958
- expectMockArgFields(sendMediaFeishuMock, "media send params", {
959
- mediaUrl: "https://example.com/a.png",
960
- });
961
- });
962
-
963
- it("passes replyInThread to sendMessageFeishu for plain text", async () => {
964
- const { options } = createDispatcherHarness({
965
- replyToMessageId: "om_msg",
966
- replyInThread: true,
967
- });
968
- await options.deliver({ text: "plain text" }, { kind: "final" });
969
-
970
- expectMockArgFields(sendMessageFeishuMock, "message send params", {
971
- replyToMessageId: "om_msg",
972
- replyInThread: true,
973
- });
974
- });
975
-
976
- it("allows top-level fallback for normal group quoted replies", async () => {
977
- const { options } = createDispatcherHarness({
978
- replyToMessageId: "om_quote_reply",
979
- replyInThread: true,
980
- threadReply: true,
981
- rootId: "om_original_msg",
982
- });
983
- await options.deliver({ text: "plain text" }, { kind: "final" });
984
-
985
- expectMockArgFields(sendMessageFeishuMock, "message send params", {
986
- replyToMessageId: "om_quote_reply",
987
- replyInThread: true,
988
- allowTopLevelReplyFallback: true,
989
- });
990
- });
991
-
992
- it("keeps native topic replies opted out of top-level fallback", async () => {
993
- const { options } = createDispatcherHarness({
994
- replyToMessageId: "om_topic_root",
995
- replyInThread: true,
996
- threadReply: true,
997
- rootId: "om_topic_root",
998
- });
999
- await options.deliver({ text: "plain text" }, { kind: "final" });
1000
-
1001
- expectMockArgFields(sendMessageFeishuMock, "message send params", {
1002
- replyToMessageId: "om_topic_root",
1003
- replyInThread: true,
1004
- allowTopLevelReplyFallback: false,
1005
- });
1006
- });
1007
-
1008
- it("passes replyInThread to sendStructuredCardFeishu for card text", async () => {
1009
- resolveFeishuAccountMock.mockReturnValue({
1010
- accountId: "main",
1011
- appId: "app_id",
1012
- appSecret: "app_secret",
1013
- domain: "feishu",
1014
- config: {
1015
- renderMode: "card",
1016
- streaming: false,
1017
- },
1018
- });
1019
-
1020
- const { options } = createDispatcherHarness({
1021
- replyToMessageId: "om_msg",
1022
- replyInThread: true,
1023
- });
1024
- await options.deliver({ text: "card text" }, { kind: "final" });
1025
-
1026
- expectMockArgFields(sendStructuredCardFeishuMock, "structured card params", {
1027
- replyToMessageId: "om_msg",
1028
- replyInThread: true,
1029
- });
1030
- });
1031
-
1032
- it("streams reasoning content as blockquote before answer", async () => {
1033
- const { result, options } = createDispatcherHarness({
1034
- runtime: createRuntimeLogger(),
1035
- allowReasoningPreview: true,
1036
- });
1037
-
1038
- await options.onReplyStart?.();
1039
- result.replyOptions.onReasoningStream?.({ text: "thinking step 1" });
1040
- result.replyOptions.onReasoningStream?.({
1041
- text: "thinking step 1\nstep 2",
1042
- });
1043
- result.replyOptions.onPartialReply?.({ text: "answer part" });
1044
- result.replyOptions.onReasoningEnd?.();
1045
- await options.deliver({ text: "answer part final" }, { kind: "final" });
1046
- await options.onIdle?.();
1047
-
1048
- expect(streamingInstances).toHaveLength(1);
1049
- const updateCalls = streamingInstances[0].update.mock.calls.map((c: unknown[]) =>
1050
- typeof c[0] === "string" ? c[0] : "",
1051
- );
1052
- const reasoningUpdate = updateCalls.find((c) => c.includes("Thinking"));
1053
- expect(reasoningUpdate).toContain("> 💭 **Thinking**");
1054
- // formatReasoningPrefix strips "Reasoning:" prefix and italic markers
1055
- expect(reasoningUpdate).toContain("> thinking step");
1056
- expect(reasoningUpdate).not.toContain("Reasoning:");
1057
- expect(reasoningUpdate).not.toMatch(/> _.*_/);
1058
-
1059
- const combinedUpdate = updateCalls.find((c) => c.includes("Thinking") && c.includes("---"));
1060
- if (!combinedUpdate) {
1061
- throw new Error("expected combined reasoning and final-answer streaming update");
1062
- }
1063
-
1064
- expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
1065
- const closeArg = firstStreamingCloseText();
1066
- expect(closeArg).toContain("> 💭 **Thinking**");
1067
- expect(closeArg).toContain("---");
1068
- expect(closeArg).toContain("answer part final");
1069
- });
1070
-
1071
- it("provides onReasoningStream and onReasoningEnd when reasoning previews are allowed", () => {
1072
- const { result } = createDispatcherHarness({
1073
- runtime: createRuntimeLogger(),
1074
- allowReasoningPreview: true,
1075
- });
1076
-
1077
- expect(result.replyOptions.onReasoningStream).toBeTypeOf("function");
1078
- expect(result.replyOptions.onReasoningEnd).toBeTypeOf("function");
1079
- });
1080
-
1081
- it("omits reasoning callbacks unless reasoning previews are allowed", () => {
1082
- const { result } = createDispatcherHarness({
1083
- runtime: createRuntimeLogger(),
1084
- });
1085
-
1086
- expect(result.replyOptions.onReasoningStream).toBeUndefined();
1087
- expect(result.replyOptions.onReasoningEnd).toBeUndefined();
1088
- });
1089
-
1090
- it("omits reasoning callbacks when streaming is disabled", () => {
1091
- resolveFeishuAccountMock.mockReturnValue({
1092
- accountId: "main",
1093
- appId: "app_id",
1094
- appSecret: "app_secret",
1095
- domain: "feishu",
1096
- config: {
1097
- renderMode: "auto",
1098
- streaming: false,
1099
- },
1100
- });
1101
-
1102
- const { result } = createDispatcherHarness({
1103
- runtime: createRuntimeLogger(),
1104
- });
1105
-
1106
- expect(result.replyOptions.onReasoningStream).toBeUndefined();
1107
- expect(result.replyOptions.onReasoningEnd).toBeUndefined();
1108
- });
1109
-
1110
- it("renders reasoning-only card when no answer text arrives", async () => {
1111
- const { result, options } = createDispatcherHarness({
1112
- runtime: createRuntimeLogger(),
1113
- allowReasoningPreview: true,
1114
- });
1115
-
1116
- await options.onReplyStart?.();
1117
- result.replyOptions.onReasoningStream?.({ text: "deep thought" });
1118
- result.replyOptions.onReasoningEnd?.();
1119
- await options.onIdle?.();
1120
-
1121
- expect(streamingInstances).toHaveLength(1);
1122
- expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
1123
- const closeArg = firstStreamingCloseText();
1124
- expect(closeArg).toContain("> 💭 **Thinking**");
1125
- expect(closeArg).toContain("> deep thought");
1126
- expect(closeArg).not.toContain("Reasoning:");
1127
- expect(closeArg).not.toContain("---");
1128
- });
1129
-
1130
- it("ignores empty reasoning payloads", async () => {
1131
- const { result, options } = createDispatcherHarness({
1132
- runtime: createRuntimeLogger(),
1133
- allowReasoningPreview: true,
1134
- });
1135
-
1136
- await options.onReplyStart?.();
1137
- result.replyOptions.onReasoningStream?.({ text: "" });
1138
- result.replyOptions.onPartialReply?.({ text: "```ts\ncode\n```" });
1139
- await options.deliver({ text: "```ts\ncode\n```" }, { kind: "final" });
1140
- await options.onIdle?.();
1141
-
1142
- expect(streamingInstances).toHaveLength(1);
1143
- const closeArg = firstStreamingCloseText();
1144
- expect(closeArg).not.toContain("Thinking");
1145
- expect(closeArg).toBe("```ts\ncode\n```");
1146
- });
1147
-
1148
- it("deduplicates final text by raw answer payload, not combined card text", async () => {
1149
- const { result, options } = createDispatcherHarness({
1150
- runtime: createRuntimeLogger(),
1151
- allowReasoningPreview: true,
1152
- });
1153
-
1154
- await options.onReplyStart?.();
1155
- result.replyOptions.onReasoningStream?.({ text: "thought" });
1156
- result.replyOptions.onReasoningEnd?.();
1157
- await options.deliver({ text: "```ts\nfinal answer\n```" }, { kind: "final" });
1158
- await options.onIdle?.();
1159
-
1160
- expect(streamingInstances).toHaveLength(1);
1161
- expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
1162
-
1163
- // Deliver the same raw answer text again — should be deduped
1164
- await options.deliver({ text: "```ts\nfinal answer\n```" }, { kind: "final" });
1165
-
1166
- // No second streaming session since the raw answer text matches
1167
- expect(streamingInstances).toHaveLength(1);
1168
- });
1169
-
1170
- it("passes replyToMessageId and replyInThread to streaming.start()", async () => {
1171
- const { options } = createDispatcherHarness({
1172
- runtime: createRuntimeLogger(),
1173
- replyToMessageId: "om_msg",
1174
- replyInThread: true,
1175
- });
1176
- await options.deliver({ text: "```ts\nconst x = 1\n```" }, { kind: "final" });
1177
-
1178
- expect(streamingInstances).toHaveLength(1);
1179
- expectStreamingStartOptions(0, {
1180
- replyToMessageId: "om_msg",
1181
- replyInThread: true,
1182
- header: { title: "agent", template: "blue" },
1183
- note: "Agent: agent",
1184
- });
1185
- });
1186
-
1187
- it("uses streaming cards for thread replies and keeps topic metadata", async () => {
1188
- const { options } = createDispatcherHarness({
1189
- runtime: createRuntimeLogger(),
1190
- replyToMessageId: "om_msg",
1191
- replyInThread: false,
1192
- threadReply: true,
1193
- rootId: "om_root_topic",
1194
- });
1195
- await options.deliver({ text: "```ts\nconst x = 1\n```" }, { kind: "final" });
1196
-
1197
- expect(streamingInstances).toHaveLength(1);
1198
- expectStreamingStartOptions(0, {
1199
- replyToMessageId: "om_msg",
1200
- replyInThread: true,
1201
- rootId: "om_root_topic",
1202
- });
1203
- expect(sendStructuredCardFeishuMock).not.toHaveBeenCalled();
1204
- });
1205
-
1206
- it("omits the generic main header from streaming and static cards", async () => {
1207
- resolveFeishuAccountMock.mockReturnValue({
1208
- accountId: "main",
1209
- appId: "app_id",
1210
- appSecret: "app_secret",
1211
- domain: "feishu",
1212
- config: {
1213
- renderMode: "card",
1214
- streaming: true,
1215
- },
1216
- });
1217
-
1218
- const { options } = createDispatcherHarness({
1219
- agentId: "main",
1220
- runtime: createRuntimeLogger(),
1221
- });
1222
- await options.deliver({ text: "streamed card" }, { kind: "final" });
1223
- await options.onIdle?.();
1224
-
1225
- expectStreamingStartOptions(0, {
1226
- header: undefined,
1227
- });
1228
-
1229
- resolveFeishuAccountMock.mockReturnValue({
1230
- accountId: "main",
1231
- appId: "app_id",
1232
- appSecret: "app_secret",
1233
- domain: "feishu",
1234
- config: {
1235
- renderMode: "card",
1236
- streaming: false,
1237
- },
1238
- });
1239
-
1240
- const { options: staticOptions } = createDispatcherHarness({
1241
- agentId: "main",
1242
- runtime: createRuntimeLogger(),
1243
- });
1244
- await staticOptions.deliver({ text: "static card" }, { kind: "final" });
1245
-
1246
- expectLastMockArgFields(sendStructuredCardFeishuMock, "structured card params", {
1247
- header: undefined,
1248
- });
1249
- });
1250
-
1251
- it("shows shared transient tool status on streaming cards but omits it from the final close", async () => {
1252
- resolveFeishuAccountMock.mockReturnValue({
1253
- accountId: "main",
1254
- appId: "app_id",
1255
- appSecret: "app_secret",
1256
- domain: "feishu",
1257
- config: {
1258
- renderMode: "card",
1259
- streaming: true,
1260
- },
1261
- });
1262
-
1263
- const { result, options } = createDispatcherHarness({
1264
- runtime: createRuntimeLogger(),
1265
- });
1266
- await options.onReplyStart?.();
1267
- result.replyOptions.onToolStart?.({ name: "web_search" });
1268
- result.replyOptions.onPartialReply?.({ text: "final answer" });
1269
- await options.onIdle?.();
1270
-
1271
- const updateTexts = streamingUpdateTexts();
1272
- expect(updateTexts.join("\n")).toContain("🔎 Web Search");
1273
- expect(streamingInstances[0].close).toHaveBeenCalledWith("final answer", {
1274
- note: "Agent: agent",
1275
- });
1276
- });
1277
-
1278
- it("shows raw command detail in streaming card tool status", async () => {
1279
- resolveFeishuAccountMock.mockReturnValue({
1280
- accountId: "main",
1281
- appId: "app_id",
1282
- appSecret: "app_secret",
1283
- domain: "feishu",
1284
- config: {
1285
- renderMode: "card",
1286
- streaming: true,
1287
- },
1288
- });
1289
-
1290
- const { result, options } = createDispatcherHarness({
1291
- runtime: createRuntimeLogger(),
1292
- });
1293
- await options.onReplyStart?.();
1294
- result.replyOptions.onToolStart?.({
1295
- name: "exec",
1296
- args: { command: "pnpm test -- --watch=false" },
1297
- detailMode: "raw",
1298
- });
1299
- result.replyOptions.onPartialReply?.({ text: "final answer" });
1300
- await options.onIdle?.();
1301
-
1302
- const updateTexts = streamingUpdateTexts();
1303
- expect(updateTexts.join("\n")).toContain("🛠️ run tests, `pnpm test -- --watch=false`");
1304
- });
1305
-
1306
- it("omits message-like tools from streaming card status", async () => {
1307
- resolveFeishuAccountMock.mockReturnValue({
1308
- accountId: "main",
1309
- appId: "app_id",
1310
- appSecret: "app_secret",
1311
- domain: "feishu",
1312
- config: {
1313
- renderMode: "card",
1314
- streaming: true,
1315
- },
1316
- });
1317
-
1318
- const { result, options } = createDispatcherHarness({
1319
- runtime: createRuntimeLogger(),
1320
- });
1321
- await options.onReplyStart?.();
1322
- result.replyOptions.onToolStart?.({ name: "message" });
1323
- result.replyOptions.onPartialReply?.({ text: "final answer" });
1324
- await options.onIdle?.();
1325
-
1326
- const updateTexts = streamingUpdateTexts();
1327
- expect(updateTexts.join("\n")).not.toContain("Message");
1328
- });
1329
-
1330
- it("does not suppress a later final after error closeout", async () => {
1331
- resolveFeishuAccountMock.mockReturnValue({
1332
- accountId: "main",
1333
- appId: "app_id",
1334
- appSecret: "app_secret",
1335
- domain: "feishu",
1336
- config: {
1337
- renderMode: "card",
1338
- streaming: true,
1339
- },
1340
- });
1341
- sendMediaFeishuMock.mockRejectedValueOnce(new Error("media failed"));
1342
-
1343
- const { options } = createDispatcherHarness({
1344
- runtime: createRuntimeLogger(),
1345
- });
1346
-
1347
- await expect(
1348
- options.deliver(
1349
- { text: "First answer", mediaUrl: "https://example.com/a.png" },
1350
- { kind: "final" },
1351
- ),
1352
- ).rejects.toThrow("media failed");
1353
- await Promise.all([
1354
- options.onError?.(new Error("media failed"), { kind: "final" }),
1355
- options.onIdle?.(),
1356
- ]);
1357
- await options.deliver({ text: "Second answer" }, { kind: "final" });
1358
- await options.onIdle?.();
1359
-
1360
- expect(streamingInstances).toHaveLength(2);
1361
- expect(streamingInstances[0].close).toHaveBeenCalledWith("First answer", {
1362
- note: "Agent: agent",
1363
- });
1364
- expect(streamingInstances[1].close).toHaveBeenCalledWith("Second answer", {
1365
- note: "Agent: agent",
1366
- });
1367
- expect(sendMessageFeishuMock).not.toHaveBeenCalled();
1368
- expect(sendStructuredCardFeishuMock).not.toHaveBeenCalled();
1369
- });
1370
-
1371
- it("does not suppress a recovery final after late media failure", async () => {
1372
- resolveFeishuAccountMock.mockReturnValue({
1373
- accountId: "main",
1374
- appId: "app_id",
1375
- appSecret: "app_secret",
1376
- domain: "feishu",
1377
- config: {
1378
- renderMode: "card",
1379
- streaming: true,
1380
- },
1381
- });
1382
-
1383
- const { options } = createDispatcherHarness({
1384
- runtime: createRuntimeLogger(),
1385
- });
1386
-
1387
- await options.deliver({ text: "First answer" }, { kind: "final" });
1388
- await options.onIdle?.();
1389
- sendMediaFeishuMock.mockRejectedValueOnce(new Error("media failed"));
1390
- await expect(
1391
- options.deliver(
1392
- { text: "Late attachment", mediaUrl: "https://example.com/a.png" },
1393
- { kind: "final" },
1394
- ),
1395
- ).rejects.toThrow("media failed");
1396
- await options.onError?.(new Error("media failed"), { kind: "final" });
1397
- await options.deliver({ text: "Recovered answer" }, { kind: "final" });
1398
- await options.onIdle?.();
1399
-
1400
- expect(streamingInstances).toHaveLength(2);
1401
- expect(streamingInstances[0].close).toHaveBeenCalledWith("First answer", {
1402
- note: "Agent: agent",
1403
- });
1404
- expect(streamingInstances[1].close).toHaveBeenCalledWith("Recovered answer", {
1405
- note: "Agent: agent",
1406
- });
1407
- expect(sendStructuredCardFeishuMock).not.toHaveBeenCalled();
1408
- });
1409
-
1410
- it("cleans streaming state even when close throws", async () => {
1411
- const origPush = streamingInstances.push.bind(streamingInstances);
1412
- streamingInstances.push = (...args: StreamingSessionStub[]) => {
1413
- if (args.length > 0 && streamingInstances.length === 0) {
1414
- args[0].close = vi.fn(async () => {
1415
- args[0].active = false;
1416
- throw new Error("close failed");
1417
- });
1418
- }
1419
- return origPush(...args);
1420
- };
1421
-
1422
- try {
1423
- const { options } = createDispatcherHarness({
1424
- runtime: createRuntimeLogger(),
1425
- });
1426
- await options.deliver({ text: "```md\nfirst\n```" }, { kind: "final" });
1427
- await expect(options.onIdle?.()).rejects.toThrow("close failed");
1428
- await options.deliver({ text: "```md\nsecond\n```" }, { kind: "final" });
1429
- await options.onIdle?.();
1430
-
1431
- expect(streamingInstances).toHaveLength(2);
1432
- expect(streamingInstances[1].close).toHaveBeenCalledWith("```md\nsecond\n```", {
1433
- note: "Agent: agent",
1434
- });
1435
- } finally {
1436
- streamingInstances.push = origPush;
1437
- }
1438
- });
1439
-
1440
- it("passes replyInThread to media attachments", async () => {
1441
- const { options } = createDispatcherHarness({
1442
- replyToMessageId: "om_msg",
1443
- replyInThread: true,
1444
- });
1445
- await options.deliver({ mediaUrl: "https://example.com/a.png" }, { kind: "final" });
1446
-
1447
- expectMockArgFields(sendMediaFeishuMock, "media send params", {
1448
- replyToMessageId: "om_msg",
1449
- replyInThread: true,
1450
- });
1451
- });
1452
-
1453
- it("backs off streaming retries after start() throws (HTTP 400)", async () => {
1454
- const errorMock = vi.fn();
1455
- let shouldFailStart = true;
1456
- const nowSpy = vi.spyOn(Date, "now").mockReturnValue(1_000);
1457
-
1458
- // Intercept streaming instance creation to make first start() reject
1459
- const origPush = streamingInstances.push.bind(streamingInstances);
1460
- streamingInstances.push = (...args: StreamingSessionStub[]) => {
1461
- if (shouldFailStart) {
1462
- args[0].start = vi
1463
- .fn()
1464
- .mockRejectedValue(new Error("Create card request failed with HTTP 400"));
1465
- shouldFailStart = false;
1466
- }
1467
- return origPush(...args);
1468
- };
1469
-
1470
- try {
1471
- createFeishuReplyDispatcher({
1472
- cfg: {} as never,
1473
- agentId: "agent",
1474
- runtime: { log: vi.fn(), error: errorMock } as never,
1475
- chatId: "oc_chat",
1476
- });
1477
-
1478
- const options = firstTypingDispatcherOptions();
1479
-
1480
- // First deliver with markdown triggers startStreaming - which will fail
1481
- await options.deliver({ text: "```ts\nconst x = 1\n```" }, { kind: "final" });
1482
-
1483
- // Wait for the async error to propagate
1484
- await vi.waitFor(() => {
1485
- expect(errorMock.mock.calls.map(([message]) => String(message)).join("\n")).toContain(
1486
- "streaming start failed",
1487
- );
1488
- });
1489
- expect(streamingInstances).toHaveLength(1);
1490
- expect(sendStructuredCardFeishuMock).toHaveBeenCalledTimes(1);
1491
-
1492
- // Immediate next markdown reply should skip a new streaming start and
1493
- // fall back directly to a normal card instead of paying the 400 latency.
1494
- await options.deliver({ text: "```ts\nconst y = 2\n```" }, { kind: "final" });
1495
-
1496
- expect(streamingInstances).toHaveLength(1);
1497
- expect(sendStructuredCardFeishuMock).toHaveBeenCalledTimes(2);
1498
-
1499
- // After the short backoff expires, retry streaming so fixed permissions
1500
- // or transient Feishu failures recover without a process restart.
1501
- nowSpy.mockReturnValue(62_000);
1502
- await options.deliver({ text: "```ts\nconst z = 3\n```" }, { kind: "final" });
1503
- await options.onIdle?.();
1504
-
1505
- expect(streamingInstances).toHaveLength(2);
1506
- expect(streamingInstances[1].start).toHaveBeenCalled();
1507
- expect(streamingInstances[1].close).toHaveBeenCalled();
1508
- } finally {
1509
- streamingInstances.push = origPush;
1510
- nowSpy.mockRestore();
1511
- }
1512
- });
1513
- });