@openclaw/feishu 2026.3.12 → 2026.5.1-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (188) hide show
  1. package/api.ts +31 -0
  2. package/channel-entry.ts +20 -0
  3. package/channel-plugin-api.ts +1 -0
  4. package/contract-api.ts +16 -0
  5. package/index.ts +70 -53
  6. package/openclaw.plugin.json +1653 -4
  7. package/package.json +32 -7
  8. package/runtime-api.ts +55 -0
  9. package/secret-contract-api.ts +5 -0
  10. package/security-contract-api.ts +1 -0
  11. package/session-key-api.ts +1 -0
  12. package/setup-api.ts +3 -0
  13. package/setup-entry.test.ts +14 -0
  14. package/setup-entry.ts +13 -0
  15. package/src/accounts.test.ts +115 -22
  16. package/src/accounts.ts +199 -117
  17. package/src/app-registration.ts +331 -0
  18. package/src/approval-auth.test.ts +24 -0
  19. package/src/approval-auth.ts +25 -0
  20. package/src/async.test.ts +35 -0
  21. package/src/async.ts +43 -1
  22. package/src/audio-preflight.runtime.ts +9 -0
  23. package/src/bitable.test.ts +131 -0
  24. package/src/bitable.ts +59 -22
  25. package/src/bot-content.ts +474 -0
  26. package/src/bot-group-name.test.ts +108 -0
  27. package/src/bot-runtime-api.ts +12 -0
  28. package/src/bot-sender-name.ts +125 -0
  29. package/src/bot.broadcast.test.ts +463 -0
  30. package/src/bot.card-action.test.ts +519 -5
  31. package/src/bot.checkBotMentioned.test.ts +92 -20
  32. package/src/bot.helpers.test.ts +118 -0
  33. package/src/bot.stripBotMention.test.ts +13 -21
  34. package/src/bot.test.ts +1334 -401
  35. package/src/bot.ts +798 -786
  36. package/src/card-action.ts +408 -40
  37. package/src/card-interaction.test.ts +129 -0
  38. package/src/card-interaction.ts +159 -0
  39. package/src/card-test-helpers.ts +47 -0
  40. package/src/card-ux-approval.ts +65 -0
  41. package/src/card-ux-launcher.test.ts +99 -0
  42. package/src/card-ux-launcher.ts +121 -0
  43. package/src/card-ux-shared.ts +33 -0
  44. package/src/channel-runtime-api.ts +16 -0
  45. package/src/channel.runtime.ts +47 -0
  46. package/src/channel.test.ts +914 -3
  47. package/src/channel.ts +1252 -309
  48. package/src/chat-schema.ts +5 -4
  49. package/src/chat.test.ts +84 -28
  50. package/src/chat.ts +68 -10
  51. package/src/client.test.ts +212 -103
  52. package/src/client.ts +115 -21
  53. package/src/comment-dispatcher-runtime-api.ts +6 -0
  54. package/src/comment-dispatcher.test.ts +169 -0
  55. package/src/comment-dispatcher.ts +107 -0
  56. package/src/comment-handler-runtime-api.ts +3 -0
  57. package/src/comment-handler.test.ts +486 -0
  58. package/src/comment-handler.ts +309 -0
  59. package/src/comment-reaction.test.ts +166 -0
  60. package/src/comment-reaction.ts +259 -0
  61. package/src/comment-shared.test.ts +182 -0
  62. package/src/comment-shared.ts +365 -0
  63. package/src/comment-target.ts +44 -0
  64. package/src/config-schema.test.ts +77 -25
  65. package/src/config-schema.ts +31 -4
  66. package/src/conversation-id.test.ts +18 -0
  67. package/src/conversation-id.ts +199 -0
  68. package/src/dedup-runtime-api.ts +1 -0
  69. package/src/dedup.ts +76 -35
  70. package/src/directory.static.ts +61 -0
  71. package/src/directory.test.ts +119 -20
  72. package/src/directory.ts +61 -91
  73. package/src/doc-schema.ts +1 -1
  74. package/src/docx-batch-insert.test.ts +39 -38
  75. package/src/docx-batch-insert.ts +55 -19
  76. package/src/docx-color-text.ts +9 -4
  77. package/src/docx-table-ops.test.ts +53 -0
  78. package/src/docx-table-ops.ts +52 -34
  79. package/src/docx-types.ts +38 -0
  80. package/src/docx.account-selection.test.ts +12 -3
  81. package/src/docx.test.ts +314 -74
  82. package/src/docx.ts +278 -122
  83. package/src/drive-schema.ts +47 -1
  84. package/src/drive.test.ts +1219 -0
  85. package/src/drive.ts +614 -13
  86. package/src/dynamic-agent.ts +10 -4
  87. package/src/event-types.ts +45 -0
  88. package/src/external-keys.ts +1 -1
  89. package/src/lifecycle.test-support.ts +220 -0
  90. package/src/media.test.ts +413 -87
  91. package/src/media.ts +488 -154
  92. package/src/mention-target.types.ts +5 -0
  93. package/src/mention.ts +32 -51
  94. package/src/message-action-contract.ts +13 -0
  95. package/src/monitor-state-runtime-api.ts +7 -0
  96. package/src/monitor-transport-runtime-api.ts +7 -0
  97. package/src/monitor.account.ts +220 -313
  98. package/src/monitor.acp-init-failure.lifecycle.test-support.ts +219 -0
  99. package/src/monitor.bot-identity.ts +86 -0
  100. package/src/monitor.bot-menu-handler.ts +165 -0
  101. package/src/monitor.bot-menu.lifecycle.test-support.ts +224 -0
  102. package/src/monitor.bot-menu.test.ts +178 -0
  103. package/src/monitor.broadcast.reply-once.lifecycle.test-support.ts +264 -0
  104. package/src/monitor.card-action.lifecycle.test-support.ts +373 -0
  105. package/src/monitor.cleanup.test.ts +376 -0
  106. package/src/monitor.comment-notice-handler.ts +105 -0
  107. package/src/monitor.comment.test.ts +937 -0
  108. package/src/monitor.comment.ts +1386 -0
  109. package/src/monitor.lifecycle.test.ts +4 -0
  110. package/src/monitor.message-handler.ts +339 -0
  111. package/src/monitor.reaction.lifecycle.test-support.ts +68 -0
  112. package/src/monitor.reaction.test.ts +194 -92
  113. package/src/monitor.reply-once.lifecycle.test-support.ts +190 -0
  114. package/src/monitor.startup.test.ts +24 -36
  115. package/src/monitor.startup.ts +26 -16
  116. package/src/monitor.state.ts +20 -5
  117. package/src/monitor.synthetic-error.ts +18 -0
  118. package/src/monitor.test-mocks.ts +2 -2
  119. package/src/monitor.transport.ts +297 -39
  120. package/src/monitor.ts +15 -10
  121. package/src/monitor.webhook-e2e.test.ts +272 -0
  122. package/src/monitor.webhook-security.test.ts +125 -91
  123. package/src/monitor.webhook.test-helpers.ts +116 -0
  124. package/src/outbound-runtime-api.ts +1 -0
  125. package/src/outbound.test.ts +627 -53
  126. package/src/outbound.ts +623 -81
  127. package/src/perm-schema.ts +1 -1
  128. package/src/perm.ts +1 -7
  129. package/src/pins.ts +108 -0
  130. package/src/policy.test.ts +297 -117
  131. package/src/policy.ts +142 -29
  132. package/src/post.ts +7 -6
  133. package/src/probe.test.ts +122 -118
  134. package/src/probe.ts +26 -16
  135. package/src/processing-claims.ts +59 -0
  136. package/src/qr-terminal.ts +1 -0
  137. package/src/reactions.ts +23 -60
  138. package/src/reasoning-preview.test.ts +59 -0
  139. package/src/reasoning-preview.ts +20 -0
  140. package/src/reply-dispatcher-runtime-api.ts +7 -0
  141. package/src/reply-dispatcher.test.ts +721 -168
  142. package/src/reply-dispatcher.ts +422 -172
  143. package/src/runtime.ts +6 -3
  144. package/src/secret-contract.ts +145 -0
  145. package/src/secret-input.ts +1 -13
  146. package/src/security-audit-shared.ts +69 -0
  147. package/src/security-audit.test.ts +61 -0
  148. package/src/security-audit.ts +1 -0
  149. package/src/send-result.ts +1 -1
  150. package/src/send-target.test.ts +9 -3
  151. package/src/send-target.ts +10 -4
  152. package/src/send.reply-fallback.test.ts +127 -42
  153. package/src/send.test.ts +386 -4
  154. package/src/send.ts +486 -164
  155. package/src/sequential-key.test.ts +72 -0
  156. package/src/sequential-key.ts +28 -0
  157. package/src/sequential-queue.test.ts +92 -0
  158. package/src/sequential-queue.ts +16 -0
  159. package/src/session-conversation.ts +42 -0
  160. package/src/session-route.ts +48 -0
  161. package/src/setup-core.ts +51 -0
  162. package/src/{onboarding.test.ts → setup-surface.test.ts} +52 -21
  163. package/src/setup-surface.ts +581 -0
  164. package/src/streaming-card.test.ts +138 -2
  165. package/src/streaming-card.ts +134 -18
  166. package/src/subagent-hooks.test.ts +603 -0
  167. package/src/subagent-hooks.ts +397 -0
  168. package/src/targets.ts +3 -13
  169. package/src/test-support/lifecycle-test-support.ts +479 -0
  170. package/src/thread-bindings.test.ts +143 -0
  171. package/src/thread-bindings.ts +330 -0
  172. package/src/tool-account-routing.test.ts +66 -8
  173. package/src/tool-account.test.ts +44 -0
  174. package/src/tool-account.ts +40 -17
  175. package/src/tool-factory-test-harness.ts +11 -8
  176. package/src/tool-result.ts +3 -1
  177. package/src/tools-config.ts +1 -1
  178. package/src/types.ts +16 -15
  179. package/src/typing.ts +10 -6
  180. package/src/wiki-schema.ts +1 -1
  181. package/src/wiki.ts +1 -7
  182. package/subagent-hooks-api.ts +31 -0
  183. package/tsconfig.json +16 -0
  184. package/src/feishu-command-handler.ts +0 -59
  185. package/src/onboarding.status.test.ts +0 -25
  186. package/src/onboarding.ts +0 -489
  187. package/src/send-message.ts +0 -71
  188. package/src/targets.test.ts +0 -70
