@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,18 +1,25 @@
1
+ import { logTypingFailure } from "openclaw/plugin-sdk/channel-feedback";
2
+ import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline";
3
+ import {
4
+ resolveSendableOutboundReplyParts,
5
+ resolveTextChunksWithFallback,
6
+ sendMediaWithLeadingCaption,
7
+ } from "openclaw/plugin-sdk/reply-payload";
8
+ import { stripReasoningTagsFromText } from "openclaw/plugin-sdk/text-runtime";
9
+ import { resolveFeishuRuntimeAccount } from "./accounts.js";
10
+ import { createFeishuClient } from "./client.js";
11
+ import { sendMediaFeishu } from "./media.js";
12
+ import type { MentionTarget } from "./mention-target.types.js";
13
+ import { buildMentionedCardContent } from "./mention.js";
1
14
  import {
2
15
  createReplyPrefixContext,
3
- createTypingCallbacks,
4
- logTypingFailure,
5
16
  type ClawdbotConfig,
17
+ type OutboundIdentity,
6
18
  type ReplyPayload,
7
19
  type RuntimeEnv,
8
- } from "openclaw/plugin-sdk/feishu";
9
- import { resolveFeishuAccount } from "./accounts.js";
10
- import { createFeishuClient } from "./client.js";
11
- import { sendMediaFeishu } from "./media.js";
12
- import type { MentionTarget } from "./mention.js";
13
- import { buildMentionedCardContent } from "./mention.js";
20
+ } from "./reply-dispatcher-runtime-api.js";
14
21
  import { getFeishuRuntime } from "./runtime.js";
15
- import { sendMarkdownCardFeishu, sendMessageFeishu } from "./send.js";
22
+ import { sendMessageFeishu, sendStructuredCardFeishu, type CardHeaderConfig } from "./send.js";
16
23
  import { FeishuStreamingSession, mergeStreamingText } from "./streaming-card.js";
17
24
  import { resolveReceiveIdType } from "./targets.js";
18
25
  import { addTypingIndicator, removeTypingIndicator, type TypingIndicatorState } from "./typing.js";
