@openclaw/feishu 2026.3.13 → 2026.5.2-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 (187) 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 +1827 -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 +95 -7
  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 +778 -775
  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 +1253 -309
  48. package/src/chat-schema.ts +5 -4
  49. package/src/chat.test.ts +135 -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 +406 -0
  63. package/src/comment-target.ts +44 -0
  64. package/src/config-schema.test.ts +63 -1
  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 +33 -95
  70. package/src/directory.static.ts +61 -0
  71. package/src/directory.test.ts +116 -20
  72. package/src/directory.ts +60 -92
  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 +403 -26
  91. package/src/media.ts +509 -132
  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 +218 -312
  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 +108 -48
  113. package/src/monitor.startup.test.ts +11 -9
  114. package/src/monitor.startup.ts +26 -16
  115. package/src/monitor.state.ts +20 -5
  116. package/src/monitor.synthetic-error.ts +18 -0
  117. package/src/monitor.test-mocks.ts +2 -2
  118. package/src/monitor.transport.ts +220 -60
  119. package/src/monitor.ts +15 -10
  120. package/src/monitor.webhook-e2e.test.ts +65 -7
  121. package/src/monitor.webhook-security.test.ts +122 -0
  122. package/src/monitor.webhook.test-helpers.ts +44 -26
  123. package/src/outbound-runtime-api.ts +1 -0
  124. package/src/outbound.test.ts +616 -37
  125. package/src/outbound.ts +623 -81
  126. package/src/perm-schema.ts +1 -1
  127. package/src/perm.ts +1 -7
  128. package/src/pins.ts +108 -0
  129. package/src/policy.test.ts +297 -117
  130. package/src/policy.ts +142 -29
  131. package/src/post.ts +7 -6
  132. package/src/probe.test.ts +14 -9
  133. package/src/probe.ts +26 -16
  134. package/src/processing-claims.ts +59 -0
  135. package/src/qr-terminal.ts +1 -0
  136. package/src/reactions.ts +4 -34
  137. package/src/reasoning-preview.test.ts +59 -0
  138. package/src/reasoning-preview.ts +20 -0
  139. package/src/reply-dispatcher-runtime-api.ts +7 -0
  140. package/src/reply-dispatcher.test.ts +660 -29
  141. package/src/reply-dispatcher.ts +407 -154
  142. package/src/runtime.ts +6 -3
  143. package/src/secret-contract.ts +145 -0
  144. package/src/secret-input.ts +1 -13
  145. package/src/security-audit-shared.ts +69 -0
  146. package/src/security-audit.test.ts +61 -0
  147. package/src/security-audit.ts +1 -0
  148. package/src/send-result.ts +1 -1
  149. package/src/send-target.test.ts +9 -3
  150. package/src/send-target.ts +10 -4
  151. package/src/send.reply-fallback.test.ts +105 -2
  152. package/src/send.test.ts +386 -4
  153. package/src/send.ts +414 -95
  154. package/src/sequential-key.test.ts +72 -0
  155. package/src/sequential-key.ts +28 -0
  156. package/src/sequential-queue.test.ts +92 -0
  157. package/src/sequential-queue.ts +16 -0
  158. package/src/session-conversation.ts +42 -0
  159. package/src/session-route.ts +48 -0
  160. package/src/setup-core.ts +51 -0
  161. package/src/{onboarding.test.ts → setup-surface.test.ts} +52 -21
  162. package/src/setup-surface.ts +581 -0
  163. package/src/streaming-card.test.ts +138 -2
  164. package/src/streaming-card.ts +134 -18
  165. package/src/subagent-hooks.test.ts +603 -0
  166. package/src/subagent-hooks.ts +397 -0
  167. package/src/targets.ts +3 -13
  168. package/src/test-support/lifecycle-test-support.ts +453 -0
  169. package/src/thread-bindings.test.ts +143 -0
  170. package/src/thread-bindings.ts +330 -0
  171. package/src/tool-account-routing.test.ts +66 -8
  172. package/src/tool-account.test.ts +44 -0
  173. package/src/tool-account.ts +40 -17
  174. package/src/tool-factory-test-harness.ts +11 -8
  175. package/src/tool-result.ts +3 -1
  176. package/src/tools-config.ts +1 -1
  177. package/src/types.ts +16 -15
  178. package/src/typing.ts +10 -6
  179. package/src/wiki-schema.ts +1 -1
  180. package/src/wiki.ts +1 -7
  181. package/subagent-hooks-api.ts +31 -0
  182. package/tsconfig.json +16 -0
  183. package/src/feishu-command-handler.ts +0 -59
  184. package/src/onboarding.status.test.ts +0 -25
  185. package/src/onboarding.ts +0 -489
  186. package/src/send-message.ts +0 -71
  187. 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;