@@ -1,22 +1,62 @@
1
1
  import { beforeEach, describe, expect, it, vi } from "vitest";
2
2
 
3
+ type StreamingSessionStub = {
4
+ active: boolean;
5
+ start: ReturnType<typeof vi.fn>;
6
+ update: ReturnType<typeof vi.fn>;
7
+ close: ReturnType<typeof vi.fn>;
8
+ isActive: ReturnType<typeof vi.fn>;
9
+ };
10
+
3
11
  const resolveFeishuAccountMock = vi.hoisted(() => vi.fn());
4
12
  const getFeishuRuntimeMock = vi.hoisted(() => vi.fn());
5
13
  const sendMessageFeishuMock = vi.hoisted(() => vi.fn());
6
14
  const sendMarkdownCardFeishuMock = vi.hoisted(() => vi.fn());
15
+ const sendStructuredCardFeishuMock = vi.hoisted(() => vi.fn());
7
16
  const sendMediaFeishuMock = vi.hoisted(() => vi.fn());
8
17
  const createFeishuClientMock = vi.hoisted(() => vi.fn());
9
18
  const resolveReceiveIdTypeMock = vi.hoisted(() => vi.fn());
10
19
  const createReplyDispatcherWithTypingMock = vi.hoisted(() => vi.fn());
