@openclaw/feishu 2026.5.2-beta.2 → 2026.5.3-beta.1

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