@@ -26,6 +33,30 @@ function shouldUseCard(text: string): boolean {
26
33
  * Messages older than this are likely replays after context compaction (#30418). */
27
34
  const TYPING_INDICATOR_MAX_AGE_MS = 2 * 60_000;
28
35
  const MS_EPOCH_MIN = 1_000_000_000_000;
36
+ const STREAMING_START_FAILURE_BACKOFF_MS = 60_000;
37
+ const streamingStartBackoffUntilByAccount = new Map<string, number>();
38
+
39
+ function isStreamingStartBackedOff(accountId: string, now = Date.now()): boolean {
40
+ const backoffUntil = streamingStartBackoffUntilByAccount.get(accountId);
41
+ if (backoffUntil === undefined) {
42
+ return false;
43
+ }
44
+ if (backoffUntil <= now) {
45
+ streamingStartBackoffUntilByAccount.delete(accountId);
46
+ return false;
47
+ }
48
+ return true;
49
+ }
50
+
51
+ function rememberStreamingStartFailure(accountId: string, now = Date.now()): number {
52
+ const backoffUntil = now + STREAMING_START_FAILURE_BACKOFF_MS;
53
+ streamingStartBackoffUntilByAccount.set(accountId, backoffUntil);
54
+ return backoffUntil;
55
+ }
56
+
57
+ export function clearFeishuStreamingStartBackoffForTests() {
58
+ streamingStartBackoffUntilByAccount.clear();
59
+ }
29
60
 
30
61
  function normalizeEpochMs(timestamp: number | undefined): number | undefined {
31
62
  if (!Number.isFinite(timestamp) || timestamp === undefined || timestamp <= 0) {
@@ -36,11 +67,46 @@ function normalizeEpochMs(timestamp: number | undefined): number | undefined {
36
67
  return timestamp < MS_EPOCH_MIN ? timestamp * 1000 : timestamp;
37
68
  }
38
69
 
39
- export type CreateFeishuReplyDispatcherParams = {
70
+ /** Build a card header from agent identity config. */
71
+ function resolveCardHeader(
72
+ agentId: string,
73
+ identity: OutboundIdentity | undefined,
74
+ ): CardHeaderConfig | undefined {
75
+ const name = identity?.name?.trim() || (agentId === "main" ? "" : agentId);
76
+ const emoji = identity?.emoji?.trim();
77
+ const title = (emoji ? `${emoji} ${name}` : name).trim();
78
+ if (!title) {
79
+ return undefined;
80
+ }
81
+ return {
82
+ title,
83
+ template: identity?.theme ?? "blue",
84
+ };
85
+ }
86
+
87
+ /** Build a card note footer from agent identity and model context. */
88
+ function resolveCardNote(
89
+ agentId: string,
90
+ identity: OutboundIdentity | undefined,
91
+ prefixCtx: { model?: string; provider?: string },
92
+ ): string {
93
+ const name = identity?.name?.trim() || agentId;
94
+ const parts: string[] = [`Agent: ${name}`];
95
+ if (prefixCtx.model) {
96
+ parts.push(`Model: ${prefixCtx.model}`);
97
+ }
98
+ if (prefixCtx.provider) {
99
+ parts.push(`Provider: ${prefixCtx.provider}`);
100
+ }
101
+ return parts.join(" | ");
102
+ }
103
+
104
+ type CreateFeishuReplyDispatcherParams = {
40
105
  cfg: ClawdbotConfig;
41
106
  agentId: string;
42
107
  runtime: RuntimeEnv;
43
108
  chatId: string;
109
+ allowReasoningPreview?: boolean;
44
110
  replyToMessageId?: string;
45
111
  /** When true, preserve typing indicator on reply target but send messages without reply metadata */
46
112
  skipReplyToInMessages?: boolean;
@@ -50,6 +116,7 @@ export type CreateFeishuReplyDispatcherParams = {
50
116
  rootId?: string;
51
117
  mentionTargets?: MentionTarget[];
52
118
  accountId?: string;
119
+ identity?: OutboundIdentity;
53
120
  /** Epoch ms when the inbound message was created. Used to suppress typing
54
121
  * indicators on old/replayed messages after context compaction (#30418). */
55
122
  messageCreateTimeMs?: number;
@@ -68,66 +135,78 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
68
135
  rootId,
69
136
  mentionTargets,
70
137
  accountId,
138
+ identity,
71
139
  } = params;
72
140
  const sendReplyToMessageId = skipReplyToInMessages ? undefined : replyToMessageId;
73
141
  const threadReplyMode = threadReply === true;
74
142
  const effectiveReplyInThread = threadReplyMode ? true : replyInThread;
75
- const account = resolveFeishuAccount({ cfg, accountId });
143
+ const account = resolveFeishuRuntimeAccount({ cfg, accountId });
76
144
  const prefixContext = createReplyPrefixContext({ cfg, agentId });
77
145
 
78
146
  let typingState: TypingIndicatorState | null = null;
79
- const typingCallbacks = createTypingCallbacks({
80
- start: async () => {
81
- // Check if typing indicator is enabled (default: true)
82
- if (!(account.config.typingIndicator ?? true)) {
83
- return;
84
- }
85
- if (!replyToMessageId) {
86
- return;
87
- }
88
- // Skip typing indicator for old messages — likely replays after context
89
- // compaction that would flood users with stale notifications (#30418).
90
- const messageCreateTimeMs = normalizeEpochMs(params.messageCreateTimeMs);
91
- if (
92
- messageCreateTimeMs !== undefined &&
93
- Date.now() - messageCreateTimeMs > TYPING_INDICATOR_MAX_AGE_MS
94
- ) {
95
- return;
96
- }
97
- // Feishu reactions persist until explicitly removed, so skip keepalive
98
- // re-adds when a reaction already exists. Re-adding the same emoji
99
- // triggers a new push notification for every call (#28660).
100
- if (typingState?.reactionId) {
101
- return;
102
- }
103
- typingState = await addTypingIndicator({
104
- cfg,
105
- messageId: replyToMessageId,
106
- accountId,
107
- runtime: params.runtime,
108
- });
109
- },
110
- stop: async () => {
111
- if (!typingState) {
112
- return;
113
- }
114
- await removeTypingIndicator({ cfg, state: typingState, accountId, runtime: params.runtime });
115
- typingState = null;
147
+ const { typingCallbacks } = createChannelReplyPipeline({
148
+ cfg,
149
+ agentId,
150
+ channel: "feishu",
151
+ accountId,
152
+ typing: {
153
+ start: async () => {
154
+ // Check if typing indicator is enabled (default: true)
155
+ if (!(account.config.typingIndicator ?? true)) {
156
+ return;
157
+ }
158
+ if (!replyToMessageId) {
159
+ return;
160
+ }
161
+ // Skip typing indicator for old messages — likely replays after context
162
+ // compaction that would flood users with stale notifications (#30418).
163
+ const messageCreateTimeMs = normalizeEpochMs(params.messageCreateTimeMs);
164
+ if (
165
+ messageCreateTimeMs !== undefined &&
166
+ Date.now() - messageCreateTimeMs > TYPING_INDICATOR_MAX_AGE_MS
167
+ ) {
168
+ return;
169
+ }
170
+ // Feishu reactions persist until explicitly removed, so skip keepalive
171
+ // re-adds when a reaction already exists. Re-adding the same emoji
172
+ // triggers a new push notification for every call (#28660).
173
+ if (typingState?.reactionId) {
174
+ return;
175
+ }
176
+ typingState = await addTypingIndicator({
177
+ cfg,
178
+ messageId: replyToMessageId,
179
+ accountId,
180
+ runtime: params.runtime,
181
+ });
182
+ },
183
+ stop: async () => {
184
+ if (!typingState) {
185
+ return;
186
+ }
187
+ await removeTypingIndicator({
188
+ cfg,
189
+ state: typingState,
190
+ accountId,
191
+ runtime: params.runtime,
192
+ });
193
+ typingState = null;
194
+ },
195
+ onStartError: (err) =>
196
+ logTypingFailure({
197
+ log: (message) => params.runtime.log?.(message),
198
+ channel: "feishu",
199
+ action: "start",
200
+ error: err,
201
+ }),
202
+ onStopError: (err) =>
203
+ logTypingFailure({
204
+ log: (message) => params.runtime.log?.(message),
205
+ channel: "feishu",
206
+ action: "stop",
207
+ error: err,
208
+ }),
116
209
  },
117
- onStartError: (err) =>
118
- logTypingFailure({
119
- log: (message) => params.runtime.log?.(message),
120
- channel: "feishu",
121
- action: "start",
122
- error: err,
123
- }),
124
- onStopError: (err) =>
125
- logTypingFailure({
126
- log: (message) => params.runtime.log?.(message),
127
- channel: "feishu",
128
- action: "stop",
129
- error: err,
130
- }),
131
210
  });
132
211
 
133
212
  const textChunkLimit = core.channel.text.resolveTextChunkLimit(cfg, "feishu", accountId, {
@@ -136,18 +215,61 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
136
215
  const chunkMode = core.channel.text.resolveChunkMode(cfg, "feishu");
137
216
  const tableMode = core.channel.text.resolveMarkdownTableMode({ cfg, channel: "feishu" });
138
217
  const renderMode = account.config?.renderMode ?? "auto";
139
- // Card streaming may miss thread affinity in topic contexts; use direct replies there.
140
- const streamingEnabled =
141
- !threadReplyMode && account.config?.streaming !== false && renderMode !== "raw";
218
+ const streamingEnabled = account.config?.streaming !== false && renderMode !== "raw";
219
+ const reasoningPreviewEnabled = streamingEnabled && params.allowReasoningPreview === true;
142
220
 
143
221
  let streaming: FeishuStreamingSession | null = null;
144
222
  let streamText = "";
145
223
  let lastPartial = "";
224
+ let reasoningText = "";
225
+ let statusLine = "";
226
+ let snapshotBaseText = "";
227
+ let lastSnapshotTextLength = 0;
146
228
  const deliveredFinalTexts = new Set<string>();
147
229
  let partialUpdateQueue: Promise<void> = Promise.resolve();
148
230
  let streamingStartPromise: Promise<void> | null = null;
231
+ let streamingClosedForReply = false;
232
+ let streamingCloseErroredForReply = false;
149
233
  type StreamTextUpdateMode = "snapshot" | "delta";
150
234
 
235
+ const formatReasoningPrefix = (thinking: string): string => {
236
+ if (!thinking) {
237
+ return "";
238
+ }
239
+ const withoutLabel = thinking.replace(/^Reasoning:\n/, "");
240
+ const plain = withoutLabel.replace(/^_(.*)_$/gm, "$1");
241
+ const lines = plain.split("\n").map((line) => `> ${line}`);
242
+ return `> 💭 **Thinking**\n${lines.join("\n")}`;
243
+ };
244
+
245
+ const buildCombinedStreamText = (thinking: string, answer: string): string => {
246
+ const parts: string[] = [];
247
+ if (thinking) {
248
+ parts.push(formatReasoningPrefix(thinking));
249
+ }
250
+ if (thinking && answer) {
251
+ parts.push("\n\n---\n\n");
252
+ }
253
+ if (answer) {
254
+ parts.push(answer);
255
+ }
256
+ if (statusLine) {
257
+ parts.push(parts.length > 0 ? `\n\n${statusLine}` : statusLine);
258
+ }
259
+ return parts.join("");
260
+ };
261
+
262
+ const flushStreamingCardUpdate = (combined: string) => {
263
+ partialUpdateQueue = partialUpdateQueue.then(async () => {
264
+ if (streamingStartPromise) {
265
+ await streamingStartPromise;
266
+ }
267
+ if (streaming?.isActive()) {
268
+ await streaming.update(combined);
269
+ }
270
+ });
271
+ };
272
+
151
273
  const queueStreamingUpdate = (
152
274
  nextText: string,
153
275
  options?: {
@@ -165,20 +287,42 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
165
287
  lastPartial = nextText;
166
288
  }
167
289
  const mode = options?.mode ?? "snapshot";
168
- streamText =
169
- mode === "delta" ? `${streamText}${nextText}` : mergeStreamingText(streamText, nextText);
170
- partialUpdateQueue = partialUpdateQueue.then(async () => {
171
- if (streamingStartPromise) {
172
- await streamingStartPromise;
290
+ if (mode === "delta") {
291
+ streamText = `${streamText}${nextText}`;
292
+ } else {
293
+ const currentSnapshotText = snapshotBaseText
294
+ ? streamText.slice(snapshotBaseText.length)
295
+ : streamText;
296
+ const startsNewSnapshotBlock =
297
+ lastSnapshotTextLength >= 20 &&
298
+ nextText.length < lastSnapshotTextLength * 0.5 &&
299
+ !currentSnapshotText.includes(nextText);
300
+ if (startsNewSnapshotBlock) {
301
+ snapshotBaseText = streamText;
302
+ streamText = `${snapshotBaseText}${nextText}`;
303
+ } else {
304
+ streamText = `${snapshotBaseText}${mergeStreamingText(currentSnapshotText, nextText)}`;
173
305
  }
174
- if (streaming?.isActive()) {
175
- await streaming.update(streamText);
176
- }
177
- });
306
+ lastSnapshotTextLength = nextText.length;
307
+ }
308
+ flushStreamingCardUpdate(buildCombinedStreamText(reasoningText, streamText));
309
+ };
310
+
311
+ const queueReasoningUpdate = (nextThinking: string) => {
312
+ if (!nextThinking) {
313
+ return;
314
+ }
315
+ reasoningText = nextThinking;
316
+ flushStreamingCardUpdate(buildCombinedStreamText(reasoningText, streamText));
178
317
  };
179
318
 
180
319
  const startStreaming = () => {
181
- if (!streamingEnabled || streamingStartPromise || streaming) {
320
+ if (
321
+ !streamingEnabled ||
322
+ streamingStartPromise ||
323
+ streaming ||
324
+ isStreamingStartBackedOff(account.accountId)
325
+ ) {
182
326
  return;
183
327
  }
184
328
  streamingStartPromise = (async () => {
@@ -194,34 +338,115 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
194
338
  params.runtime.log?.(`feishu[${account.accountId}] ${message}`),
195
339
  );
196
340
  try {
341
+ const cardHeader = resolveCardHeader(agentId, identity);
342
+ const cardNote = resolveCardNote(agentId, identity, prefixContext.prefixContext);
197
343
  await streaming.start(chatId, resolveReceiveIdType(chatId), {
198
344
  replyToMessageId,
199
345
  replyInThread: effectiveReplyInThread,
200
346
  rootId,
347
+ header: cardHeader,
348
+ note: cardNote,
201
349
  });
350
+ streamingStartBackoffUntilByAccount.delete(account.accountId);
202
351
  } catch (error) {
203
- params.runtime.error?.(`feishu: streaming start failed: ${String(error)}`);
352
+ rememberStreamingStartFailure(account.accountId);
353
+ params.runtime.error?.(
354
+ `feishu[${account.accountId}]: streaming start failed; using non-streaming card fallback for ${
355
+ STREAMING_START_FAILURE_BACKOFF_MS / 1000
356
+ }s: ${String(error)}`,
357
+ );
204
358
  streaming = null;
359
+ streamingStartPromise = null;
205
360
  }
206
361
  })();
207
362
  };
208
363
 
209
- const closeStreaming = async () => {
210
- if (streamingStartPromise) {
211
- await streamingStartPromise;
212
- }
213
- await partialUpdateQueue;
214
- if (streaming?.isActive()) {
215
- let text = streamText;
216
- if (mentionTargets?.length) {
217
- text = buildMentionedCardContent(mentionTargets, text);
364
+ const closeStreaming = async (options?: { markClosedForReply?: boolean }) => {
365
+ try {
366
+ if (streamingStartPromise) {
367
+ await streamingStartPromise;
368
+ }
369
+ await partialUpdateQueue;
370
+ if (streaming?.isActive()) {
371
+ statusLine = "";
372
+ let text = buildCombinedStreamText(reasoningText, streamText);
373
+ if (mentionTargets?.length) {
374
+ text = buildMentionedCardContent(mentionTargets, text);
375
+ }
376
+ const finalNote = resolveCardNote(agentId, identity, prefixContext.prefixContext);
377
+ await streaming.close(text, { note: finalNote });
378
+ // Track the raw streamed text so the duplicate-final check in deliver()
379
+ // can skip the redundant text delivery that arrives after onIdle closes
380
+ // the streaming card.
381
+ if (streamText) {
382
+ deliveredFinalTexts.add(streamText);
383
+ if (options?.markClosedForReply !== false && !streamingCloseErroredForReply) {
384
+ streamingClosedForReply = true;
385
+ }
386
+ }
218
387
  }
219
- await streaming.close(text);
388
+ } finally {
389
+ streaming = null;
390
+ streamingStartPromise = null;
391
+ partialUpdateQueue = Promise.resolve();
392
+ streamText = "";
393
+ lastPartial = "";
394
+ reasoningText = "";
395
+ statusLine = "";
396
+ snapshotBaseText = "";
397
+ lastSnapshotTextLength = 0;
398
+ }
399
+ };
400
+
401
+ const updateStreamingStatusLine = (nextStatusLine: string) => {
402
+ statusLine = nextStatusLine;
403
+ if (!streaming?.isActive() && !streamingStartPromise && renderMode !== "card") {
404
+ return;
220
405
  }
221
- streaming = null;
222
- streamingStartPromise = null;
223
- streamText = "";
224
- lastPartial = "";
406
+ startStreaming();
407
+ flushStreamingCardUpdate(buildCombinedStreamText(reasoningText, streamText));
408
+ };
409
+
410
+ const sendChunkedTextReply = async (params: {
411
+ text: string;
412
+ useCard: boolean;
413
+ infoKind?: string;
414
+ sendChunk: (params: { chunk: string; isFirst: boolean }) => Promise<void>;
415
+ }) => {
416
+ const chunkSource = params.useCard
417
+ ? params.text
418
+ : core.channel.text.convertMarkdownTables(params.text, tableMode);
419
+ const chunks = resolveTextChunksWithFallback(
420
+ chunkSource,
421
+ core.channel.text.chunkTextWithMode(chunkSource, textChunkLimit, chunkMode),
422
+ );
423
+ for (const [index, chunk] of chunks.entries()) {
424
+ await params.sendChunk({
425
+ chunk,
426
+ isFirst: index === 0,
427
+ });
428
+ }
429
+ if (params.infoKind === "final") {
430
+ deliveredFinalTexts.add(params.text);
431
+ }
432
+ };
433
+
434
+ const sendMediaReplies = async (payload: ReplyPayload) => {
435
+ await sendMediaWithLeadingCaption({
436
+ mediaUrls: resolveSendableOutboundReplyParts(payload).mediaUrls,
437
+ caption: "",
438
+ send: async ({ mediaUrl }) => {
439
+ await sendMediaFeishu({
440
+ cfg,
441
+ to: chatId,
442
+ mediaUrl,
443
+ replyToMessageId: sendReplyToMessageId,
444
+ replyInThread: effectiveReplyInThread,
445
+ accountId,
446
+ ...(payload.audioAsVoice === true ? { audioAsVoice: true } : {}),
447
+ });
448
+ },
449
+ });
225
450
  };
226
451
 
227
452
  const { dispatcher, replyOptions, markDispatchIdle } =
@@ -229,34 +454,39 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
229
454
  responsePrefix: prefixContext.responsePrefix,
230
455
  responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
231
456
  humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, agentId),
232
- onReplyStart: () => {
457
+ onReplyStart: async () => {
233
458
  deliveredFinalTexts.clear();
459
+ streamingClosedForReply = false;
460
+ streamingCloseErroredForReply = false;
234
461
  if (streamingEnabled && renderMode === "card") {
235
462
  startStreaming();
236
463
  }
237
- void typingCallbacks.onReplyStart?.();
464
+ await typingCallbacks?.onReplyStart?.();
238
465
  },
239
466
  deliver: async (payload: ReplyPayload, info) => {
240
- const text = payload.text ?? "";
241
- const mediaList =
242
- payload.mediaUrls && payload.mediaUrls.length > 0
243
- ? payload.mediaUrls
244
- : payload.mediaUrl
245
- ? [payload.mediaUrl]
246
- : [];
247
- const hasText = Boolean(text.trim());
248
- const hasMedia = mediaList.length > 0;
467
+ const reply = resolveSendableOutboundReplyParts(payload);
468
+ const text = reply.text;
469
+ const hasText = reply.hasText;
470
+ const hasMedia = reply.hasMedia;
471
+ const useCard =
472
+ hasText && (renderMode === "card" || (renderMode === "auto" && shouldUseCard(text)));
249
473
  const skipTextForDuplicateFinal =
250
474
  info?.kind === "final" && hasText && deliveredFinalTexts.has(text);
251
- const shouldDeliverText = hasText && !skipTextForDuplicateFinal;
475
+ const skipTextForClosedStreamingFinal =
476
+ info?.kind === "final" &&
477
+ hasText &&
478
+ streamingClosedForReply &&
479
+ !streamingCloseErroredForReply &&
480
+ streamingEnabled &&
481
+ useCard;
482
+ const shouldDeliverText =
483
+ hasText && !skipTextForDuplicateFinal && !skipTextForClosedStreamingFinal;
252
484
 
253
485
  if (!shouldDeliverText && !hasMedia) {
254
486
  return;
255
487
  }
256
488
 
257
489
  if (shouldDeliverText) {
258
- const useCard = renderMode === "card" || (renderMode === "auto" && shouldUseCard(text));
259
-
260
490
  if (info?.kind === "block") {
261
491
  // Drop internal block chunks unless we can safely consume them as
262
492
  // streaming-card fallback content.
@@ -280,100 +510,81 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
280
510
  if (info?.kind === "block") {
281
511
  // Some runtimes emit block payloads without onPartial/final callbacks.
282
512
  // Mirror block text into streamText so onIdle close still sends content.
283
- queueStreamingUpdate(text, { mode: "delta" });
513
+ queueStreamingUpdate(text, { mode: "delta", dedupeWithLastPartial: true });
284
514
  }
285
515
  if (info?.kind === "final") {
286
- streamText = mergeStreamingText(streamText, text);
287
- await closeStreaming();
288
- deliveredFinalTexts.add(text);
516
+ streamText = text;
517
+ snapshotBaseText = "";
518
+ lastSnapshotTextLength = text.length;
519
+ flushStreamingCardUpdate(buildCombinedStreamText(reasoningText, streamText));
289
520
  }
290
521
  // Send media even when streaming handled the text
291
522
  if (hasMedia) {
292
- for (const mediaUrl of mediaList) {
293
- await sendMediaFeishu({
294
- cfg,
295
- to: chatId,
296
- mediaUrl,
297
- replyToMessageId: sendReplyToMessageId,
298
- replyInThread: effectiveReplyInThread,
299
- accountId,
300
- });
301
- }
523
+ await sendMediaReplies(payload);
302
524
  }
303
525
  return;
304
526
  }
305
527
 
306
- let first = true;
307
528
  if (useCard) {
308
- for (const chunk of core.channel.text.chunkTextWithMode(
529
+ const cardHeader = resolveCardHeader(agentId, identity);
530
+ const cardNote = resolveCardNote(agentId, identity, prefixContext.prefixContext);
531
+ await sendChunkedTextReply({
309
532
  text,
310
- textChunkLimit,
311
- chunkMode,
312
- )) {
313
- await sendMarkdownCardFeishu({
314
- cfg,
315
- to: chatId,
316
- text: chunk,
317
- replyToMessageId: sendReplyToMessageId,
318
- replyInThread: effectiveReplyInThread,
319
- mentions: first ? mentionTargets : undefined,
320
- accountId,
321
- });
322
- first = false;
323
- }
324
- if (info?.kind === "final") {
325
- deliveredFinalTexts.add(text);
326
- }
533
+ useCard: true,
534
+ infoKind: info?.kind,
535
+ sendChunk: async ({ chunk, isFirst }) => {
536
+ await sendStructuredCardFeishu({
537
+ cfg,
538
+ to: chatId,
539
+ text: chunk,
540
+ replyToMessageId: sendReplyToMessageId,
541
+ replyInThread: effectiveReplyInThread,
542
+ mentions: isFirst ? mentionTargets : undefined,
543
+ accountId,
544
+ header: cardHeader,
545
+ note: cardNote,
546
+ });
547
+ },
548
+ });
327
549
  } else {
328
- const converted = core.channel.text.convertMarkdownTables(text, tableMode);
329
- for (const chunk of core.channel.text.chunkTextWithMode(
330
- converted,
331
- textChunkLimit,
332
- chunkMode,
333
- )) {
334
- await sendMessageFeishu({
335
- cfg,
336
- to: chatId,
337
- text: chunk,
338
- replyToMessageId: sendReplyToMessageId,
339
- replyInThread: effectiveReplyInThread,
340
- mentions: first ? mentionTargets : undefined,
341
- accountId,
342
- });
343
- first = false;
344
- }
345
- if (info?.kind === "final") {
346
- deliveredFinalTexts.add(text);
347
- }
550
+ await sendChunkedTextReply({
551
+ text,
552
+ useCard: false,
553
+ infoKind: info?.kind,
554
+ sendChunk: async ({ chunk, isFirst }) => {
555
+ await sendMessageFeishu({
556
+ cfg,
557
+ to: chatId,
558
+ text: chunk,
559
+ replyToMessageId: sendReplyToMessageId,
560
+ replyInThread: effectiveReplyInThread,
561
+ mentions: isFirst ? mentionTargets : undefined,
562
+ accountId,
563
+ });
564
+ },
565
+ });
348
566
  }
349
567
  }
350
568
 
351
569
  if (hasMedia) {
352
- for (const mediaUrl of mediaList) {
353
- await sendMediaFeishu({
354
- cfg,
355
- to: chatId,
356
- mediaUrl,
357
- replyToMessageId: sendReplyToMessageId,
358
- replyInThread: effectiveReplyInThread,
359
- accountId,
360
- });
361
- }
570
+ await sendMediaReplies(payload);
362
571
  }
363
572
  },
364
573
  onError: async (error, info) => {
574
+ streamingCloseErroredForReply = true;
575
+ streamingClosedForReply = false;
365
576
  params.runtime.error?.(
366
577
  `feishu[${account.accountId}] ${info.kind} reply failed: ${String(error)}`,
367
578
  );
368
- await closeStreaming();
369
- typingCallbacks.onIdle?.();
579
+ await closeStreaming({ markClosedForReply: false });
580
+ typingCallbacks?.onIdle?.();
370
581
  },
371
582
  onIdle: async () => {
372
583
  await closeStreaming();
373
- typingCallbacks.onIdle?.();
584
+ typingCallbacks?.onIdle?.();
374
585
  },
375
586
  onCleanup: () => {
376
- typingCallbacks.onCleanup?.();
587
+ typingCallbacks?.onCleanup?.();
377
588
  },
378
589
  });
379
590
 
@@ -388,12 +599,51 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
388
599
  if (!payload.text) {
389
600
  return;
390
601
  }
391
- queueStreamingUpdate(payload.text, {
602
+ const cleaned = stripReasoningTagsFromText(payload.text, {
603
+ mode: "strict",
604
+ trim: "both",
605
+ });
606
+ if (!cleaned) {
607
+ return;
608
+ }
609
+ queueStreamingUpdate(cleaned, {
392
610
  dedupeWithLastPartial: true,
393
611
  mode: "snapshot",
394
612
  });
395
613
  }
396
614
  : undefined,
615
+ onReasoningStream: reasoningPreviewEnabled
616
+ ? (payload: ReplyPayload) => {
617
+ if (!payload.text) {
618
+ return;
619
+ }
620
+ startStreaming();
621
+ queueReasoningUpdate(payload.text);
622
+ }
623
+ : undefined,
624
+ onReasoningEnd: reasoningPreviewEnabled ? () => {} : undefined,
625
+ onToolStart: streamingEnabled
626
+ ? (payload: { name?: string; phase?: string }) => {
627
+ updateStreamingStatusLine(
628
+ `🔧 **Using: ${payload.name ?? payload.phase ?? "tool"}...**`,
629
+ );
630
+ }
631
+ : undefined,
632
+ onAssistantMessageStart: streamingEnabled
633
+ ? () => {
634
+ updateStreamingStatusLine("");
635
+ }
636
+ : undefined,
637
+ onCompactionStart: streamingEnabled
638
+ ? () => {
639
+ updateStreamingStatusLine("📦 **Compacting context...**");
640
+ }
641
+ : undefined,
642
+ onCompactionEnd: streamingEnabled
643
+ ? () => {
644
+ updateStreamingStatusLine("");
645
+ }
646
+ : undefined,
397
647
  },
398
648
  markDispatchIdle,
399
649
  };