11
20
  const addTypingIndicatorMock = vi.hoisted(() => vi.fn(async () => ({ messageId: "om_msg" })));
12
21
  const removeTypingIndicatorMock = vi.hoisted(() => vi.fn(async () => {}));
13
- const streamingInstances = vi.hoisted(() => [] as any[]);
22
+ const streamingInstances = vi.hoisted((): StreamingSessionStub[] => []);
23
+
24
+ function mergeStreamingText(
25
+ previousText: string | undefined,
26
+ nextText: string | undefined,
27
+ ): string {
28
+ const previous = typeof previousText === "string" ? previousText : "";
29
+ const next = typeof nextText === "string" ? nextText : "";
30
+ if (!next) {
31
+ return previous;
32
+ }
33
+ if (!previous || next === previous) {
34
+ return next;
35
+ }
36
+ if (next.startsWith(previous) || next.includes(previous)) {
37
+ return next;
38
+ }
39
+ if (previous.startsWith(next) || previous.includes(next)) {
40
+ return previous;
41
+ }
42
+ const maxOverlap = Math.min(previous.length, next.length);
43
+ for (let overlap = maxOverlap; overlap > 0; overlap -= 1) {
44
+ if (previous.slice(-overlap) === next.slice(0, overlap)) {
45
+ return `${previous}${next.slice(overlap)}`;
46
+ }
47
+ }
48
+ return `${previous}${next}`;
49
+ }
14
50
 