173
- }
174
- if (streaming?.isActive()) {
175
- await streaming.update(streamText);
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)}`;
176
305
  }
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,104 +338,155 @@ 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;
218
368
  }
219
- await streaming.close(text);
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
+ }
387
+ }
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));
225
408
  };
226
409
 
227
410
  const sendChunkedTextReply = async (params: {
228
411
  text: string;
229
412
  useCard: boolean;
230
413
  infoKind?: string;
414
+ sendChunk: (params: { chunk: string; isFirst: boolean }) => Promise<void>;
231
415
  }) => {
232
- let first = true;
233
416
  const chunkSource = params.useCard
234
417
  ? params.text
235
418
  : core.channel.text.convertMarkdownTables(params.text, tableMode);
236
- for (const chunk of core.channel.text.chunkTextWithMode(
419
+ const chunks = resolveTextChunksWithFallback(
237
420
  chunkSource,
238
- textChunkLimit,
239
- chunkMode,
240
- )) {
241
- const message = {
242
- cfg,
243
- to: chatId,
244
- text: chunk,
245
- replyToMessageId: sendReplyToMessageId,
246
- replyInThread: effectiveReplyInThread,
247
- mentions: first ? mentionTargets : undefined,
248
- accountId,
249
- };
250
- if (params.useCard) {
251
- await sendMarkdownCardFeishu(message);
252
- } else {
253
- await sendMessageFeishu(message);
254
- }
255
- first = false;
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
+ });
256
428
  }
257
429
  if (params.infoKind === "final") {
258
430
  deliveredFinalTexts.add(params.text);
259
431
  }
260
432
  };
261
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
+ });
450
+ };
451
+
262
452
  const { dispatcher, replyOptions, markDispatchIdle } =
263
453
  core.channel.reply.createReplyDispatcherWithTyping({
264
454
  responsePrefix: prefixContext.responsePrefix,
265
455
  responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
266
456
  humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, agentId),
267
- onReplyStart: () => {
457
+ onReplyStart: async () => {
268
458
  deliveredFinalTexts.clear();
459
+ streamingClosedForReply = false;
460
+ streamingCloseErroredForReply = false;
269
461
  if (streamingEnabled && renderMode === "card") {
270
462
  startStreaming();
271
463
  }
272
- void typingCallbacks.onReplyStart?.();
464
+ await typingCallbacks?.onReplyStart?.();
273
465
  },
274
466
  deliver: async (payload: ReplyPayload, info) => {
275
- const text = payload.text ?? "";
276
- const mediaList =
277
- payload.mediaUrls && payload.mediaUrls.length > 0
278
- ? payload.mediaUrls
279
- : payload.mediaUrl
280
- ? [payload.mediaUrl]
281
- : [];
282
- const hasText = Boolean(text.trim());
283
- 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)));
284
473
  const skipTextForDuplicateFinal =
285
474
  info?.kind === "final" && hasText && deliveredFinalTexts.has(text);
286
- 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;
287
484
 
288
485
  if (!shouldDeliverText && !hasMedia) {
289
486
  return;
290
487
  }
291
488
 
292
489
  if (shouldDeliverText) {
293
- const useCard = renderMode === "card" || (renderMode === "auto" && shouldUseCard(text));
294
-
295
490
  if (info?.kind === "block") {
296
491
  // Drop internal block chunks unless we can safely consume them as
297
492
  // streaming-card fallback content.
@@ -315,62 +510,81 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
315
510
  if (info?.kind === "block") {
316
511
  // Some runtimes emit block payloads without onPartial/final callbacks.
317
512
  // Mirror block text into streamText so onIdle close still sends content.
318
- queueStreamingUpdate(text, { mode: "delta" });
513
+ queueStreamingUpdate(text, { mode: "delta", dedupeWithLastPartial: true });
319
514
  }
320
515
  if (info?.kind === "final") {
321
- streamText = mergeStreamingText(streamText, text);
322
- await closeStreaming();
323
- deliveredFinalTexts.add(text);
516
+ streamText = text;
517
+ snapshotBaseText = "";
518
+ lastSnapshotTextLength = text.length;
519
+ flushStreamingCardUpdate(buildCombinedStreamText(reasoningText, streamText));
324
520
  }
325
521
  // Send media even when streaming handled the text
326
522
  if (hasMedia) {
327
- for (const mediaUrl of mediaList) {
328
- await sendMediaFeishu({
523
+ await sendMediaReplies(payload);
524
+ }
525
+ return;
526
+ }
527
+
528
+ if (useCard) {
529
+ const cardHeader = resolveCardHeader(agentId, identity);
530
+ const cardNote = resolveCardNote(agentId, identity, prefixContext.prefixContext);
531
+ await sendChunkedTextReply({
532
+ text,
533
+ useCard: true,
534
+ infoKind: info?.kind,
535
+ sendChunk: async ({ chunk, isFirst }) => {
536
+ await sendStructuredCardFeishu({
329
537
  cfg,
330
538
  to: chatId,
331
- mediaUrl,
539
+ text: chunk,
332
540
  replyToMessageId: sendReplyToMessageId,
333
541
  replyInThread: effectiveReplyInThread,
542
+ mentions: isFirst ? mentionTargets : undefined,
334
543
  accountId,
544
+ header: cardHeader,
545
+ note: cardNote,
335
546
  });
336
- }
337
- }
338
- return;
339
- }
340
-
341
- if (useCard) {
342
- await sendChunkedTextReply({ text, useCard: true, infoKind: info?.kind });
547
+ },
548
+ });
343
549
  } else {
344
- await sendChunkedTextReply({ text, useCard: false, infoKind: info?.kind });
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
+ });
345
566
  }
346
567
  }
347
568
 
348
569
  if (hasMedia) {
349
- for (const mediaUrl of mediaList) {
350
- await sendMediaFeishu({
351
- cfg,
352
- to: chatId,
353
- mediaUrl,
354
- replyToMessageId: sendReplyToMessageId,
355
- replyInThread: effectiveReplyInThread,
356
- accountId,
357
- });
358
- }
570
+ await sendMediaReplies(payload);
359
571
  }
360
572
  },
361
573
  onError: async (error, info) => {
574
+ streamingCloseErroredForReply = true;
575
+ streamingClosedForReply = false;
362
576
  params.runtime.error?.(
363
577
  `feishu[${account.accountId}] ${info.kind} reply failed: ${String(error)}`,
364
578
  );
365
- await closeStreaming();
366
- typingCallbacks.onIdle?.();
579
+ await closeStreaming({ markClosedForReply: false });
580
+ typingCallbacks?.onIdle?.();
367
581
  },
368
582
  onIdle: async () => {
369
583
  await closeStreaming();
370
- typingCallbacks.onIdle?.();
584
+ typingCallbacks?.onIdle?.();
371
585
  },
372
586
  onCleanup: () => {
373
- typingCallbacks.onCleanup?.();
587
+ typingCallbacks?.onCleanup?.();
374
588
  },
375
589
  });
376
590
 
@@ -385,12 +599,51 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
385
599
  if (!payload.text) {
386
600
  return;
387
601
  }
388
- 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, {
389
610
  dedupeWithLastPartial: true,
390
611
  mode: "snapshot",
391
612
  });
392
613
  }
393
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,
394
647
  },
395
648
  markDispatchIdle,
396
649
  };