15
- vi.mock("./accounts.js", () => ({ resolveFeishuAccount: resolveFeishuAccountMock }));
51
+ vi.mock("./accounts.js", () => ({
52
+ resolveFeishuAccount: resolveFeishuAccountMock,
53
+ resolveFeishuRuntimeAccount: resolveFeishuAccountMock,
54
+ }));
16
55
  vi.mock("./runtime.js", () => ({ getFeishuRuntime: getFeishuRuntimeMock }));
17
56
  vi.mock("./send.js", () => ({
18
57
  sendMessageFeishu: sendMessageFeishuMock,
19
58
  sendMarkdownCardFeishu: sendMarkdownCardFeishuMock,
59
+ sendStructuredCardFeishu: sendStructuredCardFeishuMock,
20
60
  }));
21
61
  vi.mock("./media.js", () => ({ sendMediaFeishu: sendMediaFeishuMock }));
22
62
  vi.mock("./client.js", () => ({ createFeishuClient: createFeishuClientMock }));
@@ -25,48 +65,41 @@ vi.mock("./typing.js", () => ({
25
65
  addTypingIndicator: addTypingIndicatorMock,
26
66
  removeTypingIndicator: removeTypingIndicatorMock,
27
67
  }));
28
- vi.mock("./streaming-card.js", () => ({
29
- mergeStreamingText: (previousText: string | undefined, nextText: string | undefined) => {
30
- const previous = typeof previousText === "string" ? previousText : "";
31
- const next = typeof nextText === "string" ? nextText : "";
32
- if (!next) {
33
- return previous;
34
- }
35
- if (!previous || next === previous) {
36
- return next;
37
- }
38
- if (next.startsWith(previous)) {
39
- return next;
40
- }
41
- if (previous.startsWith(next)) {
42
- return previous;
43
- }
44
- return `${previous}${next}`;
45
- },
46
- FeishuStreamingSession: class {
47
- active = false;
48
- start = vi.fn(async () => {
49
- this.active = true;
50
- });
51
- update = vi.fn(async () => {});
52
- close = vi.fn(async () => {
53
- this.active = false;
54
- });
55
- isActive = vi.fn(() => this.active);
56
-
57
- constructor() {
58
- streamingInstances.push(this);
59
- }
60
- },
61
- }));
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
+ });
62
88
 
63
- import { createFeishuReplyDispatcher } from "./reply-dispatcher.js";
89
+ import {
90
+ clearFeishuStreamingStartBackoffForTests,
91
+ createFeishuReplyDispatcher,
92
+ } from "./reply-dispatcher.js";
64
93
 
65
94
  describe("createFeishuReplyDispatcher streaming behavior", () => {
95
+ type ReplyDispatcherArgs = Parameters<typeof createFeishuReplyDispatcher>[0];
96
+
66
97
  beforeEach(() => {
67
98
  vi.clearAllMocks();
99
+ clearFeishuStreamingStartBackoffForTests();
68
100
  streamingInstances.length = 0;
69
101
  sendMediaFeishuMock.mockResolvedValue(undefined);
102
+ sendStructuredCardFeishuMock.mockResolvedValue(undefined);
70
103
 
71
104
  resolveFeishuAccountMock.mockReturnValue({
72
105
  accountId: "main",
@@ -128,6 +161,25 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
128
161
  return createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
129
162
  }
130
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
+
131
183
  it("skips typing indicator when account typingIndicator is disabled", async () => {
132
184
  resolveFeishuAccountMock.mockReturnValue({
133
185
  accountId: "main",
@@ -209,14 +261,7 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
209
261
  });
210
262
 
211
263
  it("keeps auto mode plain text on non-streaming send path", async () => {
212
- createFeishuReplyDispatcher({
213
- cfg: {} as never,
214
- agentId: "agent",
215
- runtime: {} as never,
216
- chatId: "oc_chat",
217
- });
218
-
219
- const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
264
+ const { options } = createDispatcherHarness();
220
265
  await options.deliver({ text: "plain text" }, { kind: "final" });
221
266
 
222
267
  expect(streamingInstances).toHaveLength(0);
@@ -225,14 +270,7 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
225
270
  });
226
271
 
227
272
  it("suppresses internal block payload delivery", async () => {
228
- createFeishuReplyDispatcher({
229
- cfg: {} as never,
230
- agentId: "agent",
231
- runtime: {} as never,
232
- chatId: "oc_chat",
233
- });
234
-
235
- const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
273
+ const { options } = createDispatcherHarness();
236
274
  await options.deliver({ text: "internal reasoning chunk" }, { kind: "block" });
237
275
 
238
276
  expect(streamingInstances).toHaveLength(0);
@@ -253,86 +291,154 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
253
291
  });
254
292
 
255
293
  it("uses streaming session for auto mode markdown payloads", async () => {
256
- createFeishuReplyDispatcher({
257
- cfg: {} as never,
258
- agentId: "agent",
259
- runtime: { log: vi.fn(), error: vi.fn() } as never,
260
- chatId: "oc_chat",
294
+ const { options } = createDispatcherHarness({
295
+ runtime: createRuntimeLogger(),
261
296
  rootId: "om_root_topic",
262
297
  });
263
-
264
- const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
265
298
  await options.deliver({ text: "```ts\nconst x = 1\n```" }, { kind: "final" });
299
+ await options.onIdle?.();
266
300
 
267
301
  expect(streamingInstances).toHaveLength(1);
268
302
  expect(streamingInstances[0].start).toHaveBeenCalledTimes(1);
269
- expect(streamingInstances[0].start).toHaveBeenCalledWith("oc_chat", "chat_id", {
270
- replyToMessageId: undefined,
271
- replyInThread: undefined,
272
- rootId: "om_root_topic",
273
- });
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
+ );
274
314
  expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
275
315
  expect(sendMessageFeishuMock).not.toHaveBeenCalled();
276
316
  expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
277
317
  });
278
318
 
279
319
  it("closes streaming with block text when final reply is missing", async () => {
280
- createFeishuReplyDispatcher({
281
- cfg: {} as never,
282
- agentId: "agent",
283
- runtime: { log: vi.fn(), error: vi.fn() } as never,
284
- chatId: "oc_chat",
320
+ const { options } = createDispatcherHarness({
321
+ runtime: createRuntimeLogger(),
285
322
  });
286
-
287
- const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
288
323
  await options.deliver({ text: "```md\npartial answer\n```" }, { kind: "block" });
289
324
  await options.onIdle?.();
290
325
 
291
326
  expect(streamingInstances).toHaveLength(1);
292
327
  expect(streamingInstances[0].start).toHaveBeenCalledTimes(1);
293
328
  expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
294
- expect(streamingInstances[0].close).toHaveBeenCalledWith("```md\npartial answer\n```");
329
+ expect(streamingInstances[0].close).toHaveBeenCalledWith("```md\npartial answer\n```", {
330
+ note: "Agent: agent",
331
+ });
295
332
  });
296
333
 
297
- it("delivers distinct final payloads after streaming close", async () => {
298
- createFeishuReplyDispatcher({
299
- cfg: {} as never,
300
- agentId: "agent",
301
- runtime: { log: vi.fn(), error: vi.fn() } as never,
302
- chatId: "oc_chat",
334
+ it("coalesces distinct final payloads into one streaming card until idle", async () => {
335
+ const { options } = createDispatcherHarness({
336
+ runtime: createRuntimeLogger(),
303
337
  });
304
-
305
- const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
306
338
  await options.deliver({ text: "```md\n完整回复第一段\n```" }, { kind: "final" });
307
339
  await options.deliver({ text: "```md\n完整回复第一段 + 第二段\n```" }, { kind: "final" });
340
+ await options.onIdle?.();
308
341
 
309
- expect(streamingInstances).toHaveLength(2);
342
+ expect(streamingInstances).toHaveLength(1);
310
343
  expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
311
- expect(streamingInstances[0].close).toHaveBeenCalledWith("```md\n完整回复第一段\n```");
312
- expect(streamingInstances[1].close).toHaveBeenCalledTimes(1);
313
- expect(streamingInstances[1].close).toHaveBeenCalledWith("```md\n完整回复第一段 + 第二段\n```");
344
+ expect(streamingInstances[0].close).toHaveBeenCalledWith(
345
+ "```md\n完整回复第一段 + 第二段\n```",
346
+ {
347
+ note: "Agent: agent",
348
+ },
349
+ );
314
350
  expect(sendMessageFeishuMock).not.toHaveBeenCalled();
315
351
  expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
316
352
  });
317
353
 
318
354
  it("skips exact duplicate final text after streaming close", async () => {
319
- createFeishuReplyDispatcher({
320
- cfg: {} as never,
321
- agentId: "agent",
322
- runtime: { log: vi.fn(), error: vi.fn() } as never,
323
- chatId: "oc_chat",
355
+ const { options } = createDispatcherHarness({
356
+ runtime: createRuntimeLogger(),
324
357
  });
325
-
326
- const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
327
358
  await options.deliver({ text: "```md\n同一条回复\n```" }, { kind: "final" });
359
+ await options.onIdle?.();
328
360
  await options.deliver({ text: "```md\n同一条回复\n```" }, { kind: "final" });
329
361
 
330
362
  expect(streamingInstances).toHaveLength(1);
331
363
  expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
332
- expect(streamingInstances[0].close).toHaveBeenCalledWith("```md\n同一条回复\n```");
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
+ });
333
397
  expect(sendMessageFeishuMock).not.toHaveBeenCalled();
334
398
  expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
399
+ expect(sendStructuredCardFeishuMock).not.toHaveBeenCalled();
335
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
+
336
442
  it("suppresses duplicate final text while still sending media", async () => {
337
443
  const options = setupNonStreamingAutoDispatcher();
338
444
  await options.deliver({ text: "plain final" }, { kind: "final" });
@@ -383,33 +489,108 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
383
489
  },
384
490
  });
385
491
 
386
- const result = createFeishuReplyDispatcher({
387
- cfg: {} as never,
388
- agentId: "agent",
389
- runtime: { log: vi.fn(), error: vi.fn() } as never,
390
- chatId: "oc_chat",
492
+ const { result, options } = createDispatcherHarness({
493
+ runtime: createRuntimeLogger(),
391
494
  });
392
-
393
- const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
394
495
  await options.onReplyStart?.();
395
- await result.replyOptions.onPartialReply?.({ text: "hello" });
496
+ result.replyOptions.onPartialReply?.({ text: "hello" });
396
497
  await options.deliver({ text: "lo world" }, { kind: "block" });
397
498
  await options.onIdle?.();
398
499
 
399
500
  expect(streamingInstances).toHaveLength(1);
400
501
  expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
401
- expect(streamingInstances[0].close).toHaveBeenCalledWith("hellolo world");
502
+ expect(streamingInstances[0].close).toHaveBeenCalledWith("hellolo world", {
503
+ note: "Agent: agent",
504
+ });
402
505
  });
403
506
 
404
- it("sends media-only payloads as attachments", async () => {
405
- createFeishuReplyDispatcher({
406
- cfg: {} as never,
407
- agentId: "agent",
408
- runtime: {} as never,
409
- chatId: "oc_chat",
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
+ },
410
517
  });
411
518
 
412
- const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
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();
413
594
  await options.deliver({ mediaUrl: "https://example.com/a.png" }, { kind: "final" });
414
595
 
415
596
  expect(sendMediaFeishuMock).toHaveBeenCalledTimes(1);
@@ -423,15 +604,23 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
423
604
  expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
424
605
  });
425
606
 
426
- it("falls back to legacy mediaUrl when mediaUrls is an empty array", async () => {
427
- createFeishuReplyDispatcher({
428
- cfg: {} as never,
429
- agentId: "agent",
430
- runtime: {} as never,
431
- chatId: "oc_chat",
432
- });
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
+ );
433
613
 
434
- const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
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();
435
624
  await options.deliver(
436
625
  { text: "caption", mediaUrl: "https://example.com/a.png", mediaUrls: [] },
437
626
  { kind: "final" },
@@ -447,18 +636,14 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
447
636
  });
448
637
 
449
638
  it("sends attachments after streaming final markdown replies", async () => {
450
- createFeishuReplyDispatcher({
451
- cfg: {} as never,
452
- agentId: "agent",
453
- runtime: { log: vi.fn(), error: vi.fn() } as never,
454
- chatId: "oc_chat",
639
+ const { options } = createDispatcherHarness({
640
+ runtime: createRuntimeLogger(),
455
641
  });
456
-
457
- const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
458
642
  await options.deliver(
459
643
  { text: "```ts\nconst x = 1\n```", mediaUrls: ["https://example.com/a.png"] },
460
644
  { kind: "final" },
461
645
  );
646
+ await options.onIdle?.();
462
647
 
463
648
  expect(streamingInstances).toHaveLength(1);
464
649
  expect(streamingInstances[0].start).toHaveBeenCalledTimes(1);
@@ -472,16 +657,10 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
472
657
  });
473
658
 
474
659
  it("passes replyInThread to sendMessageFeishu for plain text", async () => {
475
- createFeishuReplyDispatcher({
476
- cfg: {} as never,
477
- agentId: "agent",
478
- runtime: {} as never,
479
- chatId: "oc_chat",
660
+ const { options } = createDispatcherHarness({
480
661
  replyToMessageId: "om_msg",
481
662
  replyInThread: true,
482
663
  });
483
-
484
- const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
485
664
  await options.deliver({ text: "plain text" }, { kind: "final" });
486
665
 
487
666
  expect(sendMessageFeishuMock).toHaveBeenCalledWith(
@@ -492,7 +671,7 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
492
671
  );
493
672
  });
494
673
 
495
- it("passes replyInThread to sendMarkdownCardFeishu for card text", async () => {
674
+ it("passes replyInThread to sendStructuredCardFeishu for card text", async () => {
496
675
  resolveFeishuAccountMock.mockReturnValue({
497
676
  accountId: "main",
498
677
  appId: "app_id",
@@ -504,19 +683,13 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
504
683
  },
505
684
  });
506
685
 
507
- createFeishuReplyDispatcher({
508
- cfg: {} as never,
509
- agentId: "agent",
510
- runtime: {} as never,
511
- chatId: "oc_chat",
686
+ const { options } = createDispatcherHarness({
512
687
  replyToMessageId: "om_msg",
513
688
  replyInThread: true,
514
689
  });
515
-
516
- const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
517
690
  await options.deliver({ text: "card text" }, { kind: "final" });
518
691
 
519
- expect(sendMarkdownCardFeishuMock).toHaveBeenCalledWith(
692
+ expect(sendStructuredCardFeishuMock).toHaveBeenCalledWith(
520
693
  expect.objectContaining({
521
694
  replyToMessageId: "om_msg",
522
695
  replyInThread: true,
@@ -524,61 +697,382 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
524
697
  );
525
698
  });
526
699
 
527
- it("passes replyToMessageId and replyInThread to streaming.start()", async () => {
528
- createFeishuReplyDispatcher({
529
- cfg: {} as never,
530
- agentId: "agent",
531
- runtime: { log: vi.fn(), error: vi.fn() } as never,
532
- chatId: "oc_chat",
533
- replyToMessageId: "om_msg",
534
- replyInThread: true,
700
+ it("streams reasoning content as blockquote before answer", async () => {
701
+ const { result, options } = createDispatcherHarness({
702
+ runtime: createRuntimeLogger(),
703
+ allowReasoningPreview: true,
535
704
  });
536
705
 
537
- const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
538
- await options.deliver({ text: "```ts\nconst x = 1\n```" }, { kind: "final" });
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?.();
539
808
 
540
809
  expect(streamingInstances).toHaveLength(1);
541
- expect(streamingInstances[0].start).toHaveBeenCalledWith("oc_chat", "chat_id", {
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(),
542
840
  replyToMessageId: "om_msg",
543
841
  replyInThread: true,
544
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
+ );
545
856
  });
546
857
 
547
- it("disables streaming for thread replies and keeps reply metadata", async () => {
548
- createFeishuReplyDispatcher({
549
- cfg: {} as never,
550
- agentId: "agent",
551
- runtime: { log: vi.fn(), error: vi.fn() } as never,
552
- chatId: "oc_chat",
858
+ it("uses streaming cards for thread replies and keeps topic metadata", async () => {
859
+ const { options } = createDispatcherHarness({
860
+ runtime: createRuntimeLogger(),
553
861
  replyToMessageId: "om_msg",
554
862
  replyInThread: false,
555
863
  threadReply: true,
556
864
  rootId: "om_root_topic",
557
865
  });
558
-
559
- const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
560
866
  await options.deliver({ text: "```ts\nconst x = 1\n```" }, { kind: "final" });
561
867
 
562
- expect(streamingInstances).toHaveLength(0);
563
- expect(sendMarkdownCardFeishuMock).toHaveBeenCalledWith(
868
+ expect(streamingInstances).toHaveLength(1);
869
+ expect(streamingInstances[0].start).toHaveBeenCalledWith(
870
+ "oc_chat",
871
+ "chat_id",
564
872
  expect.objectContaining({
565
873
  replyToMessageId: "om_msg",
566
874
  replyInThread: true,
875
+ rootId: "om_root_topic",
567
876
  }),
568
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
+ }
569
1069
  });
570
1070
 
571
1071
  it("passes replyInThread to media attachments", async () => {
572
- createFeishuReplyDispatcher({
573
- cfg: {} as never,
574
- agentId: "agent",
575
- runtime: {} as never,
576
- chatId: "oc_chat",
1072
+ const { options } = createDispatcherHarness({
577
1073
  replyToMessageId: "om_msg",
578
1074
  replyInThread: true,
579
1075
  });
580
-
581
- const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
582
1076
  await options.deliver({ mediaUrl: "https://example.com/a.png" }, { kind: "final" });
583
1077
 
584
1078
  expect(sendMediaFeishuMock).toHaveBeenCalledWith(
@@ -588,4 +1082,63 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
588
1082
  }),
589
1083
  );
590
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
+ });
591
1144
  });